Browse Source

Skip IP rate counter for anonymous /api/ requests

Anonymous API requests now pass through after the quota check without
incrementing rl:ip:{ip}:reqs or writing a block key. A misbehaving
script or JS snippet behind a NAT IP can no longer lock out the org's
page requests by hammering /api/.

Enforcement for anonymous /api/:
  - nginx sapl_api zone (60r/m, burst=120) — burst gate
  - API quota (500/day, 3500/week) — daily cap

Authenticated /api/ still falls through to _evaluate_authenticated
(per-user counter keyed by uid, NAT-safe).

Interim measure until APP_ACCESS_KEYs per tenant org are introduced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rate-limiter-2026
Edward Ribeiro 3 weeks ago
parent
commit
8d17a5cc16
  1. 12
      sapl/middleware/ratelimit.py

12
sapl/middleware/ratelimit.py

@ -3,6 +3,8 @@ RateLimitMiddleware — cross-pod rate limiting backed by shared Redis.
Decision flow (per request): Decision flow (per request):
0. /api/ path AND consumer daily/weekly quota exceeded? 429 0. /api/ path AND consumer daily/weekly quota exceeded? 429
Anonymous /api/ (quota not exceeded): pass immediately no IP counter,
no block key. nginx sapl_api zone (60r/m) is the burst gate.
1. Known bot UA? 429 (Python list substring match) 1. Known bot UA? 429 (Python list substring match)
1b. Redis UA deny list? 429 (runtime SET token hash match, refreshed every 60 s) 1b. Redis UA deny list? 429 (runtime SET token hash match, refreshed every 60 s)
2. Anonymous AND IP in blocked set? 429 (authenticated users skip have per-user limit at 3c) 2. Anonymous AND IP in blocked set? 429 (authenticated users skip have per-user limit at 3c)
@ -298,6 +300,16 @@ class RateLimitMiddleware:
response['X-RateLimit-Reason'] = f'quota_{exceeded}' response['X-RateLimit-Reason'] = f'quota_{exceeded}'
return response return response
# Anonymous /api/ requests: quota + nginx sapl_api zone are the only
# controls. Skip _evaluate so anonymous API traffic never increments
# the global IP counter or writes a block key — a misbehaving script
# behind a NAT must not lock out the org's page requests.
# Authenticated /api/ falls through to _evaluate_authenticated normally
# (per-user counter, NAT-safe).
user = getattr(request, 'user', None)
if not (user and user.is_authenticated):
return self.get_response(request)
decision = self._evaluate(request) decision = self._evaluate(request)
if decision['action'] == 'block': if decision['action'] == 'block':
logger.warning( logger.warning(

Loading…
Cancel
Save