Browse Source

Apply 60/min API rate limit and IP quota to all callers regardless of auth

Authenticating must not bypass /api/ rate controls. The per-minute
threshold (60/min) and daily/weekly quota (500/day · 3 500/week) now
apply to all callers keyed by IP — auth status is not checked.

Auth users no longer fall through to _evaluate for /api/ requests;
_evaluate (240/min per-user) still governs all non-/api/ paths.
QUOTA_USER_DAILY/WEEKLY key templates removed (no longer written).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rate-limiter-2026
Edward Ribeiro 3 weeks ago
parent
commit
1c9ca823e8
  1. 65
      sapl/middleware/ratelimit.py
  2. 4
      sapl/settings.py

65
sapl/middleware/ratelimit.py

@ -87,8 +87,6 @@ RL_INDEX_API_BLOCKED_IPS = 'rl:index:api_blocked_ips'
# TTL set only on first INCR (Lua); daily=24h, weekly=7d — cleanup only, # TTL set only on first INCR (Lua); daily=24h, weekly=7d — cleanup only,
# resets are implicit in the date/week embedded in the key name. # resets are implicit in the date/week embedded in the key name.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
QUOTA_USER_DAILY = 'quota:{ns}:daily:{date}:user:{uid}'
QUOTA_USER_WEEKLY = 'quota:{ns}:weekly:{week}:user:{uid}'
QUOTA_IP_DAILY = 'quota:{ns}:daily:{date}:ip:{ip}' QUOTA_IP_DAILY = 'quota:{ns}:daily:{date}:ip:{ip}'
QUOTA_IP_WEEKLY = 'quota:{ns}:weekly:{week}:ip:{ip}' QUOTA_IP_WEEKLY = 'quota:{ns}:weekly:{week}:ip:{ip}'
@ -324,7 +322,7 @@ class RateLimitMiddleware:
[p.pattern for p in self._bypass_paths] or '(none)', [p.pattern for p in self._bypass_paths] or '(none)',
) )
logger.info( logger.info(
'[API QUOTAS] daily=%s weekly=%s (auth keyed by user pk, anon by IP)', '[API QUOTAS] daily=%s weekly=%s (all callers keyed by IP)',
settings.API_QUOTA_DAILY, settings.API_QUOTA_DAILY,
settings.API_QUOTA_WEEKLY, settings.API_QUOTA_WEEKLY,
) )
@ -428,37 +426,21 @@ class RateLimitMiddleware:
response['X-RateLimit-Reason'] = f'quota_{exceeded}' response['X-RateLimit-Reason'] = f'quota_{exceeded}'
return response return response
user = getattr(request, 'user', None) # 6. Per-minute rate limit — 60/min for all callers (anon and auth).
if not (user and user.is_authenticated): # Auth is not exempt: authenticating must not bypass this cap.
# 6. API-specific per-minute rate limit for anonymous external callers. # 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: count = self._incr_with_ttl(RL_API_IP_REQUESTS.format(ip=ip), self.api_window)
count = self._incr_with_ttl(RL_API_IP_REQUESTS.format(ip=ip), self.api_window) if count >= self.api_threshold:
if count >= self.api_threshold: _set_block(RL_API_IP_BLOCKED.format(ip=ip), RL_INDEX_API_BLOCKED_IPS, self.api_block_seconds)
_set_block(RL_API_IP_BLOCKED.format(ip=ip), RL_INDEX_API_BLOCKED_IPS, self.api_block_seconds) logger.warning(
logger.warning( 'api_rate_limit_block reason=api_threshold_exceeded '
'api_rate_limit_block reason=api_threshold_exceeded ' 'ip=%s path=%s user_agent=%s count=%s threshold=%s',
'ip=%s path=%s user_agent=%s count=%s threshold=%s', ip, request.path, request.META.get('HTTP_USER_AGENT', ''),
ip, request.path, request.META.get('HTTP_USER_AGENT', ''), count, self.api_threshold,
count, self.api_threshold, )
) self._inc_block_metric('api_threshold_exceeded')
self._inc_block_metric('api_threshold_exceeded') return self._api_block_response('api_threshold_exceeded')
return self._api_block_response('api_threshold_exceeded')
return self.get_response(request)
# 7. Authenticated /api/ — existing per-user rate limiter unchanged
decision = self._evaluate(request)
if decision['action'] == 'block':
logger.warning(
'ratelimit_block layer=django reason=%s ip=%s path=%s namespace=%s',
decision['reason'], decision['ip'], request.path, _NAMESPACE,
extra={'ua': request.META.get('HTTP_USER_AGENT', '')},
)
self._inc_block_metric(decision['reason'])
response = HttpResponse(status=429)
response['Retry-After'] = self.BLOCK_TTL
response['X-RateLimit-Reason'] = decision['reason']
return response
return self.get_response(request) return self.get_response(request)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -566,8 +548,7 @@ class RateLimitMiddleware:
def _check_api_quota(self, request): def _check_api_quota(self, request):
""" """
Increment daily and weekly API quota counters for all /api/ callers. Increment daily and weekly API quota counters for all /api/ callers.
Auth users are keyed by user pk (NAT-safe); anon callers by IP. All callers are keyed by IP auth status is not checked.
Both groups share the same daily/weekly cap.
Fails open (returns None) if Redis/cache is unavailable. Fails open (returns None) if Redis/cache is unavailable.
""" """
today = date.today() today = date.today()
@ -575,15 +556,9 @@ class RateLimitMiddleware:
date_str = today.isoformat() date_str = today.isoformat()
week_str = f'{iso[0]}-W{iso[1]:02d}' week_str = f'{iso[0]}-W{iso[1]:02d}'
user = getattr(request, 'user', None) ip = get_client_ip(request)
if user and user.is_authenticated: d_key = QUOTA_IP_DAILY.format(ns=_NAMESPACE, date=date_str, ip=ip)
uid = str(user.pk) w_key = QUOTA_IP_WEEKLY.format(ns=_NAMESPACE, week=week_str, ip=ip)
d_key = QUOTA_USER_DAILY.format(ns=_NAMESPACE, date=date_str, uid=uid)
w_key = QUOTA_USER_WEEKLY.format(ns=_NAMESPACE, week=week_str, uid=uid)
else:
ip = get_client_ip(request)
d_key = QUOTA_IP_DAILY.format(ns=_NAMESPACE, date=date_str, ip=ip)
w_key = QUOTA_IP_WEEKLY.format(ns=_NAMESPACE, week=week_str, ip=ip)
try: try:
if _incr_with_ttl(d_key, 86400) > self.api_quota_daily: if _incr_with_ttl(d_key, 86400) > self.api_quota_daily:

4
sapl/settings.py

@ -437,8 +437,8 @@ RATE_LIMIT_BYPASS_PATHS = [
] ]
# API quota — daily and weekly call caps for all /api/ callers (anon and auth). # API quota — daily and weekly call caps for all /api/ callers (anon and auth).
# Auth users are keyed by user pk (NAT-safe); anon callers by IP. # All callers are keyed by IP — auth status is not checked.
# Both groups share the same cap; weekly default is 7× the daily cap. # Weekly default is 7× the daily cap.
API_QUOTA_DAILY = config('API_QUOTA_DAILY', default=500, cast=int) API_QUOTA_DAILY = config('API_QUOTA_DAILY', default=500, cast=int)
API_QUOTA_WEEKLY = config('API_QUOTA_WEEKLY', default=3500, cast=int) API_QUOTA_WEEKLY = config('API_QUOTA_WEEKLY', default=3500, cast=int)

Loading…
Cancel
Save