Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 22m ago 4b8c9e1d
history blame raw 8.8 KiB
1package githttp
2
3// Every resource the anonymous read path can spend, and the test that it is
4// bounded. These are all white-box: the point is the bound, not the wire format,
5// and a proxy that accepts a body without limit fails these before it can fail a
6// production instance.
7
8import (
9 "context"
10 "io"
11 "log/slog"
12 "net/http"
13 "net/http/httptest"
14 "path/filepath"
15 "strings"
16 "testing"
17 "time"
18
19 "rodent.software/vole/core/git"
20 "rodent.software/vole/forge/access"
21 "rodent.software/vole/forge/repo"
22)
23
24// newProxyWith builds the same fixture newProxy does, with a hook to adjust the
25// bounds, and returns the repository directory so a test can name a real commit.
26func newProxyWith(t *testing.T, tweak func(*Config)) (*Proxy, string) {
27 t.Helper()
28 root := filepath.Join(t.TempDir(), "repos")
29 acls, err := access.Open(filepath.Join(filepath.Dir(root), "users.db"))
30 if err != nil {
31 t.Fatal(err)
32 }
33 acls.AuditPath = filepath.Join(filepath.Dir(root), "audit.log")
34 t.Cleanup(func() { acls.Close() })
35 makeRepo(t, root, true, acls)
36 st := repo.New(root, "/bin/true", acls)
37 cfg := Config{
38 Root: root,
39 Open: st.Open,
40 Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
41 }
42 if tweak != nil {
43 tweak(&cfg)
44 }
45 return New(cfg), filepath.Join(root, "demo.git")
46}
47
48func TestOversizedBodyRefused(t *testing.T) {
49 t.Parallel()
50 p := newProxy(t, true)
51 req := httptest.NewRequest(http.MethodPost, "/demo.git/git-upload-pack",
52 strings.NewReader(strings.Repeat("x", maxUploadPackBody+1)))
53 req.ContentLength = maxUploadPackBody + 1
54 rec := httptest.NewRecorder()
55 p.ServeHTTP(rec, req)
56 if rec.Code != http.StatusRequestEntityTooLarge {
57 t.Fatalf("status = %d, want 413", rec.Code)
58 }
59}
60
61func TestEmptyPostRefused(t *testing.T) {
62 t.Parallel()
63 p := newProxy(t, true)
64 req := httptest.NewRequest(http.MethodPost, "/demo.git/git-upload-pack", strings.NewReader(""))
65 req.ContentLength = 0
66 rec := httptest.NewRecorder()
67 p.ServeHTTP(rec, req)
68 if rec.Code != http.StatusRequestEntityTooLarge {
69 t.Fatalf("status = %d, want 413 for a zero-length fetch", rec.Code)
70 }
71}
72
73// TestOnlyTheTwoEndpointsExist covers the path validation. The old check accepted
74// anything that started with git-upload-pack, and passed the rest of the path to
75// the CGI as PATH_INFO.
76func TestOnlyTheTwoEndpointsExist(t *testing.T) {
77 t.Parallel()
78 p := newProxy(t, true)
79 cases := []struct {
80 method, target string
81 code int
82 }{
83 {http.MethodGet, "/demo.git/info/refs?service=git-upload-pack", http.StatusOK},
84 {http.MethodGet, "/demo.git/info/refs", http.StatusNotFound},
85 {http.MethodGet, "/demo.git/info/refs?service=git-upload-pack&service=evil", http.StatusNotFound},
86 {http.MethodGet, "/demo.git/git-upload-pack", http.StatusNotFound},
87 {http.MethodPost, "/demo.git/info/refs?service=git-upload-pack", http.StatusNotFound},
88 {http.MethodGet, "/demo.git/git-upload-pack/../../git-receive-pack", http.StatusNotFound},
89 {http.MethodGet, "/demo.git/../demo.git/info/refs", http.StatusNotFound},
90 {http.MethodGet, "/demo.git/git-upload-pack/objects/info/packs", http.StatusNotFound},
91 {http.MethodGet, "/demo.git/objects/info/packs", http.StatusNotFound},
92 {http.MethodGet, "/demo.git/HEAD", http.StatusNotFound},
93 {http.MethodPost, "/demo.git/git-receive-pack", http.StatusForbidden},
94 }
95 for _, c := range cases {
96 req := httptest.NewRequest(c.method, c.target, nil)
97 if c.method == http.MethodPost {
98 req.ContentLength = 4
99 req.Body = io.NopCloser(strings.NewReader("want"))
100 }
101 rec := httptest.NewRecorder()
102 p.ServeHTTP(rec, req)
103 if rec.Code != c.code {
104 t.Errorf("%s %s = %d, want %d", c.method, c.target, rec.Code, c.code)
105 }
106 }
107}
108
109// TestConcurrentClonesAreBounded fills the transport and asks for one more. A
110// refused clone is a 503 with Retry-After, not a queued goroutine.
111func TestConcurrentClonesAreBounded(t *testing.T) {
112 t.Parallel()
113 p, _ := newProxyWith(t, func(c *Config) { c.MaxConcurrent = 1; c.MaxPerClient = 1 })
114 p.sem <- struct{}{}
115 t.Cleanup(func() { <-p.sem })
116
117 req := httptest.NewRequest(http.MethodGet, "/demo.git/info/refs?service=git-upload-pack", nil)
118 rec := httptest.NewRecorder()
119 p.ServeHTTP(rec, req)
120 if rec.Code != http.StatusServiceUnavailable {
121 t.Fatalf("status = %d, want 503", rec.Code)
122 }
123 if rec.Header().Get("Retry-After") == "" {
124 t.Fatal("a refused clone did not say when to come back")
125 }
126 if p.Stats()["refused_busy"] != 1 {
127 t.Fatalf("refused_busy = %d", p.Stats()["refused_busy"])
128 }
129}
130
131// TestPerClientClonesAreBounded: one address cannot take every transport slot.
132func TestPerClientClonesAreBounded(t *testing.T) {
133 t.Parallel()
134 p, _ := newProxyWith(t, func(c *Config) { c.MaxConcurrent = 4; c.MaxPerClient = 1 })
135 ip := clientIP(httptest.NewRequest(http.MethodGet, "/", nil), "")
136 p.mu.Lock()
137 p.perClient[ip] = 1
138 p.mu.Unlock()
139
140 req := httptest.NewRequest(http.MethodGet, "/demo.git/info/refs?service=git-upload-pack", nil)
141 rec := httptest.NewRecorder()
142 p.ServeHTTP(rec, req)
143 if rec.Code != http.StatusServiceUnavailable {
144 t.Fatalf("status = %d, want 503 for the second clone from one address", rec.Code)
145 }
146 // The transport slot must be handed back, not leaked by the refusal.
147 if len(p.sem) != 0 {
148 t.Fatalf("semaphore holds %d slots after a refused clone", len(p.sem))
149 }
150}
151
152// TestProxyHeaderNamesTheClient covers the operator-facing knob: behind a proxy
153// every request shares one connection address, so the limits need the address the
154// proxy saw — and only from the header the operator named.
155func TestProxyHeaderNamesTheClient(t *testing.T) {
156 t.Parallel()
157 req := httptest.NewRequest(http.MethodGet, "/", nil)
158 req.RemoteAddr = "127.0.0.1:5000"
159 req.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.7")
160 if got := clientIP(req, ""); got != "127.0.0.1" {
161 t.Fatalf("untrusted header changed the client: %q", got)
162 }
163 if got := clientIP(req, "X-Forwarded-For"); got != "198.51.100.7" {
164 t.Fatalf("client from header = %q, want the last entry (the nearest proxy's)", got)
165 }
166}
167
168// stalledFetch is a fetch that cannot finish: a valid want line and no flush-pkt,
169// so upload-pack waits for input that never comes.
170func stalledFetch(t *testing.T, dir string) (*http.Request, func()) {
171 t.Helper()
172 g := git.Open(dir)
173 sha, err := g.RevParse("refs/heads/main")
174 if err != nil || sha == "" {
175 t.Fatalf("rev-parse: %q %v", sha, err)
176 }
177 pr, pw := io.Pipe()
178 body := io.MultiReader(
179 strings.NewReader("want "+sha+" side-band-64k ofs-delta agent=git/2.0\n"),
180 pr,
181 )
182 req := httptest.NewRequest(http.MethodPost, "/demo.git/git-upload-pack", pipeBody{r: body, c: pr})
183 req.ContentLength = 4096
184 req.Header.Set("Content-Type", "application/x-git-upload-pack-request")
185 return req, func() { pw.Close() }
186}
187
188// pipeBody keeps the pipe's Close, which io.MultiReader drops: the backend closes
189// the request body to unblock its own copy when the request is over.
190type pipeBody struct {
191 r io.Reader
192 c io.Closer
193}
194
195func (b pipeBody) Read(p []byte) (int, error) { return b.r.Read(p) }
196func (b pipeBody) Close() error { return b.c.Close() }
197
198// TestClientDisconnectStopsTheBackend: a client that walks away must not leave a
199// git process holding a pack open. Without the request context this blocks until
200// the process times out on its own, which is forever.
201func TestClientDisconnectStopsTheBackend(t *testing.T) {
202 t.Parallel()
203 p, dir := newProxyWith(t, nil)
204 req, cleanup := stalledFetch(t, dir)
205 t.Cleanup(cleanup)
206 ctx, cancel := context.WithCancel(req.Context())
207 t.Cleanup(cancel)
208 req = req.WithContext(ctx)
209
210 done := make(chan struct{})
211 go func() {
212 defer close(done)
213 p.ServeHTTP(httptest.NewRecorder(), req)
214 }()
215 time.Sleep(100 * time.Millisecond) // let the backend start and block
216 cancel()
217 select {
218 case <-done:
219 case <-time.After(10 * time.Second):
220 t.Fatal("a disconnected clone kept the backend running")
221 }
222}
223
224// TestRequestTimeoutEndsAStalledClone: a fetch that never finishes is stopped
225// with an answer rather than holding a slot for hours.
226func TestRequestTimeoutEndsAStalledClone(t *testing.T) {
227 t.Parallel()
228 p, dir := newProxyWith(t, func(c *Config) { c.RequestTimeout = 300 * time.Millisecond })
229 req, cleanup := stalledFetch(t, dir)
230 t.Cleanup(cleanup)
231
232 rec := httptest.NewRecorder()
233 start := time.Now()
234 p.ServeHTTP(rec, req)
235 elapsed := time.Since(start)
236 if elapsed > 5*time.Second {
237 t.Fatalf("a stalled fetch took %s to be stopped", elapsed)
238 }
239 // The backend had already written its headers before it stalled, so the status
240 // cannot become 504: the client sees a 200 and a truncated body, which is what
241 // a real clone sees when the server walks away mid-pack.
242 if rec.Code != http.StatusOK {
243 t.Fatalf("status = %d", rec.Code)
244 }
245 // Past the handshake there is nothing to receive, and the process is gone.
246 if body := rec.Body.Bytes(); len(body) > 1<<10 {
247 t.Fatalf("a stopped fetch still delivered %d bytes", len(body))
248 }
249 if p.Stats()["timed_out"] != 1 {
250 t.Fatalf("timed_out = %d", p.Stats()["timed_out"])
251 }
252}
253