SCRUM-194 — gateway_dev: per-IP rate limiting¶
Plan ref: GWD-2 (docs/11-admin-plane-plan.md). Stacked on SCRUM-202. Closes gap
10 in doc 10 ("no rate limiting on gateway_dev").
What changed¶
Ported services/gateway's limiter (same code, same keying on RemoteAddr,
X-Forwarded-For never trusted) and wired it per route:
| Routes | Bucket | Default |
|---|---|---|
/admin/ (static SPA) |
none — a page load fetches dozens of hashed assets | — |
/admin-auth/ (login, refresh, logout, mfa, onboard) |
login | 5 rps, burst 10 |
everything else, incl. /api/admin/* |
general | 20 rps, burst 40 |
/ catch-all 404 |
none (a 404 costs nothing; limiting it maps the surface) | — |
Limiter runs before auth (junk tokens cost no signature check). 429 body:
{"error":{"code":"rate_limit_exceeded","message":"too many requests",…}}, no
Retry-After. New env: GATEWAY_DEV_LOGIN_RATE_LIMIT_RPS/_BURST,
GATEWAY_DEV_SWEEP_INTERVAL (5m), GATEWAY_DEV_MAX_IDLE_AGE (10m); existing
GATEWAY_DEV_RATE_LIMIT_RPS/_BURST are now enforced. Main's wiring moved into
buildPublicMux so tests exercise the production chain.
Tunnel caveat: through the SSH tunnel all admins share one peer IP, so the buckets are effectively team-wide. Per-account lockout (SCRUM-203) is the real brute-force control.
How to verify¶
cd services/gateway_dev
go vet ./... && go test -race -count=1 ./...
bash testdata/smoke/smoke.sh | grep -E "429|FINDING"
Expected smoke lines:
12 x POST /admin-auth/login back to back -> 200 ×10 429 429 [expect at least one 429]
one more POST /admin-auth/login -> 429 rate_limit_exceeded
| Test | Proves |
|---|---|
internal/ratelimit (ported) |
per-IP buckets are independent; 429 is COM-5; sweeper evicts idle entries |
TestRateLimit_LoginRouteUsesStricterLimiter |
11th login in a burst → 429 |
TestRateLimit_AdminBundleIsUnlimited |
60 rapid /admin/assets/* → no 429 |
TestRateLimit_GeneralBucketAfterBurst |
/api/admin/session/players 429s after 40 |
TestRateLimit_UnknownPathNeverLimited |
catch-all never 429 |
TestRateLimit_Login/UnlimitedPatternsExistInRouteTable |
a route rename can't silently drop a bucket |
| config tests | defaults, overrides, 8 rejection cases (non-positive values) |
Results at time of writing¶
go vet,go test -race: pass (4 packages). Smoke: login 429s as above, 0 findings.
How it was built¶
DeepSeek run scoped (Landlock) to services/gateway_dev (149 s, ~26k output
tokens), porting services/gateway. Claude review: limiter diffed against the
reference (identical but for the import path), wiring read, README tunnel caveat.