Commit 7953e78178
Verified · cmc ci/build: success
e2e/api_test.go +81
| @@ -161,3 +161,84 @@ func TestJSONAPI(t *testing.T) { | ||
| 161 | 161 | t.Fatalf("API on disabled instance: %d, want 404", resp.StatusCode) |
| 162 | 162 | } |
| 163 | 163 | } |
| 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. | |
| 169 | func 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. | |
| 229 | func 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 { | ||
| 114 | 114 | MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset |
| 115 | 115 | CloneTimeoutSec int `toml:"clone_timeout"` |
| 116 | 116 | 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"` | |
| 117 | 120 | } |
| 118 | 121 | |
| 119 | 122 | type Mail struct { |
| @@ -141,6 +144,7 @@ func Default() Config { | ||
| 141 | 144 | MaxAssetBytes: 512 << 20, |
| 142 | 145 | CloneTimeoutSec: 3600, |
| 143 | 146 | SSHAuthRate: 10, |
| 147 | APIRate: 120, | |
| 144 | 148 | }, |
| 145 | 149 | } |
| 146 | 150 | } |
internal/httpd/api.go +22
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "errors" |
| 7 | 7 | "io" |
| 8 | 8 | "net/http" |
| 9 | "strconv" | |
| 9 | 10 | "strings" |
| 10 | 11 | |
| 11 | 12 | "gitbay.org/gitbay/internal/control" |
| @@ -47,6 +48,18 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { | ||
| 47 | 48 | return |
| 48 | 49 | } |
| 49 | 50 | |
| 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 | ||
| 50 | 63 | var stdout, stderr bytes.Buffer |
| 51 | 64 | ctx := &control.Ctx{ |
| 52 | 65 | User: user, |
| @@ -91,6 +104,15 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { | ||
| 91 | 104 | json.NewEncoder(w).Encode(body) |
| 92 | 105 | } |
| 93 | 106 | |
| 107 | // limitKey buckets an authenticated caller by account, so rotating tokens | |
| 108 | // buys no extra budget, and everyone else by peer address. | |
| 109 | func 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 | ||
| 94 | 116 | // apiAuth resolves the bearer token; failures are uniform 401s. |
| 95 | 117 | func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) { |
| 96 | 118 | token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") |
internal/httpd/apilimit.go added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 20 | type 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 | ||
| 28 | type bucket struct { | |
| 29 | read, write float64 | |
| 30 | last time.Time | |
| 31 | } | |
| 32 | ||
| 33 | func 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. | |
| 51 | func (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 | ||
| 87 | func 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 | ||
| 98 | func 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. | |
| 108 | func 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 | ||
| 115 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "testing" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | func 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 | ||
| 32 | func 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 | ||
| 50 | func 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. | |
| 65 | func 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 ( | ||
| 20 | 20 | ) |
| 21 | 21 | |
| 22 | 22 | type Server struct { |
| 23 | cfg config.Config | |
| 24 | st *store.Store | |
| 23 | cfg config.Config | |
| 24 | st *store.Store | |
| 25 | apiLimit *apiLimiter | |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | 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)} | |
| 29 | 30 | } |
| 30 | 31 | |
| 31 | 32 | // receivePackRefusal exists only to fail legibly if a client POSTs without |