1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githttp_test
4
5import (
6 "context"
7 "encoding/base64"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "net/netip"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync"
16 "testing"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/mock"
20 "github.com/stretchr/testify/require"
21
22 "bnema.dev/ermite/internal/adapters/githttp"
23 "bnema.dev/ermite/internal/clientlimit"
24 "bnema.dev/ermite/internal/domain"
25 "bnema.dev/ermite/internal/ports"
26 "bnema.dev/ermite/internal/ports/mocks"
27)
28
29type fakeAuth struct {
30 mu sync.Mutex
31 seen []string
32}
33
34func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) {
35 f.mu.Lock()
36 f.seen = append(f.seen, secret)
37 f.mu.Unlock()
38 switch secret {
39 case "root-secret":
40 return domain.Root(), nil
41 case "reader-secret":
42 return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
43 }
44 return domain.Principal{}, domain.ErrUnauthenticated
45}
46
47type fakeRepos struct{}
48
49func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
50 switch n.String() {
51 case "pub":
52 return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
53 case "priv":
54 if p.Allows(domain.ScopeRepoRead, n) {
55 return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
56 }
57 }
58 return domain.Repo{}, domain.ErrNotFound
59}
60
61type fakePushes struct {
62 mu sync.Mutex
63 calls []bool // the create flag of each Open
64 err error
65}
66
67func (f *fakePushes) Open(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error {
68 f.mu.Lock()
69 defer f.mu.Unlock()
70 f.calls = append(f.calls, create)
71 if p.IsAnonymous() {
72 return domain.ErrUnauthenticated
73 }
74 return f.err
75}
76
77type fakeTokens struct {
78 mu sync.Mutex
79 released int
80 repo domain.RepoName
81}
82
83func (f *fakeTokens) Register(_ context.Context, r domain.RepoName, _ domain.Principal) (string, func()) {
84 f.mu.Lock()
85 defer f.mu.Unlock()
86 f.repo = r
87 return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() }
88}
89func (f *fakeTokens) Socket() string { return "/run/hook.sock" }
90
91// cgiScript is a stand-in for git-http-backend: it answers with the
92// environment and the body it received. Like Git, it reads the whole request
93// before it answers (net/http cannot read a request body once the response
94// has started).
95func cgiScript(t *testing.T) string {
96 t.Helper()
97 p := filepath.Join(t.TempDir(), "backend")
98 body := "#!/bin/sh\nin=$(mktemp)\ncat > \"$in\"\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat \"$in\"\nrm -f \"$in\"\n"
99 require.NoError(t, os.WriteFile(p, []byte(body), 0o755))
100 return p
101}
102
103type rig struct {
104 h *githttp.Handler
105 srv *httptest.Server
106 pushes *fakePushes
107 backend *mocks.GitBackend
108 hooks *fakeTokens
109 auth *fakeAuth
110
111 pushFlags []bool
112 backendErr error
113}
114
115func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
116 t.Helper()
117 r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}}
118 cfg := githttp.Config{
119 Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,
120 }
121 script := cgiScript(t)
122 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
123 func(_ context.Context, push bool) (ports.HTTPBackend, error) {
124 r.pushFlags = append(r.pushFlags, push)
125 if r.backendErr != nil {
126 return ports.HTTPBackend{}, r.backendErr
127 }
128 return ports.HTTPBackend{Path: script, Dir: filepath.Dir(script), Env: []string{"PATH=/usr/bin:/bin", "GIT_PROJECT_ROOT=/x"}}, nil
129 }).Maybe()
130 if mut != nil {
131 mut(&cfg)
132 }
133 r.h = githttp.New(cfg)
134 mux := http.NewServeMux()
135 for _, p := range githttp.Patterns {
136 mux.Handle(p, r.h)
137 }
138 r.srv = httptest.NewServer(mux)
139 t.Cleanup(r.srv.Close)
140 return r
141}
142
143func (r *rig) do(t *testing.T, method, path, user, pass string, body io.Reader, hdr map[string]string) (int, string, http.Header) {
144 t.Helper()
145 req, err := http.NewRequest(method, r.srv.URL+path, body)
146 require.NoError(t, err)
147 if pass != "" {
148 req.SetBasicAuth(user, pass)
149 }
150 for k, v := range hdr {
151 req.Header.Set(k, v)
152 }
153 resp, err := http.DefaultClient.Do(req)
154 require.NoError(t, err)
155 defer func() { _ = resp.Body.Close() }()
156 b, err := io.ReadAll(resp.Body)
157 require.NoError(t, err)
158 return resp.StatusCode, string(b), resp.Header
159}
160
161func envOf(body string) map[string]string {
162 head, _, _ := strings.Cut(body, "\n--body--\n")
163 m := map[string]string{}
164 for _, l := range strings.Split(head, "\n") {
165 k, v, ok := strings.Cut(l, "=")
166 if ok {
167 m[k] = v
168 }
169 }
170 return m
171}
172
173func TestHandlerRoutesAndStatuses(t *testing.T) {
174 r := newRig(t, nil)
175 tests := []struct {
176 name string
177 method string
178 path string
179 pass string
180 want int
181 auth bool // expects a WWW-Authenticate challenge
182 }{
183 {"public, anonymous", "GET", "/pub.git/info/refs?service=git-upload-pack", "", 200, false},
184 {"public without .git", "GET", "/pub/info/refs?service=git-upload-pack", "", 200, false},
185 {"private, anonymous", "GET", "/priv.git/info/refs?service=git-upload-pack", "", 401, true},
186 {"missing, anonymous", "GET", "/none.git/info/refs?service=git-upload-pack", "", 401, true},
187 {"private, reader", "GET", "/priv.git/info/refs?service=git-upload-pack", "reader-secret", 200, false},
188 {"private, wrong token", "GET", "/priv.git/info/refs?service=git-upload-pack", "bad", 401, true},
189 {"missing, valid token", "GET", "/none.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
190 {"invalid name", "GET", "/Bad.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
191 {"name ending in .git.git", "GET", "/x.git.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
192 {"no service", "GET", "/pub.git/info/refs", "", 404, false},
193 {"unknown service", "GET", "/pub.git/info/refs?service=git-evil", "", 404, false},
194 {"HEAD file", "GET", "/pub.git/HEAD", "", 404, false},
195 {"objects", "GET", "/pub.git/objects/info/packs", "", 404, false},
196 {"upload-pack wrong method", "GET", "/pub.git/git-upload-pack", "", 405, false},
197 {"info/refs wrong method", "POST", "/pub.git/info/refs?service=git-upload-pack", "", 405, false},
198 {"receive-pack, anonymous", "GET", "/pub.git/info/refs?service=git-receive-pack", "", 401, true},
199 {"receive-pack, root", "GET", "/pub.git/info/refs?service=git-receive-pack", "root-secret", 200, false},
200 }
201 for _, tt := range tests {
202 t.Run(tt.name, func(t *testing.T) {
203 user := ""
204 if tt.pass != "" {
205 user = "whatever" // the username is ignored
206 }
207 code, _, hdr := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil)
208 assert.Equal(t, tt.want, code)
209 if tt.auth {
210 assert.Equal(t, `Basic realm="ermite"`, hdr.Get("WWW-Authenticate"))
211 } else {
212 assert.Empty(t, hdr.Get("WWW-Authenticate"))
213 }
214 })
215 }
216}
217
218func TestHandlerPushAuthorizationAnswers(t *testing.T) {
219 r := newRig(t, nil)
220 get := func() int {
221 code, _, _ := r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
222 return code
223 }
224 assert.Equal(t, 200, get())
225 r.pushes.err = domain.ErrForbidden
226 assert.Equal(t, 403, get())
227 r.pushes.err = domain.ErrNotFound
228 assert.Equal(t, 404, get())
229 r.pushes.err = domain.ErrUnauthenticated
230 assert.Equal(t, 401, get())
231 r.pushes.err = errBoom{}
232 assert.Equal(t, 500, get())
233
234 // Only the first request of a push may create.
235 r.pushes.err = nil
236 r.pushes.calls = nil
237 r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
238 r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"})
239 assert.Equal(t, []bool{true, false}, r.pushes.calls)
240 // Reads never reach the push use case.
241 r.pushes.calls = nil
242 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
243 assert.Empty(t, r.pushes.calls)
244}
245
246type errBoom struct{}
247
248func (errBoom) Error() string { return "boom with /internal/detail" }
249
250func TestHandlerBackendEnvironment(t *testing.T) {
251 r := newRig(t, nil)
252 hdr := map[string]string{
253 "Cookie": "session=secret",
254 "Proxy-Authorization": "Basic proxy-should-not-leak",
255 "Git-Protocol": "version=2",
256 "Content-Type": "application/x-git-receive-pack-request",
257 "X-Forwarded-For": "1.2.3.4",
258 "X-Auth-Token": "arbitrary-header-secret",
259 "Accept": "application/x-git-upload-pack-result",
260 "User-Agent": "git/2.50",
261 }
262
263 // A read: no hook environment.
264 code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, hdr)
265 require.Equal(t, 200, code)
266 e := envOf(body)
267 assert.Equal(t, "/pub.git/info/refs", e["PATH_INFO"])
268 assert.Equal(t, "service=git-upload-pack", e["QUERY_STRING"])
269 assert.Equal(t, "version=2", e["HTTP_GIT_PROTOCOL"])
270 assert.Equal(t, "/x", e["GIT_PROJECT_ROOT"], "the backend environment is used")
271 assert.NotContains(t, e, "ERMITE_HOOK_TOKEN")
272 assert.NotContains(t, body, "should-not-leak")
273 assert.NotContains(t, body, "root-secret")
274 assert.NotContains(t, body, base64.StdEncoding.EncodeToString([]byte("u:root-secret")), "the Authorization header is not passed on")
275 assert.NotContains(t, body, "session=secret")
276 assert.NotContains(t, e, "HTTP_AUTHORIZATION")
277 assert.NotContains(t, e, "HTTP_PROXY_AUTHORIZATION")
278 assert.NotContains(t, e, "HTTP_COOKIE")
279 assert.Equal(t, "application/x-git-upload-pack-result", e["HTTP_ACCEPT"])
280 assert.Equal(t, "git/2.50", e["HTTP_USER_AGENT"])
281 assert.Equal(t, []bool{false}, r.pushFlags)
282
283 // A receive-pack POST gets the hook environment and releases the token.
284 code, _, _ = r.do(t, "POST", "/Pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("PACKDATA"), hdr)
285 assert.Equal(t, 404, code, "invalid name")
286 code, body, _ = r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("PACKDATA"), hdr)
287 require.Equal(t, 200, code)
288 e = envOf(body)
289 assert.Equal(t, "hook-token", e["ERMITE_HOOK_TOKEN"])
290 assert.Equal(t, "/run/hook.sock", e["ERMITE_HOOK_SOCKET"])
291 assert.Equal(t, "/pub.git/git-receive-pack", e["PATH_INFO"])
292 assert.Equal(t, "8", e["CONTENT_LENGTH"])
293 assert.True(t, strings.HasSuffix(body, "\n--body--\nPACKDATA"), "the body reaches the backend")
294 assert.NotContains(t, body, "root-secret")
295 assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks")
296 assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN")
297 assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR")
298 assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends")
299 assert.Equal(t, "pub", r.hooks.repo.String())
300
301 // The info/refs advertisement of a push does not register a hook token.
302 r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
303 assert.Equal(t, 1, r.hooks.released)
304}
305
306func TestHandlerNormalizesRepositoryPath(t *testing.T) {
307 r := newRig(t, nil)
308 _, body, _ := r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
309 assert.Equal(t, "/pub.git/info/refs", envOf(body)["PATH_INFO"], "the backend always sees <name>.git")
310}
311
312func TestHandlerChunkedBodyIsStreamed(t *testing.T) {
313 r := newRig(t, nil)
314 payload := strings.Repeat("x", 300_000)
315 // An io.Reader of unknown length makes the client send chunked.
316 body := io.MultiReader(strings.NewReader(payload))
317 code, out, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", body, map[string]string{"Content-Type": "application/x-git-upload-pack-request"})
318 require.Equal(t, 200, code)
319 e := envOf(out)
320 assert.NotContains(t, e, "CONTENT_LENGTH", "the length is unknown to the backend: the body is streamed, not buffered")
321 assert.NotContains(t, e, "HTTP_TRANSFER_ENCODING")
322 _, got, _ := strings.Cut(out, "\n--body--\n")
323 assert.Equal(t, len(payload), len(got))
324 assert.True(t, payload == got, "the body arrives intact")
325}
326
327func TestHandlerBodyLimit(t *testing.T) {
328 r := newRig(t, func(c *githttp.Config) { c.MaxBodyBytes = 1000 })
329 big := strings.Repeat("x", 5000)
330 code, _, _ := r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader(big), nil)
331 assert.Equal(t, http.StatusRequestEntityTooLarge, code, "declared length over the limit")
332
333 // A chunked body has no declared length: it is cut off at the limit, so
334 // the backend sees at most the limit and never the rest.
335 code, out, _ := r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", io.MultiReader(strings.NewReader(big)), nil)
336 _, got, _ := strings.Cut(out, "\n--body--\n")
337 assert.LessOrEqual(t, len(got), 1000, "the body is cut off at the limit (status %d)", code)
338 assert.NotEqual(t, big, got)
339
340 code, _, _ = r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("ok"), nil)
341 assert.Equal(t, 200, code)
342}
343
344func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) {
345 r := newRig(t, nil) // the push limit is 4 GiB
346 big := strings.NewReader(strings.Repeat("x", 64<<20+1))
347 code, _, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", big, nil)
348 assert.Equal(t, http.StatusRequestEntityTooLarge, code, "a fetch request is a short list of wants")
349}
350
351func TestHandlerServiceQuery(t *testing.T) {
352 r := newRig(t, nil)
353 for _, q := range []string{
354 "service=git-upload-pack&service=git-receive-pack",
355 "service=git-upload-pack&service=git-upload-pack",
356 "service=git-upload-pack&service=",
357 "service=",
358 } {
359 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?"+q, "u", "root-secret", nil, nil)
360 assert.Equal(t, 404, code, q)
361 }
362 // Other parameters do not reach the backend; the service is rebuilt.
363 code, body, _ := r.do(t, "GET", "/pub.git/info/refs?x=1&service=git-upload-pack&y=%0a", "", "", nil, nil)
364 require.Equal(t, 200, code)
365 assert.Equal(t, "service=git-upload-pack", envOf(body)["QUERY_STRING"])
366}
367
368func TestHandlerGitProtocolHeader(t *testing.T) {
369 r := newRig(t, nil)
370 tests := []struct {
371 value string
372 want string
373 }{
374 {"version=2", "version=2"},
375 {"version=1", "version=1"},
376 {"version=2:object-format=sha1", "version=2:object-format=sha1"},
377 {"VERSION=2", ""},
378 {"version=2;rm", ""},
379 {"version", ""},
380 {"version=2:", ""},
381 {"a=b c", ""},
382 {"version=" + strings.Repeat("2", 200), ""},
383 }
384 for _, tt := range tests {
385 _, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"Git-Protocol": tt.value})
386 e := envOf(body)
387 if tt.want == "" {
388 assert.NotContains(t, e, "HTTP_GIT_PROTOCOL", tt.value)
389 } else {
390 assert.Equal(t, tt.want, e["HTTP_GIT_PROTOCOL"], tt.value)
391 }
392 }
393}
394
395func TestHandlerDoesNotInheritTheProcessEnvironment(t *testing.T) {
396 t.Setenv("LD_LIBRARY_PATH", "/evil/lib")
397 t.Setenv("ERMITE_ADMIN_TOKEN", "must-not-leak")
398 r := newRig(t, nil)
399 _, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
400 e := envOf(body)
401 assert.Empty(t, e["LD_LIBRARY_PATH"], "net/http/cgi inherits it by default")
402 assert.NotContains(t, body, "must-not-leak")
403}
404
405func TestHandlerBackendFailure(t *testing.T) {
406 r := newRig(t, nil)
407 r.backendErr = io.ErrUnexpectedEOF
408 code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
409 assert.Equal(t, 500, code)
410 assert.NotContains(t, body, "EOF", "no internal detail in the response")
411}
412
413func TestHandlerLimiter(t *testing.T) {
414 r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) })
415 for i := range clientlimit.MaxFailures {
416 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil)
417 require.Equal(t, 401, code, i)
418 }
419 code, _, hdr := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
420 assert.Equal(t, 429, code)
421 assert.NotEmpty(t, hdr.Get("Retry-After"))
422 // A blocked request does not even reach the authenticator.
423 r.auth.mu.Lock()
424 n := len(r.auth.seen)
425 r.auth.mu.Unlock()
426 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
427 r.auth.mu.Lock()
428 assert.Equal(t, n, len(r.auth.seen))
429 r.auth.mu.Unlock()
430}
431
432func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
433 r := newRig(t, func(c *githttp.Config) {
434 c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
435 })
436 for range clientlimit.MaxFailures {
437 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
438 }
439 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
440 assert.Equal(t, 429, code)
441 code, _, _ = r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.2"})
442 assert.Equal(t, 200, code)
443}
444
445func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
446 r := newRig(t, nil)
447 path := "/priv.git/info/refs?service=git-upload-pack"
448 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "Bearer reader-secret"})
449 assert.Equal(t, 200, code)
450 code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})
451 assert.Equal(t, 200, code, "the scheme is case-insensitive")
452 for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {
453 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})
454 assert.Equal(t, 401, code, h)
455 }
456 // An empty password is a failed attempt, not anonymous access.
457 req, _ := http.NewRequest("GET", r.srv.URL+"/pub.git/info/refs?service=git-upload-pack", nil)
458 req.SetBasicAuth("u", "")
459 resp, err := http.DefaultClient.Do(req)
460 require.NoError(t, err)
461 _ = resp.Body.Close()
462 assert.Equal(t, 401, resp.StatusCode)
463}