Three overlapping rate limiters with inconsistent 429 response shapes #45
Labels
No labels
blocked:gate-fetch
blocked:gate-fetch
blocked:gate-implement
blocked:gate-plan
blocked:needs-attention
blocked:needs-decision
blocked:needs-human
blocked:no-procedure
blocked:preflight
bug
feature-request
in-progress
lint-cleanup
needs-decompose
needs-triage
ops-task
quickfix
security/medium
triage-approved
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Mark/msp_c2#45
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
core/rate_limiter.py (RateLimiter class + rate_limit_middleware), core/middleware.py (_is_login_rate_limit_exceeded, hardcoded 10/60s login limit), and core/security_middleware.py (RateLimitMiddleware, production only) all rate-limit the same endpoints independently. They return different bodies: rate_limit_middleware sends {"detail": "Rate limit exceeded", "retry_after": N} with X-RateLimit headers, middleware.py sends {"detail": "Too many requests"}. Attackers get the strictest limit (5 sensitive logins) while legitimate users hit whichever fires first, and the duplicated sliding-window state is hard to reason about. Consolidate on core/rate_limiter.py and delete the other two.
Design spec — Unified Rate Limiter Interface (kanban t_aa478233, audit at origin/main
69e5b4b2). No code changes in this card; this spec drives the implementation card.Unified Rate Limiter Interface — Design Spec (Mark/msp_c2#45)
Status: spec (no code changes). Source audit at origin/main
69e5b4b2.1. Current state (audit of the three implementations)
core/rate_limiter.pyRateLimiter+rate_limit_middlewarecore/config.py){"detail": "Rate limit exceeded", "retry_after": N}core/middleware.py_is_login_rate_limit_exceededinsiderequest_context_middlewarePOST /api/v1/auth/login: 10/60s per-IP (1000 in test) — hardcoded{"detail": "Too many requests"}core/security_middleware.pyRateLimitMiddleware(registered only whenENVIRONMENT == "production")"Rate limit exceeded. Please try again later."Registration order in
backend/main.py: CORS →register_middleware(request-id/logging + login rate-limit branch) →app.middleware("http")(rate_limit_middleware)→ GZip →setup_security_middleware(prod: SecurityHeaders + RateLimit + HTTPSRedirect).Findings:
middleware.py10/60s andrate_limiter.pySENSITIVE 5/60s. The 5/min scope fires first in practice, so attackers see 5/min while the 10/60 path is a redundant second net with independent sliding-window state.rate_limit_middlewarecomputesretry_afterfor the body but never emits theRetry-Afterheader — must be added.RateLimitMiddlewarereturns a plain-text body and no X-RateLimit-* headers, inconsistent with both other emitters..env.productioncarriesRATE_LIMIT_REQUESTS=100/RATE_LIMIT_WINDOW=60, which nothing reads (core/config.pyuses the per-scopeRATE_LIMIT_*_REQUESTS/RATE_LIMIT_*_WINDOWnames). Stale env, cleanup candidate.frontend/srchas zero references to "Too many requests" /retry_after/ X-RateLimit. The string exists only inbackend/core/middleware.py(RATE_LIMIT_DETAIL).backend/tests/test_rate_limiting.pyalready asserts the canonical body ("Rate limit exceeded"+retry_after) at the 6th login request — that assertion is satisfied by the SENSITIVE scope, not bymiddleware.py(whose test limit is 1000).conftest.pyresetsrate_limiter.rate_limiter._windowsbetween tests — that fixture stays valid.2. Target design
2.1 Single canonical entry point
core/rate_limiter.py(RateLimiterclass +rate_limit_middleware) is the sole rate-limiting mechanism.main.pykeepsapp.middleware("http")(rate_limit_middleware). The login branch incore/middleware.pyandRateLimitMiddlewareincore/security_middleware.pyare deleted.2.2 Unified 429 response (every rate-limit rejection)
429{"detail": "Rate limit exceeded", "retry_after": <int seconds>}— the "Too many requests" and plain-text variants are dropped.Retry-After: <int seconds>(same value as bodyretry_after; missing today — add torate_limit_middleware)X-RateLimit-Limit: <limit>X-RateLimit-Remaining: 0X-RateLimit-Reset: <unix epoch seconds>X-Request-Id(existing behavior, keep)X-RateLimit-*headers (existing behavior inrate_limit_middleware).2.3 Data-driven limits table (endpoint family → (count, window))
Replace every hardcoded limit with one table in
core/config.py, env-overridable, keyed by endpoint family. Suggested shape:RATE_LIMIT_PER_USER_REQUESTS,RATE_LIMIT_PER_IP_REQUESTS,RATE_LIMIT_SENSITIVE_REQUESTS, and*_WINDOWcounterparts incore/config.py) as the value source so deployed envs keep working; the class maps family → (count, window, scope) instead of the current fixed enum→tuple dict.rate_limit_middlewarehardcodesis_sensitive_endpoint = (POST, /api/v1/auth/login). Move it to a config list (e.g.RATE_LIMIT_SENSITIVE_PATHS) so adding sensitive endpoints is a config change.middleware.pylogin limit. Under unification the login endpoint is governed by the sensitive-login scope at 5/min (matches issue text: "Attackers get the strictest limit (5 sensitive logins)"). The general scope defaults stay at the current data-driven values (100/min per-user, 20/min per-IP); moving any family to 10/min is a one-line env change, not code.2.4 Deletion list
core/middleware.py:RATE_LIMIT_DETAILconstant,_TEST_MODE/_LOGIN_RATE_LIMIT_MAX_REQUESTS/_LOGIN_RATE_LIMIT_WINDOW_SECONDS/_LOGIN_RATE_LIMIT_ROUTEconstants_rate_limit_windowsdeque_get_client_ip(used only by the login rate-limit branch)_is_login_rate_limit_exceeded()request_context_middleware(theif _is_login_rate_limit_exceeded(request):block)os,time,defaultdict,deque(and the inlineHTTP_429_TOO_MANY_REQUESTS/JSONResponseimports used only by the branch)REQUEST_ID_HEADER,_resolve_request_id,_extract_user_context, request logging, both exception handlers,register_middlewarecore/security_middleware.py:RateLimitMiddlewareclass entirelyapp.add_middleware(RateLimitMiddleware, max_requests=100, window=60)insetup_security_middlewaretimeSecurityHeadersMiddleware,HTTPSRedirectMiddleware,setup_security_middlewareminus the RateLimit registration (the production-only gating for headers/HTTPS-redirect is unrelated to rate limiting and stays)backend/main.py: no import/registration changes needed —rate_limit_middleware(canonical) stays registered,register_middlewareandsetup_security_middlewarestill exist.2.5 Backward compatibility
{"detail": "Too many requests"}(login 429) will now receive{"detail": "Rate limit exceeded", "retry_after": N}— same 429 status, different body.frontend/src). External API consumers should key onstatus_code == 429and theRetry-Afterheader, not the detail string.test_rate_limiting.pyalready asserts the new body and passes via the SENSITIVE scope;conftest.py's window-reset fixture still targets the canonical limiter. No test changes required.2.6 Verification gate (for the implementer)
git grep -nE "_is_login_rate_limit_exceeded|RateLimitMiddleware|Too many requests"→ no hits inbackend/core/git grep -n "Rate limit exceeded"→ exactly one emitter,backend/core/rate_limiter.py{"detail": "Rate limit exceeded", "retry_after": N}plusRetry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Resetpytest backend/testspasses (rate-limiter + security suites unaffected otherwise)Fix landed at
8199e11on main (2026-08-14) per the unified-interface spec (comment 52184). Deleted middleware.py login rate-limit branch (_is_login_rate_limit_exceeded, RATE_LIMIT_DETAIL, _get_client_ip, _rate_limit_windows) and security_middleware.py RateLimitMiddleware; rate limiting is now solely core/rate_limiter.py RateLimiter + rate_limit_middleware. Limits table data-driven via core/config.py RATE_LIMITS (endpoint family -> count/window/scope); sensitive-login 5/60s; RATE_LIMIT_SENSITIVE_PATHS configurable. All 429s use canonical body {"detail":"Rate limit exceeded","retry_after":} plus Retry-After and X-RateLimit-Limit/Remaining/Reset. Verification: pytest backend/tests 217 passed; 6th login -> 429 with full canonical headers; git grep for deleted symbols: no hits in backend/core/. This commit supersedes the earlier speedrun closeout (no code).Regression tests for the unified rate limiter landed in commit
f9aa117(backend/tests/test_rate_limiter.py, 6 tests):Verified:
pytest tests/test_rate_limiter.py -q6 passed; full backend suite 208 passed (15 deselected by CI markers). Note: repo Actions CI has no live runner (localhost-runner stopped, zsus dead) so CI runs cannot execute.