Browse Source

Fix: same-origin bypass let blocked IPs through on /api/

_handle_api checked _is_same_origin() before any of the IP-prefix, global
(rl:ip:<ip>:blocked), and API-specific (rl:api:ip:<ip>:blocked) block keys —
short-circuiting straight to get_response() on a match. Since Origin/Referer
are entirely client-controlled and trivially spoofable, any caller could
defeat every /api/ block and counter (including an operator-set global block)
by simply sending Origin: https://<sapl-host>.

Reorder the checks so IP-prefix/global/API block lookups always run first;
the same-origin bypass now only exempts legitimate same-origin polling from
quota and per-minute rate-limit accounting, never from an active block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rate-limiter-2026
Edward Ribeiro 1 week ago
parent
commit
5f71354f52
  1. 36
      sapl/middleware/ratelimit.py
  2. 70
      sapl/middleware/test_ratelimiter.py

36
sapl/middleware/ratelimit.py

@ -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:
0a. OPTIONS? pass (CORS preflight must never be blocked) 0a. OPTIONS? pass (CORS preflight must never be blocked)
0b. Same-origin? pass (SAPL's own browser polling) 0b. rl:ip_prefix:blocked? 429 (prefix match; see -1 above)
0c. rl:ip:<ip>:blocked? 429 (global block also covers /api/) 0c. rl:ip:<ip>:blocked? 429 (global block also covers /api/)
0d. rl:api:ip:<ip>:blocked? 429 (API-only block) 0d. rl:api:ip:<ip>:blocked? 429 (API-only block)
0e. Daily/weekly quota exceeded? 429 Block checks 0b-0d always run before the same-origin check below
0f. 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.
0e. Same-origin? pass, skipping 0f-0g only (SAPL's own polling
is exempt from quota/rate-limit accounting, never from active blocks)
0f. Daily/weekly quota exceeded? 429
0g. Anon + API threshold exceeded? SET rl:api:ip:<ip>:blocked, 429
(never writes rl:ip:<ip>:blocked) (never writes rl:ip:<ip>:blocked)
0g. Auth: falls through to _evaluate (per-user counter) 0h. 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)
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)
@ -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:

70
sapl/middleware/test_ratelimiter.py

@ -647,6 +647,76 @@ def test_api_malicious_origin_is_not_same_origin():
mw._incr_with_ttl.assert_called_once() mw._incr_with_ttl.assert_called_once()
# Same-origin headers a spoofing client can trivially forge — Origin/Referer
# carry no authentication, so they must never override an active block.
_SAME_ORIGIN_META = {
'SERVER_NAME': 'sapl.example.com',
'SERVER_PORT': '80',
'HTTP_HOST': 'sapl.example.com',
'HTTP_ORIGIN': 'https://sapl.example.com',
}
def test_api_same_origin_does_not_bypass_global_ip_block():
mw, mock_cache = _make_middleware()
ip = '1.2.3.4'
mock_cache.get.side_effect = lambda key: 1 if key == RL_IP_BLOCKED.format(ip=ip) else None
mw._check_api_quota = MagicMock(return_value=None)
mw._incr_with_ttl = MagicMock()
request = _api_req(ip=ip, extra_meta=_SAME_ORIGIN_META)
response = mw(request)
mw.get_response.assert_not_called()
assert response.status_code == 429
assert response['X-RateLimit-Reason'] == 'global_ip_blocked'
def test_api_same_origin_does_not_bypass_api_ip_block():
mw, mock_cache = _make_middleware()
ip = '1.2.3.4'
blocked_key = RL_API_IP_BLOCKED.format(ns=_NAMESPACE, ip=ip)
mock_cache.get.side_effect = lambda key: 1 if key == blocked_key else None
mw._check_api_quota = MagicMock(return_value=None)
mw._incr_with_ttl = MagicMock()
request = _api_req(ip=ip, extra_meta=_SAME_ORIGIN_META)
response = mw(request)
mw.get_response.assert_not_called()
assert response.status_code == 429
assert response['X-RateLimit-Reason'] == 'api_ip_blocked'
def test_api_same_origin_does_not_bypass_ip_prefix_block(_seed_prefix_blocklist):
mw, _ = _make_middleware()
_seed_prefix_blocklist(['1.2.3'])
mw._check_api_quota = MagicMock(return_value=None)
mw._incr_with_ttl = MagicMock()
request = _api_req(ip='1.2.3.4', extra_meta=_SAME_ORIGIN_META)
response = mw(request)
mw.get_response.assert_not_called()
assert response.status_code == 429
assert response['X-RateLimit-Reason'] == 'ip_prefix_blocked'
def test_api_same_origin_still_skips_quota_and_rate_limit_when_not_blocked():
"""Legitimate same-origin polling keeps its original exemption from accounting."""
mw, _ = _make_middleware()
mw._check_api_quota = MagicMock(return_value=None)
mw._incr_with_ttl = MagicMock()
request = _api_req(extra_meta=_SAME_ORIGIN_META)
response = mw(request)
mw.get_response.assert_called_once_with(request)
mw._check_api_quota.assert_not_called()
mw._incr_with_ttl.assert_not_called()
assert response.status_code == 200
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _handle_api — Check 3a: IP-prefix block (operator-curated deny list) # _handle_api — Check 3a: IP-prefix block (operator-curated deny list)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

Loading…
Cancel
Save