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.
| 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 | "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. |
| 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 | 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 | |
| 48 | func 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 | |
| 61 | func 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. |
| 76 | func 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. |
| 111 | func 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. |
| 132 | func 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. |
| 155 | func 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. |
| 170 | func 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. |
| 190 | type pipeBody struct { |
| 191 | r io.Reader |
| 192 | c io.Closer |
| 193 | } |
| 194 | |
| 195 | func (b pipeBody) Read(p []byte) (int, error) { return b.r.Read(p) } |
| 196 | func (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. |
| 201 | func 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. |
| 226 | func 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 |