From 6414203b223dceaca8d71e40de16118766a0aaef Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 10:10:28 +0100 Subject: [PATCH 1/2] Document server features and enforce authentication across Git HTTP endpoints --- README.md | 13 ++++++ server/auth_test.go | 92 +++++++++++++++++++++++++++++++++++++++ server/repository_test.go | 28 ++++++++++-- server/server.go | 21 ++++----- 4 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 server/auth_test.go diff --git a/README.md b/README.md index a645c54..48391ae 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,19 @@ Grack was written to allow far more webservers to handle Git smart http requests. The aim of this project is to improve Git smart http performance by utilising the power of Go. +## Features + +- Embeddable Go server implementing `http.Handler`, with independent configuration per instance. +- Git Smart HTTP clone, fetch, and push using native `git upload-pack` and `git receive-pack`. +- Bare repository creation in-process with go-git, including cleanup on initialization failure. +- Pluggable `Store` interface for opening, creating, deleting, checking, and listing repositories. +- Filesystem storage with nested repository namespaces and path traversal/symlink checks. +- HTTP Basic authentication across repository requests, configurable route prefixes, and Git command customization. +- Standalone command-line server and backward-compatible `server.Handler()` entry point. + +Custom stores currently need to expose a local repository path. Direct remote +object storage and a fully Go-based push/fetch engine are not yet implemented. + ## Dependencies - Go >= 1.25 to build or embed the server diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 0000000..6f252a9 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,92 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestAuthenticationProtectsAllRepositoryEndpoints(t *testing.T) { + endpoints := []struct{ method, path string }{ + {"GET", "/example.git/info/refs?service=git-upload-pack"}, + {"GET", "/example.git/info/refs?service=git-receive-pack"}, + {"POST", "/example.git/git-upload-pack"}, + {"POST", "/example.git/git-receive-pack"}, + {"GET", "/example.git/info/refs"}, + {"GET", "/example.git/HEAD"}, + {"GET", "/example.git/objects/info/alternates"}, + {"GET", "/example.git/objects/info/http-alternates"}, + {"GET", "/example.git/objects/info/packs"}, + {"GET", "/example.git/objects/info/commit-graph"}, + {"GET", "/example.git/objects/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"GET", "/example.git/objects/pack/pack-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.pack"}, + {"GET", "/example.git/objects/pack/pack-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.idx"}, + } + for _, endpoint := range endpoints { + for _, credentials := range []string{"missing", "wrong-user", "wrong-password", "malformed"} { + t.Run(endpoint.method+endpoint.path+"/"+credentials, func(t *testing.T) { + store := &testStore{err: ErrRepositoryNotFound} + config := DefaultConfig + config.RequireAuth = true + config.AuthUserEnvVar = "user" + config.AuthPassEnvVar = "pass" + config.RoutePrefix = "/git" + srv := New(config, store) + req := httptest.NewRequest(endpoint.method, "/git"+endpoint.path, nil) + switch credentials { + case "wrong-user": + req.SetBasicAuth("wrong", "pass") + case "wrong-password": + req.SetBasicAuth("user", "wrong") + case "malformed": + req.Header.Set("Authorization", "Basic invalid") + } + if endpoint.method == "POST" { + rpc := "upload-pack" + if endpoint.path == "/example.git/git-receive-pack" { + rpc = "receive-pack" + } + req.Header.Set("Content-Type", "application/x-git-"+rpc+"-request") + } + res := httptest.NewRecorder() + srv.ServeHTTP(res, req) + if res.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", res.Code) + } + if got := res.Header().Get("WWW-Authenticate"); got != `Basic realm="authorization needed"` { + t.Fatalf("unexpected challenge %q", got) + } + if store.name != "" { + t.Fatalf("unauthenticated request opened repository %q", store.name) + } + }) + } + } +} + +func TestStaticRepositoryAuthentication(t *testing.T) { + dir := t.TempDir() + const head = "ref: refs/heads/main\n" + if err := os.WriteFile(filepath.Join(dir, "HEAD"), []byte(head), 0600); err != nil { + t.Fatal(err) + } + for _, requireAuth := range []bool{false, true} { + t.Run(map[bool]string{false: "public", true: "authenticated"}[requireAuth], func(t *testing.T) { + config := DefaultConfig + config.RequireAuth = requireAuth + config.AuthUserEnvVar = "user" + config.AuthPassEnvVar = "pass" + req := httptest.NewRequest("GET", "/example.git/HEAD", nil) + if requireAuth { + req.SetBasicAuth("user", "pass") + } + res := httptest.NewRecorder() + New(config, &testStore{repo: testRepository(dir)}).ServeHTTP(res, req) + if res.Code != http.StatusOK || res.Body.String() != head { + t.Fatalf("response = %d %q", res.Code, res.Body.String()) + } + }) + } +} diff --git a/server/repository_test.go b/server/repository_test.go index 2842534..dc4dd99 100644 --- a/server/repository_test.go +++ b/server/repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http/httptest" + "net/url" "os" "os/exec" "path/filepath" @@ -101,12 +102,27 @@ func (s *repositoryCreateErrorStore) Exists(context.Context, string) (bool, erro func (s *repositoryCreateErrorStore) List(context.Context) ([]string, error) { return nil, nil } func TestCreateRepositoryNativeHTTPPushAndClone(t *testing.T) { + for _, requireAuth := range []bool{false, true} { + name := "public" + if requireAuth { + name = "authenticated" + } + t.Run(name, func(t *testing.T) { testNativeHTTPPushAndClone(t, requireAuth) }) + } +} + +func testNativeHTTPPushAndClone(t *testing.T, requireAuth bool) { + t.Helper() + gitBin, err := exec.LookPath("git") if err != nil { t.Skip("native git is required for HTTP integration test") } config := DefaultConfig config.GitBinPath = gitBin + config.RequireAuth = requireAuth + config.AuthUserEnvVar = "user" + config.AuthPassEnvVar = "pass" srv := New(config, NewFilesystemStore(t.TempDir())) if _, err := srv.CreateRepository(context.Background(), "team/example.git"); err != nil { t.Fatal(err) @@ -135,10 +151,16 @@ func TestCreateRepositoryNativeHTTPPushAndClone(t *testing.T) { } run(source, "add", "hello.txt") run(source, "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "initial commit") - url := httpServer.URL + "/team/example.git" - run(source, "push", url, "master") + remoteURL, err := url.Parse(httpServer.URL + "/team/example.git") + if err != nil { + t.Fatal(err) + } + if requireAuth { + remoteURL.User = url.UserPassword("user", "pass") + } + run(source, "push", remoteURL.String(), "master") clone := filepath.Join(t.TempDir(), "clone") - run(source, "clone", url, clone) + run(source, "clone", remoteURL.String(), clone) if got, want := run(clone, "rev-parse", "HEAD"), run(source, "rev-parse", "HEAD"); got != want { t.Fatalf("cloned commit = %s, want %s", got, want) } diff --git a/server/server.go b/server/server.go index 3907059..69fc54b 100644 --- a/server/server.go +++ b/server/server.go @@ -103,6 +103,15 @@ func Handler() http.HandlerFunc { } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Authenticate before opening storage or dispatching any Git HTTP endpoint. + if s.Config.RequireAuth { + user, password, ok := r.BasicAuth() + if !ok || user != s.Config.AuthUserEnvVar || password != s.Config.AuthPassEnvVar { + renderAuthRequire(w) + return + } + } + log.Printf("%s %s %s %s", r.RemoteAddr, r.Method, r.URL.Path, r.Proto) for match, service := range services { re, err := regexp.Compile(s.Config.RoutePrefix + match) @@ -255,18 +264,6 @@ func getInfoRefs(s *Server, hr HandlerReq) { access := s.hasAccess(r, dir, serviceName, false) version := r.Header.Get("Git-Protocol") - user, password, authok := r.BasicAuth() - if s.Config.RequireAuth { - if !authok { - renderAuthRequire(w) - return - } - if user != s.Config.AuthUserEnvVar || password != s.Config.AuthPassEnvVar { - renderAuthRequire(w) - return - } - } - if access { args := []string{serviceName, "--stateless-rpc", "--advertise-refs", "."} refs := s.gitCommand(r.Context(), dir, version, args...) From e007c944ffac7775a3a5d48f58309a46c39d5fb8 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 10:13:30 +0100 Subject: [PATCH 2/2] Preserve access logs for rejected authentication attempts --- server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index 69fc54b..b1d4f52 100644 --- a/server/server.go +++ b/server/server.go @@ -103,6 +103,7 @@ func Handler() http.HandlerFunc { } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s %s %s", r.RemoteAddr, r.Method, r.URL.Path, r.Proto) // Authenticate before opening storage or dispatching any Git HTTP endpoint. if s.Config.RequireAuth { user, password, ok := r.BasicAuth() @@ -112,7 +113,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - log.Printf("%s %s %s %s", r.RemoteAddr, r.Method, r.URL.Path, r.Proto) for match, service := range services { re, err := regexp.Compile(s.Config.RoutePrefix + match) if err != nil {