v0.4.0 3027a40

handler_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close