From 796639fefa99724d0bf74a020e6f6dddd80b5336 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 28 May 2026 23:07:16 -0400 Subject: [PATCH] feat(http): route smart-HTTP through the auth.Authorizer; drop allowPush field Replace the allowPush bool gate in resolve with a call to d.authz.Authorize, extracting HTTP Basic credentials via credFromRequest. Remove the now-redundant allowPush field from the daemon struct and all daemon literals in tests. Add TestSmartHTTPAnonymousReadWhilePushDisabled to verify reads succeed when writes are denied. --- cmd/objgitd/example_hook_test.go | 1 - cmd/objgitd/git_protocol.go | 7 +++-- cmd/objgitd/git_protocol_test.go | 14 +++++----- cmd/objgitd/hooks_test.go | 2 -- cmd/objgitd/http.go | 45 ++++++++++++++++++++++++-------- cmd/objgitd/http_test.go | 40 +++++++++++++++++++++++++--- cmd/objgitd/main.go | 1 - 7 files changed, 80 insertions(+), 30 deletions(-) diff --git a/cmd/objgitd/example_hook_test.go b/cmd/objgitd/example_hook_test.go index 92175f2..716ee12 100644 --- a/cmd/objgitd/example_hook_test.go +++ b/cmd/objgitd/example_hook_test.go @@ -40,7 +40,6 @@ func TestExampleHookRuns(t *testing.T) { fs: fs, loader: transport.NewFilesystemLoader(fs, false), authz: auth.AllowAnonymous{AllowWrite: true}, - allowPush: true, allowHooks: true, hookTimeout: 30 * time.Second, } diff --git a/cmd/objgitd/git_protocol.go b/cmd/objgitd/git_protocol.go index 43ef84e..1cd4641 100644 --- a/cmd/objgitd/git_protocol.go +++ b/cmd/objgitd/git_protocol.go @@ -55,10 +55,9 @@ func operationFor(service string) auth.Operation { // daemon serves the git:// (TCP) protocol out of a billy filesystem. type daemon struct { - fs billy.Filesystem - loader transport.Loader - authz auth.Authorizer - allowPush bool + fs billy.Filesystem + loader transport.Loader + authz auth.Authorizer // allowHooks gates running .objgit/hooks/receive-pack after a push. allowHooks bool diff --git a/cmd/objgitd/git_protocol_test.go b/cmd/objgitd/git_protocol_test.go index 06cc5b0..b9708cf 100644 --- a/cmd/objgitd/git_protocol_test.go +++ b/cmd/objgitd/git_protocol_test.go @@ -25,10 +25,9 @@ func TestDaemonPushCreatesRepo(t *testing.T) { fs := memfs.New() d := &daemon{ - fs: fs, - loader: transport.NewFilesystemLoader(fs, false), - authz: auth.AllowAnonymous{AllowWrite: true}, - allowPush: true, + fs: fs, + loader: transport.NewFilesystemLoader(fs, false), + authz: auth.AllowAnonymous{AllowWrite: true}, } ctx, cancel := context.WithCancel(context.Background()) @@ -83,10 +82,9 @@ func TestDaemonPushDisabled(t *testing.T) { fs := memfs.New() d := &daemon{ - fs: fs, - loader: transport.NewFilesystemLoader(fs, false), - authz: auth.AllowAnonymous{AllowWrite: false}, - allowPush: false, + fs: fs, + loader: transport.NewFilesystemLoader(fs, false), + authz: auth.AllowAnonymous{AllowWrite: false}, } ctx, cancel := context.WithCancel(context.Background()) diff --git a/cmd/objgitd/hooks_test.go b/cmd/objgitd/hooks_test.go index 1567f34..f1b92c4 100644 --- a/cmd/objgitd/hooks_test.go +++ b/cmd/objgitd/hooks_test.go @@ -109,7 +109,6 @@ func TestReceivePackHook(t *testing.T) { fs: fs, loader: transport.NewFilesystemLoader(fs, false), authz: auth.AllowAnonymous{AllowWrite: true}, - allowPush: true, allowHooks: true, hookTimeout: 30 * time.Second, } @@ -187,7 +186,6 @@ func TestReceivePackHookAbsent(t *testing.T) { fs: fs, loader: transport.NewFilesystemLoader(fs, false), authz: auth.AllowAnonymous{AllowWrite: true}, - allowPush: true, allowHooks: true, hookTimeout: 30 * time.Second, } diff --git a/cmd/objgitd/http.go b/cmd/objgitd/http.go index 4379afe..eb030ab 100644 --- a/cmd/objgitd/http.go +++ b/cmd/objgitd/http.go @@ -13,6 +13,7 @@ import ( "github.com/go-git/go-git/v6/plumbing/transport" "github.com/go-git/go-git/v6/storage" "github.com/go-git/go-git/v6/utils/ioutil" + "tangled.org/xeiaso.net/objgit/internal/auth" ) // ServeHTTP speaks the git smart-HTTP protocol. It dispatches on the URL suffix @@ -44,7 +45,7 @@ func (d *daemon) handleInfoRefs(w http.ResponseWriter, r *http.Request, repoPath return } - st, ok := d.resolve(w, service, repoPath) + st, ok := d.resolve(w, r, service, repoPath) if !ok { return } @@ -86,7 +87,7 @@ func (d *daemon) handleInfoRefs(w http.ResponseWriter, r *http.Request, repoPath // handleRPC serves a stateless negotiation round: // POST /{repo}/git-(upload|receive)-pack. func (d *daemon) handleRPC(w http.ResponseWriter, r *http.Request, service, repoPath string) { - st, ok := d.resolve(w, service, repoPath) + st, ok := d.resolve(w, r, service, repoPath) if !ok { return } @@ -136,16 +137,28 @@ func (d *daemon) handleRPC(w http.ResponseWriter, r *http.Request, service, repo } } -// resolve loads the storer for an HTTP request, applying the same rules as the -// git:// handler: anonymous read, push gated by allowPush, and create-on-first- -// push. It writes an HTTP error and returns ok=false when the request cannot -// proceed. -func (d *daemon) resolve(w http.ResponseWriter, service, repoPath string) (storage.Storer, bool) { +// resolve loads the storer for an HTTP request, authorizing via the daemon's +// Authorizer before touching the repository. It writes an HTTP error and +// returns ok=false when the request cannot proceed. +func (d *daemon) resolve(w http.ResponseWriter, r *http.Request, service, repoPath string) (storage.Storer, bool) { + switch d.authz.Authorize(r.Context(), auth.Request{ + Repo: repoPath, + Operation: operationFor(service), + Cred: credFromRequest(r), + Transport: "http", + }) { + case auth.Allow: + // authorized; fall through to repo resolution + case auth.Unauthenticated: + w.Header().Set("WWW-Authenticate", `Basic realm="objgit"`) + http.Error(w, "authentication required", http.StatusUnauthorized) + return nil, false + default: // auth.Deny + http.Error(w, "access denied", http.StatusForbidden) + return nil, false + } + if service == transport.ReceivePackService { - if !d.allowPush { - http.Error(w, "push is disabled on this server", http.StatusForbidden) - return nil, false - } st, err := d.loadOrInit(repoPath) if err != nil { slog.Error("opening repository for push", "path", repoPath, "err", err) @@ -167,3 +180,13 @@ func (d *daemon) resolve(w http.ResponseWriter, service, repoPath string) (stora } return st, true } + +// credFromRequest extracts an auth credential from an HTTP request: HTTP Basic +// if present, otherwise anonymous. It does not validate — the Authorizer owns +// the user store. +func credFromRequest(r *http.Request) auth.Credential { + if u, p, ok := r.BasicAuth(); ok { + return auth.BasicAuth{Username: u, Password: p} + } + return auth.Anonymous{} +} diff --git a/cmd/objgitd/http_test.go b/cmd/objgitd/http_test.go index 5ffcce7..732b668 100644 --- a/cmd/objgitd/http_test.go +++ b/cmd/objgitd/http_test.go @@ -10,6 +10,7 @@ import ( "github.com/go-git/go-billy/v6" "github.com/go-git/go-billy/v6/memfs" "github.com/go-git/go-git/v6/plumbing/transport" + "tangled.org/xeiaso.net/objgit/internal/auth" ) // TestSmartHTTP drives a real git client against the smart-HTTP handler over an @@ -103,15 +104,48 @@ func newHTTPServer(t *testing.T, allowPush bool) (*httptest.Server, billy.Filesy t.Helper() fs := memfs.New() d := &daemon{ - fs: fs, - loader: transport.NewFilesystemLoader(fs, false), - allowPush: allowPush, + fs: fs, + loader: transport.NewFilesystemLoader(fs, false), + authz: auth.AllowAnonymous{AllowWrite: allowPush}, } ts := httptest.NewServer(d) t.Cleanup(ts.Close) return ts, fs } +// TestSmartHTTPAnonymousReadWhilePushDisabled verifies that with push disabled, +// anonymous clone of an existing repo still succeeds — reads are always allowed +// by the default authorizer, only writes are gated. +func TestSmartHTTPAnonymousReadWhilePushDisabled(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + // Seed a repo via a push-enabled server over a shared filesystem. + fs := memfs.New() + seed := httptest.NewServer(&daemon{fs: fs, loader: transport.NewFilesystemLoader(fs, false), authz: auth.AllowAnonymous{AllowWrite: true}}) + defer seed.Close() + + work := seedRepo(t) + srcHead := strings.TrimSpace(runGit(t, work, "rev-parse", "HEAD")) + if out, err := tryGit(work, "push", seed.URL+"/test.git", "main"); err != nil { + t.Fatalf("seed push failed: %v\n%s", err, out) + } + + // Serve the same filesystem with push disabled and clone from it. + ro := httptest.NewServer(&daemon{fs: fs, loader: transport.NewFilesystemLoader(fs, false), authz: auth.AllowAnonymous{AllowWrite: false}}) + defer ro.Close() + + dst := t.TempDir() + if out, err := tryGit(dst, "clone", ro.URL+"/test.git", "cloned"); err != nil { + t.Fatalf("anonymous clone should succeed with push disabled: %v\n%s", err, out) + } + gotHead := strings.TrimSpace(runGit(t, filepath.Join(dst, "cloned"), "rev-parse", "HEAD")) + if gotHead != srcHead { + t.Errorf("cloned HEAD %q != seeded HEAD %q", gotHead, srcHead) + } +} + // seedRepo creates a local git repository with one commit and returns its path. func seedRepo(t *testing.T) string { t.Helper() diff --git a/cmd/objgitd/main.go b/cmd/objgitd/main.go index 31a1281..9f25f2c 100644 --- a/cmd/objgitd/main.go +++ b/cmd/objgitd/main.go @@ -75,7 +75,6 @@ func main() { fs: fsys, loader: transport.NewFilesystemLoader(fsys, false), authz: auth.AllowAnonymous{AllowWrite: *allowPush}, - allowPush: *allowPush, allowHooks: *allowHooks, hookTimeout: *hookTimeout, } -- 2.51.2