A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 7953e78178

7953e78178ac7a6f7e586c39ef3660ce68ce6c64

parent: 40dab3e11b

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-27T04:01:35Z

httpd: rate limit the JSON API

The HTTP surface had no limiting at all — the SSH limiter counts auth
failures only, so every command in the registry was reachable at whatever
rate a caller managed.

A token bucket per caller, keyed by account when authenticated so
rotating tokens buys no budget, and by peer address otherwise. Writes
draw on a separate tenth-sized budget, so a client that hits the write
ceiling can still read. 429 carries Retry-After. limits.api_rate sets
the sustained per-minute rate, default 120.

Closes #39
e2e/api_test.go +81
@@ -161,3 +161,84 @@ func TestJSONAPI(t *testing.T) {
161161 t.Fatalf("API on disabled instance: %d, want 404", resp.StatusCode)
162162 }
163163 }
164
165// TestAPIRateLimit covers the limiter over the wire: a caller who exceeds
166// their budget gets 429 with a Retry-After a client can honour, writes are
167// metered separately from reads, and one caller cannot spend another's
168// budget.
169func TestAPIRateLimit(t *testing.T) {
170 // 6/minute sustained, so the read burst is 6 and the write burst 0.6 —
171 // the first write is allowed and the second is not.
172 inst := startInstanceWith(t, "[api]\nenabled = true\n[limits]\napi_rate = 6\n")
173 aliceKey := inst.newKey(t, "alice")
174 bobKey := inst.newKey(t, "bob")
175 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
176 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
177 aliceTok := mintToken(t, inst, aliceKey, "alice-app")
178 bobTok := mintToken(t, inst, bobKey, "bob-app")
179
180 // Reads: the burst is spendable, then the door closes.
181 var limited bool
182 for i := 0; i < 12; i++ {
183 status, body := inst.apiCall(t, aliceTok, []string{"whoami"}, "")
184 if status == http.StatusTooManyRequests {
185 limited = true
186 if msg, _ := body["error"].(string); !strings.Contains(msg, "retry") {
187 t.Errorf("429 body does not say when to retry: %v", body)
188 }
189 break
190 }
191 }
192 if !limited {
193 t.Fatal("a caller never hit the rate limit")
194 }
195
196 // The 429 carries Retry-After, so a client backs off correctly instead
197 // of hammering.
198 req, _ := http.NewRequest("POST",
199 fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst.httpPort),
200 strings.NewReader(`{"argv":["whoami"]}`))
201 req.Header.Set("Authorization", "Bearer "+aliceTok)
202 resp, err := http.DefaultClient.Do(req)
203 if err != nil {
204 t.Fatal(err)
205 }
206 resp.Body.Close()
207 if resp.StatusCode != http.StatusTooManyRequests {
208 t.Fatalf("expected a second 429, got %d", resp.StatusCode)
209 }
210 if ra := resp.Header.Get("Retry-After"); ra == "" || ra == "0" {
211 t.Errorf("Retry-After = %q", ra)
212 }
213
214 // One caller's flood does not spend another's budget.
215 if status, _ := inst.apiCall(t, bobTok, []string{"whoami"}, ""); status != http.StatusOK {
216 t.Errorf("bob was limited by alice's traffic: %d", status)
217 }
218
219 // Writes are metered separately: bob's read budget is nearly full, but
220 // his write budget is not.
221 inst.apiCall(t, bobTok, []string{"repo", "create", "bob/one"}, "")
222 status, _ := inst.apiCall(t, bobTok, []string{"repo", "create", "bob/two"}, "")
223 if status != http.StatusTooManyRequests {
224 t.Errorf("second write status %d, want 429 from the write budget", status)
225 }
226}
227
228// mintToken creates an API token over SSH and returns its value.
229func mintToken(t *testing.T, inst *instance, key, name string) string {
230 t.Helper()
231 out, errOut, code := inst.ssh(t, key, "", "token", "create", "--name", name, "--json")
232 if code != 0 {
233 t.Fatalf("token create: %s", errOut)
234 }
235 var env struct {
236 Data struct {
237 Token string `json:"token"`
238 } `json:"data"`
239 }
240 if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Token == "" {
241 t.Fatalf("token JSON: %v\n%s", err, out)
242 }
243 return env.Data.Token
244}
internal/config/config.go +4
@@ -114,6 +114,9 @@ type Limits struct {
114114 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset
115115 CloneTimeoutSec int `toml:"clone_timeout"`
116116 SSHAuthRate int `toml:"ssh_auth_rate"`
117 // APIRate is sustained JSON-API requests per minute per caller; writes
118 // draw on a tenth of it. 0 uses the default.
119 APIRate int `toml:"api_rate"`
117120 }
118121
119122 type Mail struct {
@@ -141,6 +144,7 @@ func Default() Config {
141144 MaxAssetBytes: 512 << 20,
142145 CloneTimeoutSec: 3600,
143146 SSHAuthRate: 10,
147 APIRate: 120,
144148 },
145149 }
146150 }
internal/httpd/api.go +22
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "net/http"
9 "strconv"
910 "strings"
1011
1112 "gitbay.org/gitbay/internal/control"
@@ -47,6 +48,18 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
4748 return
4849 }
4950
51 // Rate limit after auth so the bucket follows the token rather than the
52 // network, but before dispatch so a rejected call costs nothing beyond
53 // the lookup. A write draws on a separate, smaller budget.
54 write := true
55 if cmd, _, ok := control.Lookup(req.Argv); ok {
56 write = !cmd.ReadOnly
57 }
58 if allowed, wait := s.apiLimit.allow(limitKey(r, user), write); !allowed {
59 tooManyRequests(w, wait)
60 return
61 }
62
5063 var stdout, stderr bytes.Buffer
5164 ctx := &control.Ctx{
5265 User: user,
@@ -91,6 +104,15 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
91104 json.NewEncoder(w).Encode(body)
92105 }
93106
107// limitKey buckets an authenticated caller by account, so rotating tokens
108// buys no extra budget, and everyone else by peer address.
109func limitKey(r *http.Request, user store.User) string {
110 if user.ID != 0 {
111 return "u" + strconv.FormatInt(user.ID, 10)
112 }
113 return "ip" + clientIP(r)
114}
115
94116 // apiAuth resolves the bearer token; failures are uniform 401s.
95117 func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) {
96118 token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
internal/httpd/apilimit.go added +119
@@ -0,0 +1,119 @@
1package httpd
2
3import (
4 "net"
5 "net/http"
6 "strconv"
7 "sync"
8 "time"
9)
10
11// apiLimiter is a token bucket per caller, with a separate, smaller budget
12// for writes. Unlike the SSH limiter — which counts only auth failures,
13// because a busy CLI opens many connections legitimately — this counts
14// every request: one HTTP call is one command, and a client looping over a
15// list can issue them far faster than a person can type.
16//
17// Keyed by token hash when authenticated, so one caller's budget follows
18// them across networks, and by IP otherwise, so an unauthenticated flood
19// cannot mint budget by rotating tokens.
20type apiLimiter struct {
21 mu sync.Mutex
22 buckets map[string]*bucket
23 rate float64 // requests per second, sustained
24 burst float64
25 writes float64 // sustained write rate, a fraction of rate
26}
27
28type bucket struct {
29 read, write float64
30 last time.Time
31}
32
33func newAPILimiter(perMinute int) *apiLimiter {
34 if perMinute <= 0 {
35 perMinute = 120
36 }
37 rate := float64(perMinute) / 60
38 return &apiLimiter{
39 buckets: map[string]*bucket{},
40 rate: rate,
41 burst: float64(perMinute),
42 // Writes are rarer and more expensive; a tenth of the read budget
43 // is generous for a client and useless for a scraper.
44 writes: rate / 10,
45 }
46}
47
48// allow reports whether the caller may make this request, and how long to
49// wait if not. write requests draw on both buckets: a write is also a
50// request.
51func (l *apiLimiter) allow(key string, write bool) (bool, time.Duration) {
52 l.mu.Lock()
53 defer l.mu.Unlock()
54 now := time.Now()
55
56 if len(l.buckets) > 4096 {
57 for k, b := range l.buckets {
58 if now.Sub(b.last) > 10*time.Minute {
59 delete(l.buckets, k)
60 }
61 }
62 }
63
64 b := l.buckets[key]
65 if b == nil {
66 b = &bucket{read: l.burst, write: l.burst / 10, last: now}
67 l.buckets[key] = b
68 }
69 elapsed := now.Sub(b.last).Seconds()
70 b.last = now
71 b.read = minf(l.burst, b.read+elapsed*l.rate)
72 b.write = minf(l.burst/10, b.write+elapsed*l.writes)
73
74 if b.read < 1 {
75 return false, retryAfter(1-b.read, l.rate)
76 }
77 if write && b.write < 1 {
78 return false, retryAfter(1-b.write, l.writes)
79 }
80 b.read--
81 if write {
82 b.write--
83 }
84 return true, 0
85}
86
87func retryAfter(deficit, rate float64) time.Duration {
88 if rate <= 0 {
89 return time.Minute
90 }
91 d := time.Duration(deficit / rate * float64(time.Second))
92 if d < time.Second {
93 return time.Second
94 }
95 return d
96}
97
98func minf(a, b float64) float64 {
99 if a < b {
100 return a
101 }
102 return b
103}
104
105// clientIP is the peer address. No forwarded headers are trusted: nothing
106// in front of this process is required to set them, and honouring a
107// client-supplied header would let a caller pick their own bucket.
108func clientIP(r *http.Request) string {
109 if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
110 return host
111 }
112 return r.RemoteAddr
113}
114
115func tooManyRequests(w http.ResponseWriter, wait time.Duration) {
116 w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds()+0.5)))
117 apiError(w, http.StatusTooManyRequests,
118 "rate limited; retry in "+strconv.Itoa(int(wait.Seconds()+0.5))+"s")
119}
internal/httpd/apilimit_test.go added +76
@@ -0,0 +1,76 @@
1package httpd
2
3import (
4 "testing"
5 "time"
6)
7
8func TestAPILimiterBurstThenRefill(t *testing.T) {
9 l := newAPILimiter(60) // 1/s sustained, burst 60
10
11 // The burst is spendable immediately.
12 for i := 0; i < 60; i++ {
13 if ok, _ := l.allow("u1", false); !ok {
14 t.Fatalf("read %d rejected inside the burst", i)
15 }
16 }
17 ok, wait := l.allow("u1", false)
18 if ok {
19 t.Fatal("burst did not run out")
20 }
21 if wait < time.Second {
22 t.Errorf("Retry-After %v, want at least a second", wait)
23 }
24
25 // Refill is by elapsed time, so a caller recovers without a restart.
26 l.buckets["u1"].last = time.Now().Add(-5 * time.Second)
27 if ok, _ := l.allow("u1", false); !ok {
28 t.Error("bucket did not refill")
29 }
30}
31
32func TestAPILimiterWritesHaveTheirOwnBudget(t *testing.T) {
33 l := newAPILimiter(60) // write burst is 6
34
35 for i := 0; i < 6; i++ {
36 if ok, _ := l.allow("u1", true); !ok {
37 t.Fatalf("write %d rejected inside the write burst", i)
38 }
39 }
40 if ok, _ := l.allow("u1", true); ok {
41 t.Fatal("writes are not separately limited")
42 }
43 // Reads still have budget: a client that hit the write ceiling can
44 // still render a page.
45 if ok, _ := l.allow("u1", false); !ok {
46 t.Error("exhausting writes also blocked reads")
47 }
48}
49
50func TestAPILimiterIsPerCaller(t *testing.T) {
51 l := newAPILimiter(60)
52 for i := 0; i < 60; i++ {
53 l.allow("u1", false)
54 }
55 if ok, _ := l.allow("u1", false); ok {
56 t.Fatal("u1 not limited")
57 }
58 if ok, _ := l.allow("u2", false); !ok {
59 t.Error("one caller's flood limited another")
60 }
61}
62
63// A caller with no budget must not be able to buy more by making the
64// limiter forget them; the sweep only drops buckets that are idle.
65func TestAPILimiterSweepKeepsActiveCallers(t *testing.T) {
66 l := newAPILimiter(60)
67 for i := 0; i < 60; i++ {
68 l.allow("victim", false)
69 }
70 for i := 0; i < 4100; i++ {
71 l.allow(string(rune(i))+"filler", false)
72 }
73 if ok, _ := l.allow("victim", false); ok {
74 t.Error("an active caller's bucket was swept, resetting their budget")
75 }
76}
internal/httpd/smart.go +4 −3
@@ -20,12 +20,13 @@ import (
2020 )
2121
2222 type Server struct {
23 cfg config.Config
24 st *store.Store
23 cfg config.Config
24 st *store.Store
25 apiLimit *apiLimiter
2526 }
2627
2728 func New(cfg config.Config, st *store.Store) *Server {
28 return &Server{cfg: cfg, st: st}
29 return &Server{cfg: cfg, st: st, apiLimit: newAPILimiter(cfg.Limits.APIRate)}
2930 }
3031
3132 // receivePackRefusal exists only to fail legibly if a client POSTs without