@ -6,14 +6,19 @@ Decision flow (per request):
- 1. IP in rl : ip_prefix : blocked ( prefix match ) ? → 429
- 1. IP in rl : ip_prefix : blocked ( prefix match ) ? → 429
( universal — applies to authenticated users too , like the UA bot checks )
( universal — applies to authenticated users too , like the UA bot checks )
/ api / paths — handled by _handle_api :
/ api / paths — handled by _handle_api :
0 a . OPTIONS ? → pass ( CORS preflight must never be blocked )
0 a . OPTIONS ? → pass ( CORS preflight must never be blocked )
0 b . Same - origin ? → pass ( SAPL ' s own browser polling )
0 b . rl : ip_prefix : blocked ? → 429 ( prefix match ; see - 1 above )
0 c . rl : ip : < ip > : blocked ? → 429 ( global block also covers / api / )
0 c . rl : ip : < ip > : blocked ? → 429 ( global block also covers / api / )
0 d . rl : api : ip : < ip > : blocked ? → 429 ( API - only block )
0 d . rl : api : ip : < ip > : blocked ? → 429 ( API - only block )
0 e . Daily / weekly quota exceeded ? → 429
Block checks 0 b - 0 d always run before the same - origin check below —
0 f . Anon + API threshold exceeded ? → SET rl : api : ip : < ip > : blocked , 429
Origin / Referer are client - controlled and trivially spoofable , so they
must never be able to override an already - made block decision .
0 e . Same - origin ? → pass , skipping 0 f - 0 g only ( SAPL ' s own polling
is exempt from quota / rate - limit accounting , never from active blocks )
0 f . Daily / weekly quota exceeded ? → 429
0 g . Anon + API threshold exceeded ? → SET rl : api : ip : < ip > : blocked , 429
( never writes rl : ip : < ip > : blocked )
( never writes rl : ip : < ip > : blocked )
0 g . Auth : falls through to _evaluate ( per - user counter )
0 h . Auth : falls through to _evaluate ( per - user counter )
Non - / api / paths :
Non - / api / paths :
1. Known bot UA ? → 429 ( Python list — substring match )
1. Known bot UA ? → 429 ( Python list — substring match )
1 b . Redis UA deny list ? → 429 ( runtime SET — token hash match , refreshed every 60 s )
1 b . Redis UA deny list ? → 429 ( runtime SET — token hash match , refreshed every 60 s )
@ -448,13 +453,12 @@ class RateLimitMiddleware:
if request . method == ' OPTIONS ' :
if request . method == ' OPTIONS ' :
return self . get_response ( request )
return self . get_response ( request )
# 2. Same-origin (SAPL's own polling) — no counter, no block
if self . api_same_origin_bypass and _is_same_origin ( request ) :
return self . get_response ( request )
ip = get_client_ip ( request )
ip = get_client_ip ( request )
# 3a. IP-prefix block — operator-curated deny list, applies to everyone
# 2. IP-prefix block — operator-curated deny list, applies to everyone.
# Block checks (2-4) must run before the same-origin bypass below:
# Origin/Referer are client-controlled and trivially spoofable, so
# they must never be able to override an already-made block decision.
if self . _is_ip_prefix_blocked ( ip ) :
if self . _is_ip_prefix_blocked ( ip ) :
logger . warning (
logger . warning (
' api_rate_limit_block reason=ip_prefix_blocked ip= %s path= %s user_agent= %s ' ,
' api_rate_limit_block reason=ip_prefix_blocked ip= %s path= %s user_agent= %s ' ,
@ -481,7 +485,13 @@ class RateLimitMiddleware:
self . _inc_block_metric ( ' api_ip_blocked ' )
self . _inc_block_metric ( ' api_ip_blocked ' )
return self . _api_block_response ( ' api_ip_blocked ' )
return self . _api_block_response ( ' api_ip_blocked ' )
# 5. Daily/weekly quota (existing logic, preserved)
# 5. Same-origin (SAPL's own polling) — exempt from quota/rate-limit
# accounting only (steps 6-7 below). Must come after the block
# checks above, never before them.
if self . api_same_origin_bypass and _is_same_origin ( request ) :
return self . get_response ( request )
# 6. Daily/weekly quota (existing logic, preserved)
exceeded = self . _check_api_quota ( request )
exceeded = self . _check_api_quota ( request )
if exceeded :
if exceeded :
logger . warning (
logger . warning (
@ -495,7 +505,7 @@ class RateLimitMiddleware:
response [ ' X-RateLimit-Reason ' ] = f ' quota_ { exceeded } '
response [ ' X-RateLimit-Reason ' ] = f ' quota_ { exceeded } '
return response
return response
# 6 . Per-minute rate limit — 60/min for all callers (anon and auth).
# 7 . Per-minute rate limit — 60/min for all callers (anon and auth).
# Auth is not exempt: authenticating must not bypass this cap.
# Auth is not exempt: authenticating must not bypass this cap.
# Writes rl:api:ip:<ip>:blocked only — never rl:ip:<ip>:blocked.
# Writes rl:api:ip:<ip>:blocked only — never rl:ip:<ip>:blocked.
if self . api_rate_limit_enabled :
if self . api_rate_limit_enabled :