@ -2,9 +2,16 @@
RateLimitMiddleware — cross - pod rate limiting backed by shared Redis .
Decision flow ( per request ) :
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 ( 60 r / m ) is the burst gate .
/ api / paths — handled by _handle_api :
0 a . OPTIONS ? → pass ( CORS preflight must never be blocked )
0 b . Same - origin ? → pass ( SAPL ' s own browser polling)
0 c . rl : ip : < ip > : blocked ? → 429 ( global block also covers / api / )
0 d . rl : api : ip : < ip > : blocked ? → 429 ( API - only block )
0 e . Daily / weekly quota exceeded ? → 429
0 f . Anon + API threshold exceeded ? → SET rl : api : ip : < ip > : blocked , 429
( never writes rl : ip : < ip > : blocked )
0 g . Auth : falls through to _evaluate ( per - user counter )
Non - / api / paths :
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 )
2. Anonymous AND IP in blocked set ? → 429 ( authenticated users skip — have per - user limit at 3 c )
@ -68,6 +75,11 @@ RL_METRICS_BLOCKED = 'rl:metrics:{ns}:{date}:blocked:{reason}' # daily counter
RL_INDEX_BLOCKED_IPS = ' rl:index:blocked_ips '
RL_INDEX_BLOCKED_USERS = ' rl:index:blocked_users '
# API-specific rate limit keys — scope limited to /api/, never written by non-/api/ paths.
RL_API_IP_REQUESTS = ' rl:api:ip: {ip} :reqs '
RL_API_IP_BLOCKED = ' rl:api:ip: {ip} :blocked '
RL_INDEX_API_BLOCKED_IPS = ' rl:index:api_blocked_ips '
# ---------------------------------------------------------------------------
# API quota keys — per-consumer, per-day/week, tenant-scoped.
# Consumer identity: authenticated users by uid, anonymous by masked IP.
@ -190,6 +202,41 @@ def smart_rate(group, request):
return settings . RATE_LIMITER_RATE
def _is_same_origin ( request ) :
"""
Return True if Origin or Referer header matches the current SAPL host .
Strips port and lowercases both sides before comparing — DNS is case - insensitive
and reverse proxies may expose a different port than the browser sees .
Checks Origin first ; falls back to Referer only when Origin is absent .
Returns False when both headers are absent .
"""
from urllib . parse import urlparse
def _normalize ( host ) :
return host . lower ( ) . split ( ' : ' , 1 ) [ 0 ] . strip ( )
try :
host = _normalize ( request . get_host ( ) )
except Exception :
return False
origin = request . META . get ( ' HTTP_ORIGIN ' , ' ' )
if origin :
try :
return _normalize ( urlparse ( origin ) . netloc ) == host
except ValueError :
return False
referer = request . META . get ( ' HTTP_REFERER ' , ' ' )
if referer :
try :
return _normalize ( urlparse ( referer ) . netloc ) == host
except ValueError :
return False
return False
def _is_suspicious_headers ( request ) :
""" Real browsers send Accept-Language + Accept; bots frequently omit them. """
missing = sum ( [
@ -265,6 +312,11 @@ class RateLimitMiddleware:
self . api_quota_anon_weekly = settings . API_QUOTA_ANON_WEEKLY
self . api_quota_auth_daily = settings . API_QUOTA_AUTH_DAILY
self . api_quota_auth_weekly = settings . API_QUOTA_AUTH_WEEKLY
self . api_rate_limit_enabled = getattr ( settings , ' API_RATE_LIMIT_ENABLED ' , True )
self . api_threshold = getattr ( settings , ' API_RATE_LIMIT_THRESHOLD ' , 60 )
self . api_window = getattr ( settings , ' API_RATE_LIMIT_WINDOW_SECONDS ' , 60 )
self . api_block_seconds = getattr ( settings , ' API_RATE_LIMIT_BLOCK_SECONDS ' , 300 )
self . api_same_origin_bypass = getattr ( settings , ' API_RATE_LIMIT_SAME_ORIGIN_BYPASS ' , True )
logger . info (
' [RATELIMIT] anon= %s auth= %s bot= %s allowlist= %s bypass_paths= %s ' ,
settings . RATE_LIMITER_RATE ,
@ -280,35 +332,18 @@ class RateLimitMiddleware:
settings . API_QUOTA_AUTH_DAILY ,
settings . API_QUOTA_AUTH_WEEKLY ,
)
logger . info (
' [API RATE LIMIT] enabled= %s threshold= %s window= %s s block= %s s same_origin_bypass= %s ' ,
self . api_rate_limit_enabled , self . api_threshold , self . api_window ,
self . api_block_seconds , self . api_same_origin_bypass ,
)
def __call__ ( self , request ) :
if any ( p . match ( request . path ) for p in self . _bypass_paths ) :
return self . get_response ( request )
if request . path . startswith ( ' /api/ ' ) :
exceeded = self . _check_api_quota ( request )
if exceeded :
ip = get_client_ip ( request )
logger . warning (
' quota_exceeded window= %s ip= %s path= %s namespace= %s ' ,
exceeded , ip , request . path , _NAMESPACE ,
extra = { ' ua ' : request . META . get ( ' HTTP_USER_AGENT ' , ' ' ) } ,
)
self . _inc_block_metric ( f ' quota_ { exceeded } ' )
response = HttpResponse ( status = 429 )
response [ ' Retry-After ' ] = 86400
response [ ' X-RateLimit-Reason ' ] = f ' quota_ { exceeded } '
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 )
return self . _handle_api ( request )
decision = self . _evaluate ( request )
if decision [ ' action ' ] == ' block ' :
@ -337,6 +372,99 @@ class RateLimitMiddleware:
self . _handle_not_found ( request , decision [ ' ip ' ] )
return response
# ------------------------------------------------------------------
# /api/ handling
# ------------------------------------------------------------------
def _api_block_response ( self , reason , retry_after = None ) :
from django . http import JsonResponse
if retry_after is None :
retry_after = self . api_block_seconds
resp = JsonResponse (
{ ' detail ' : ' API rate limit exceeded. Please reduce polling frequency. ' ,
' retry_after_seconds ' : retry_after } ,
status = 429 ,
)
resp [ ' Retry-After ' ] = retry_after
resp [ ' X-RateLimit-Reason ' ] = reason
return resp
def _handle_api ( self , request ) :
# 1. OPTIONS preflight — CORS must never be blocked
if request . method == ' OPTIONS ' :
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 )
# 3. Global IP block also covers /api/
if self . _rl_cache . get ( RL_IP_BLOCKED . format ( ip = ip ) ) :
logger . warning (
' api_rate_limit_block reason=global_ip_blocked ip= %s path= %s user_agent= %s ' ,
ip , request . path , request . META . get ( ' HTTP_USER_AGENT ' , ' ' ) ,
)
self . _inc_block_metric ( ' api_global_ip_blocked ' )
return self . _api_block_response ( ' global_ip_blocked ' )
# 4. API-specific block (blocks /api/ only, never set by non-/api/ paths)
if self . _rl_cache . get ( RL_API_IP_BLOCKED . format ( ip = ip ) ) :
logger . warning (
' api_rate_limit_block reason=api_ip_blocked ip= %s path= %s user_agent= %s ' ,
ip , request . path , request . META . get ( ' HTTP_USER_AGENT ' , ' ' ) ,
)
self . _inc_block_metric ( ' api_ip_blocked ' )
return self . _api_block_response ( ' api_ip_blocked ' )
# 5. Daily/weekly quota (existing logic, preserved)
exceeded = self . _check_api_quota ( request )
if exceeded :
logger . warning (
' quota_exceeded window= %s ip= %s path= %s namespace= %s ' ,
exceeded , ip , request . path , _NAMESPACE ,
extra = { ' ua ' : request . META . get ( ' HTTP_USER_AGENT ' , ' ' ) } ,
)
self . _inc_block_metric ( f ' quota_ { exceeded } ' )
response = HttpResponse ( status = 429 )
response [ ' Retry-After ' ] = 86400
response [ ' X-RateLimit-Reason ' ] = f ' quota_ { exceeded } '
return response
user = getattr ( request , ' user ' , None )
if not ( user and user . is_authenticated ) :
# 6. API-specific per-minute rate limit for anonymous external callers.
# Writes rl:api:ip:<ip>:blocked only — never rl:ip:<ip>:blocked.
if self . api_rate_limit_enabled :
count = self . _incr_with_ttl ( RL_API_IP_REQUESTS . format ( ip = ip ) , self . api_window )
if count > = self . api_threshold :
_set_block ( RL_API_IP_BLOCKED . format ( ip = ip ) , RL_INDEX_API_BLOCKED_IPS , self . api_block_seconds )
logger . warning (
' api_rate_limit_block reason=api_threshold_exceeded '
' ip= %s path= %s user_agent= %s count= %s threshold= %s ' ,
ip , request . path , request . META . get ( ' HTTP_USER_AGENT ' , ' ' ) ,
count , self . api_threshold ,
)
self . _inc_block_metric ( ' 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 )
# ------------------------------------------------------------------
# Evaluation
# ------------------------------------------------------------------