fix(dashboard): add per-IP rate limiting to /api/badge/* endpoints (PILOT-338)#19
fix(dashboard): add per-IP rate limiting to /api/badge/* endpoints (PILOT-338)#19matthew-pilot wants to merge 1 commit into
Conversation
…ILOT-338) The two public badge endpoints (/api/badge/nodes, /api/badge/requests) had no per-IP rate limiting, allowing unlimited scrape by any client. While these are non-confidential public stats, unbounded request volume wastes CPU on SVG generation. Add a sliding-window per-IP rate limiter (30 req/min per IP) to both badge endpoints, following the same client-IP extraction pattern used by localhostOnly (respects X-Real-IP from trusted reverse proxies). Closes PILOT-338
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
🦜 Matthew PR Check — #19 PILOT-338Status
SummaryAdds per-IP sliding-window rate limiting (30 req/min) to VerdictCLEAN — all CI green, single-file change, well-scoped. Ready for review. |
🦜 Matthew Explains — #19 PILOT-338What this doesAdds per-IP sliding-window rate limiting (30 req/min/IP) to the two public WhyThe accept-layer global limiter (PILOT-317) only covers TCP accept, not the HTTP mux. The badge endpoints were un-throttled, allowing unbounded scrape of public node/request counts — wasting CPU on SVG generation. How
Scope1 file: |
🦜 Matthew PR Check — #19 PILOT-338Status
SummaryAdds per-IP sliding-window rate limiting (30 req/min) to VerdictCLEAN — all CI green, single-file, well-scoped. |
Matthew PR Check -- #19 PILOT-338Status
SummaryAdds per-IP sliding-window rate limiting (30 req/min) to /api/badge/nodes and /api/badge/requests. New ipRateLimiter type with mutex-guarded per-IP bucket map + periodic sweep. VerdictCLEAN -- all CI green, single-file, well-scoped. |
Matthew Explains -- #19 PILOT-338What this doesAdds per-IP sliding-window rate limiting (30 req/min/IP) to the two public /api/badge/* endpoints in the rendezvous dashboard. WhyThe accept-layer global limiter (PILOT-317) only covers TCP accept, not the HTTP mux. The badge endpoints were un-throttled, allowing unbounded scrape of public node/request counts -- wasting CPU on SVG generation. How
Scope1 file: dashboard/dashboard.go (+73/-5). No API surface changes. No new dependencies. |
File:line walkthrough —
|
| Line | Change |
|---|---|
| L118 | Added badgeLimiter *ipRateLimiter field to Handler struct |
| L135 | Initialize badgeLimiter via newIPRateLimiter() in NewHandler() |
New ipRateLimiter type (L427–496)
| Line | Change |
|---|---|
| L429–437 | ipRateLimiter struct with sync.Mutex-guarded map[string]*ipBucket; ipBucket holds count + resetAt |
| L439–441 | newIPRateLimiter() constructor |
| L445–454 | extractClientIP() helper — respects X-Real-IP header when direct connection is from loopback |
| L458–496 | middleware(maxReqs, window, next) — checks per-IP bucket, returns 429 on exceed, resets window on expiry. Periodic cleanup: every 1000th increment sweeps expired entries to prevent unbounded map growth |
Handler wiring (L696, L704)
| Line | Change |
|---|---|
| L696 | /api/badge/nodes wrapped with h.badgeLimiter.middleware(30, time.Minute, ...) |
| L704 | /api/badge/requests wrapped with h.badgeLimiter.middleware(30, time.Minute, ...) |
Design notes: 30 req/min/IP is generous for badge scrapers but prevents runaway loops. The periodic-sweep-on-increment pattern avoids a separate goroutine and timer. Bucket map grows only with distinct IPs seen within the current window.
|
PR State: OPEN | MERGEABLE (CLEAN, no conflicts) CI: ✅ Canary: not configured (Go unit-test-only project, no deployable artifact) Jira: PILOT-338 — QA/IN-REVIEW (assigned to Teodor Calin) Labels: Last operator activity: none (self-created PR by matthew-pilot) |
🦜 Matthew PR Check — #19 PILOT-338Status
CI Detail
VerdictCLEAN — all CI green, mergeable. Adds per-IP sliding-window rate limiting (30 req/min) to 🤖 matthew-pr-worker · 2026-05-30T07:28Z |
🦜 Matthew Explains — #19 PILOT-338What this doesAdds per-IP sliding-window rate limiting (30 requests per minute) to the two public Why it mattersThe accept-layer global limiter (from PILOT-317) only applies to the TCP accept path — not the HTTP mux. The badge endpoints ( How it works
Files changed
CI noteClean — 2/2 green (test, codecov/patch). Single-file change, no canary needed. 🤖 matthew-pr-worker · 2026-05-30T07:28Z |
What
Add per-IP sliding-window rate limiting (30 req/min) to the two public /api/badge/* endpoints.
Why
PILOT-338 — the accept-layer global limiter (PILOT-317) only applies to the TCP accept path, not the HTTP mux. The badge endpoints were completely un-throttled. While the data is public (node/request counts), unbounded scrape wastes CPU on SVG generation and inflates request metrics.
How
ipRateLimitertype with mutex-guarded per-IP bucket mapextractClientIP()helper shared with existing localhostOnly patternmiddleware()wraps http.HandlerFunc with 30-req/min/IP enforcementVerification
go build ./...— cleango vet ./...— cleango test ./...— all packages pass (dashboard sub-package: 3.0s)Closes PILOT-338