Skip to content

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.