vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Bound the anonymous Git HTTP read path
86991e7020a418a08a5e3a36ce8f296561392e3b · Keenan Nemetz · 13d ago · parent e05f1c48
Bound the anonymous Git HTTP read path The transport that needs no account had no bound on anything: the request body was capped only at "not negative", the backend ran without a request context or deadline, the path check accepted any suffix after `git-upload-pack` and handed it to the CGI as `PATH_INFO`, and the server set no `ReadTimeout` and no `MaxHeaderBytes`. Bound what the read side can spend, which is every dimension available to a fetch and none of the ones a push would need: a 2 MiB body cap with a required `Content-Length`, exactly two endpoints with one method and one service each so the CGI never sees an unvalidated path, `exec.CommandContext` plus `WaitDelay` plus an explicit kill so a disconnect or an expired budget stops git instead of leaving it running, and a concurrency semaphore with a per-client bound that answers 503 with Retry-After rather than queueing. The client address is the connection's own unless the operator names a proxy header, because behind a proxy the connection address is the proxy. Both limits and the header name are configuration, `git_http` turns the transport off entirely, and `status` lists them so an operator can see what is in force. `WriteTimeout` is deliberately not set: clone, archives, LFS objects and site files are streamed, so those flows are bounded where they are produced.
server/githttp/limits_test.go +251
| @@ -0,0 +1,251 @@ | |||
| 1 | package 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 | |||
| 8 | import ( | ||
| 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 | "github.com/rodent-software/vole/core/git" | ||
| 20 | "github.com/rodent-software/vole/forge/access" | ||
| 21 | "github.com/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. | ||
| 26 | func 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 | t.Cleanup(func() { acls.Close() }) | ||
| 34 | makeRepo(t, root, true, acls) | ||
| 35 | st := repo.New(root, "/bin/true", acls) | ||
| 36 | cfg := Config{ | ||
| 37 | Root: root, | ||
| 38 | Open: st.Open, | ||
| 39 | Log: slog.New(slog.NewTextHandler(io.Discard, nil)), | ||
| 40 | } | ||
| 41 | if tweak != nil { | ||
| 42 | tweak(&cfg) | ||
| 43 | } | ||
| 44 | return New(cfg), filepath.Join(root, "demo.git") | ||
| 45 | } | ||
| 46 | |||
| 47 | func TestOversizedBodyRefused(t *testing.T) { | ||
| 48 | t.Parallel() | ||
| 49 | p := newProxy(t, true) | ||
| 50 | req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-upload-pack", | ||
| 51 | strings.NewReader(strings.Repeat("x", maxUploadPackBody+1))) | ||
| 52 | req.ContentLength = maxUploadPackBody + 1 | ||
| 53 | rec := httptest.NewRecorder() | ||
| 54 | p.ServeHTTP(rec, req) | ||
| 55 | if rec.Code != http.StatusRequestEntityTooLarge { | ||
| 56 | t.Fatalf("status = %d, want 413", rec.Code) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | func TestEmptyPostRefused(t *testing.T) { | ||
| 61 | t.Parallel() | ||
| 62 | p := newProxy(t, true) | ||
| 63 | req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-upload-pack", strings.NewReader("")) | ||
| 64 | req.ContentLength = 0 | ||
| 65 | rec := httptest.NewRecorder() | ||
| 66 | p.ServeHTTP(rec, req) | ||
| 67 | if rec.Code != http.StatusRequestEntityTooLarge { | ||
| 68 | t.Fatalf("status = %d, want 413 for a zero-length fetch", rec.Code) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | // TestOnlyTheTwoEndpointsExist covers the path validation. The old check accepted | ||
| 73 | // anything that started with git-upload-pack, and passed the rest of the path to | ||
| 74 | // the CGI as PATH_INFO. | ||
| 75 | func TestOnlyTheTwoEndpointsExist(t *testing.T) { | ||
| 76 | t.Parallel() | ||
| 77 | p := newProxy(t, true) | ||
| 78 | cases := []struct { | ||
| 79 | method, target string | ||
| 80 | code int | ||
| 81 | }{ | ||
| 82 | {http.MethodGet, "/git/demo.git/info/refs?service=git-upload-pack", http.StatusOK}, | ||
| 83 | {http.MethodGet, "/git/demo.git/info/refs", http.StatusNotFound}, | ||
| 84 | {http.MethodGet, "/git/demo.git/info/refs?service=git-upload-pack&service=evil", http.StatusNotFound}, | ||
| 85 | {http.MethodGet, "/git/demo.git/git-upload-pack", http.StatusNotFound}, | ||
| 86 | {http.MethodPost, "/git/demo.git/info/refs?service=git-upload-pack", http.StatusNotFound}, | ||
| 87 | {http.MethodGet, "/git/demo.git/git-upload-pack/../../git-receive-pack", http.StatusNotFound}, | ||
| 88 | {http.MethodGet, "/git/demo.git/../demo.git/info/refs", http.StatusNotFound}, | ||
| 89 | {http.MethodGet, "/git/demo.git/git-upload-pack/objects/info/packs", http.StatusNotFound}, | ||
| 90 | {http.MethodGet, "/git/demo.git/objects/info/packs", http.StatusNotFound}, | ||
| 91 | {http.MethodGet, "/git/demo.git/HEAD", http.StatusNotFound}, | ||
| 92 | {http.MethodPost, "/git/demo.git/git-receive-pack", http.StatusForbidden}, | ||
| 93 | } | ||
| 94 | for _, c := range cases { | ||
| 95 | req := httptest.NewRequest(c.method, c.target, nil) | ||
| 96 | if c.method == http.MethodPost { | ||
| 97 | req.ContentLength = 4 | ||
| 98 | req.Body = io.NopCloser(strings.NewReader("want")) | ||
| 99 | } | ||
| 100 | rec := httptest.NewRecorder() | ||
| 101 | p.ServeHTTP(rec, req) | ||
| 102 | if rec.Code != c.code { | ||
| 103 | t.Errorf("%s %s = %d, want %d", c.method, c.target, rec.Code, c.code) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | // TestConcurrentClonesAreBounded fills the transport and asks for one more. A | ||
| 109 | // refused clone is a 503 with Retry-After, not a queued goroutine. | ||
| 110 | func TestConcurrentClonesAreBounded(t *testing.T) { | ||
| 111 | t.Parallel() | ||
| 112 | p, _ := newProxyWith(t, func(c *Config) { c.MaxConcurrent = 1; c.MaxPerClient = 1 }) | ||
| 113 | p.sem <- struct{}{} | ||
| 114 | t.Cleanup(func() { <-p.sem }) | ||
| 115 | |||
| 116 | req := httptest.NewRequest(http.MethodGet, "/git/demo.git/info/refs?service=git-upload-pack", nil) | ||
| 117 | rec := httptest.NewRecorder() | ||
| 118 | p.ServeHTTP(rec, req) | ||
| 119 | if rec.Code != http.StatusServiceUnavailable { | ||
| 120 | t.Fatalf("status = %d, want 503", rec.Code) | ||
| 121 | } | ||
| 122 | if rec.Header().Get("Retry-After") == "" { | ||
| 123 | t.Fatal("a refused clone did not say when to come back") | ||
| 124 | } | ||
| 125 | if p.Stats()["refused_busy"] != 1 { | ||
| 126 | t.Fatalf("refused_busy = %d", p.Stats()["refused_busy"]) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | // TestPerClientClonesAreBounded: one address cannot take every transport slot. | ||
| 131 | func TestPerClientClonesAreBounded(t *testing.T) { | ||
| 132 | t.Parallel() | ||
| 133 | p, _ := newProxyWith(t, func(c *Config) { c.MaxConcurrent = 4; c.MaxPerClient = 1 }) | ||
| 134 | ip := clientIP(httptest.NewRequest(http.MethodGet, "/", nil), "") | ||
| 135 | p.mu.Lock() | ||
| 136 | p.perClient[ip] = 1 | ||
| 137 | p.mu.Unlock() | ||
| 138 | |||
| 139 | req := httptest.NewRequest(http.MethodGet, "/git/demo.git/info/refs?service=git-upload-pack", nil) | ||
| 140 | rec := httptest.NewRecorder() | ||
| 141 | p.ServeHTTP(rec, req) | ||
| 142 | if rec.Code != http.StatusServiceUnavailable { | ||
| 143 | t.Fatalf("status = %d, want 503 for the second clone from one address", rec.Code) | ||
| 144 | } | ||
| 145 | // The transport slot must be handed back, not leaked by the refusal. | ||
| 146 | if len(p.sem) != 0 { | ||
| 147 | t.Fatalf("semaphore holds %d slots after a refused clone", len(p.sem)) | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | // TestProxyHeaderNamesTheClient covers the operator-facing knob: behind a proxy | ||
| 152 | // every request shares one connection address, so the limits need the address the | ||
| 153 | // proxy saw — and only from the header the operator named. | ||
| 154 | func TestProxyHeaderNamesTheClient(t *testing.T) { | ||
| 155 | t.Parallel() | ||
| 156 | req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 157 | req.RemoteAddr = "127.0.0.1:5000" | ||
| 158 | req.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.7") | ||
| 159 | if got := clientIP(req, ""); got != "127.0.0.1" { | ||
| 160 | t.Fatalf("untrusted header changed the client: %q", got) | ||
| 161 | } | ||
| 162 | if got := clientIP(req, "X-Forwarded-For"); got != "198.51.100.7" { | ||
| 163 | t.Fatalf("client from header = %q, want the last entry (the nearest proxy's)", got) | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | // stalledFetch is a fetch that cannot finish: a valid want line and no flush-pkt, | ||
| 168 | // so upload-pack waits for input that never comes. | ||
| 169 | func stalledFetch(t *testing.T, dir string) (*http.Request, func()) { | ||
| 170 | t.Helper() | ||
| 171 | g := git.Open(dir) | ||
| 172 | sha, err := g.RevParse("refs/heads/main") | ||
| 173 | if err != nil || sha == "" { | ||
| 174 | t.Fatalf("rev-parse: %q %v", sha, err) | ||
| 175 | } | ||
| 176 | pr, pw := io.Pipe() | ||
| 177 | body := io.MultiReader( | ||
| 178 | strings.NewReader("want "+sha+" side-band-64k ofs-delta agent=git/2.0\n"), | ||
| 179 | pr, | ||
| 180 | ) | ||
| 181 | req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-upload-pack", pipeBody{r: body, c: pr}) | ||
| 182 | req.ContentLength = 4096 | ||
| 183 | req.Header.Set("Content-Type", "application/x-git-upload-pack-request") | ||
| 184 | return req, func() { pw.Close() } | ||
| 185 | } | ||
| 186 | |||
| 187 | // pipeBody keeps the pipe's Close, which io.MultiReader drops: the backend closes | ||
| 188 | // the request body to unblock its own copy when the request is over. | ||
| 189 | type pipeBody struct { | ||
| 190 | r io.Reader | ||
| 191 | c io.Closer | ||
| 192 | } | ||
| 193 | |||
| 194 | func (b pipeBody) Read(p []byte) (int, error) { return b.r.Read(p) } | ||
| 195 | func (b pipeBody) Close() error { return b.c.Close() } | ||
| 196 | |||
| 197 | // TestClientDisconnectStopsTheBackend: a client that walks away must not leave a | ||
| 198 | // git process holding a pack open. Without the request context this blocks until | ||
| 199 | // the process times out on its own, which is forever. | ||
| 200 | func TestClientDisconnectStopsTheBackend(t *testing.T) { | ||
| 201 | t.Parallel() | ||
| 202 | p, dir := newProxyWith(t, nil) | ||
| 203 | req, cleanup := stalledFetch(t, dir) | ||
| 204 | t.Cleanup(cleanup) | ||
| 205 | ctx, cancel := context.WithCancel(req.Context()) | ||
| 206 | t.Cleanup(cancel) | ||
| 207 | req = req.WithContext(ctx) | ||
| 208 | |||
| 209 | done := make(chan struct{}) | ||
| 210 | go func() { | ||
| 211 | defer close(done) | ||
| 212 | p.ServeHTTP(httptest.NewRecorder(), req) | ||
| 213 | }() | ||
| 214 | time.Sleep(100 * time.Millisecond) // let the backend start and block | ||
| 215 | cancel() | ||
| 216 | select { | ||
| 217 | case <-done: | ||
| 218 | case <-time.After(10 * time.Second): | ||
| 219 | t.Fatal("a disconnected clone kept the backend running") | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | // TestRequestTimeoutEndsAStalledClone: a fetch that never finishes is stopped | ||
| 224 | // with an answer rather than holding a slot for hours. | ||
| 225 | func TestRequestTimeoutEndsAStalledClone(t *testing.T) { | ||
| 226 | t.Parallel() | ||
| 227 | p, dir := newProxyWith(t, func(c *Config) { c.RequestTimeout = 300 * time.Millisecond }) | ||
| 228 | req, cleanup := stalledFetch(t, dir) | ||
| 229 | t.Cleanup(cleanup) | ||
| 230 | |||
| 231 | rec := httptest.NewRecorder() | ||
| 232 | start := time.Now() | ||
| 233 | p.ServeHTTP(rec, req) | ||
| 234 | elapsed := time.Since(start) | ||
| 235 | if elapsed > 5*time.Second { | ||
| 236 | t.Fatalf("a stalled fetch took %s to be stopped", elapsed) | ||
| 237 | } | ||
| 238 | // The backend had already written its headers before it stalled, so the status | ||
| 239 | // cannot become 504: the client sees a 200 and a truncated body, which is what | ||
| 240 | // a real clone sees when the server walks away mid-pack. | ||
| 241 | if rec.Code != http.StatusOK { | ||
| 242 | t.Fatalf("status = %d", rec.Code) | ||
| 243 | } | ||
| 244 | // Past the handshake there is nothing to receive, and the process is gone. | ||
| 245 | if body := rec.Body.Bytes(); len(body) > 1<<10 { | ||
| 246 | t.Fatalf("a stopped fetch still delivered %d bytes", len(body)) | ||
| 247 | } | ||
| 248 | if p.Stats()["timed_out"] != 1 { | ||
| 249 | t.Fatalf("timed_out = %d", p.Stats()["timed_out"]) | ||
| 250 | } | ||
| 251 | } | ||