Browse Source

Scope API rate limit keys to tenant namespace

RL_API_IP_REQUESTS and RL_API_IP_BLOCKED now include {ns} so a block
in one k8s pod namespace does not leak into other tenants sharing the
same Redis instance. RL_INDEX_API_BLOCKED_IPS remains namespace-free
(operational index; members carry the namespace in their key string).

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

10
sapl/middleware/ratelimit.py

@ -76,8 +76,8 @@ RL_INDEX_BLOCKED_IPS = 'rl:index:blocked_ips'
RL_INDEX_BLOCKED_USERS = 'rl:index:blocked_users' RL_INDEX_BLOCKED_USERS = 'rl:index:blocked_users'
# API-specific rate limit keys — scope limited to /api/, never written by non-/api/ paths. # 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_REQUESTS = 'rl:api:ns:{ns}:ip:{ip}:reqs'
RL_API_IP_BLOCKED = 'rl:api:ip:{ip}:blocked' RL_API_IP_BLOCKED = 'rl:api:ns:{ns}:ip:{ip}:blocked'
RL_INDEX_API_BLOCKED_IPS = 'rl:index:api_blocked_ips' RL_INDEX_API_BLOCKED_IPS = 'rl:index:api_blocked_ips'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -404,7 +404,7 @@ class RateLimitMiddleware:
return self._api_block_response('global_ip_blocked') return self._api_block_response('global_ip_blocked')
# 4. API-specific block (blocks /api/ only, never set by non-/api/ paths) # 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)): if self._rl_cache.get(RL_API_IP_BLOCKED.format(ns=_NAMESPACE, ip=ip)):
logger.warning( logger.warning(
'api_rate_limit_block reason=api_ip_blocked ip=%s path=%s user_agent=%s', 'api_rate_limit_block reason=api_ip_blocked ip=%s path=%s user_agent=%s',
ip, request.path, request.META.get('HTTP_USER_AGENT', ''), ip, request.path, request.META.get('HTTP_USER_AGENT', ''),
@ -430,9 +430,9 @@ class RateLimitMiddleware:
# 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:
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(ns=_NAMESPACE, 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(ns=_NAMESPACE, 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',

6
sapl/middleware/test_ratelimiter.py

@ -527,7 +527,7 @@ def test_api_external_request_increments_api_counter():
response = mw(request) response = mw(request)
mw.get_response.assert_called_once_with(request) mw.get_response.assert_called_once_with(request)
call_args = mw._incr_with_ttl.call_args[0] call_args = mw._incr_with_ttl.call_args[0]
assert call_args[0] == RL_API_IP_REQUESTS.format(ip='1.2.3.4') assert call_args[0] == RL_API_IP_REQUESTS.format(ns=_NAMESPACE, ip='1.2.3.4')
def test_api_threshold_exceeded_creates_api_block_not_global_block(): def test_api_threshold_exceeded_creates_api_block_not_global_block():
@ -540,7 +540,7 @@ def test_api_threshold_exceeded_creates_api_block_not_global_block():
assert response.status_code == 429 assert response.status_code == 429
mock_set_block.assert_called_once() mock_set_block.assert_called_once()
block_key = mock_set_block.call_args[0][0] block_key = mock_set_block.call_args[0][0]
assert block_key == RL_API_IP_BLOCKED.format(ip='1.2.3.4') assert block_key == RL_API_IP_BLOCKED.format(ns=_NAMESPACE, ip='1.2.3.4')
assert block_key != RL_IP_BLOCKED.format(ip='1.2.3.4') assert block_key != RL_IP_BLOCKED.format(ip='1.2.3.4')
@ -561,7 +561,7 @@ def test_api_specific_block_blocks_api_only():
mw._check_api_quota = MagicMock(return_value=None) mw._check_api_quota = MagicMock(return_value=None)
mw._incr_with_ttl = MagicMock() mw._incr_with_ttl = MagicMock()
ip = '1.2.3.4' ip = '1.2.3.4'
mock_cache.get.side_effect = lambda key: 1 if key == RL_API_IP_BLOCKED.format(ip=ip) else None mock_cache.get.side_effect = lambda key: 1 if key == RL_API_IP_BLOCKED.format(ns=_NAMESPACE, ip=ip) else None
response = mw(_api_req(ip=ip)) response = mw(_api_req(ip=ip))
assert response.status_code == 429 assert response.status_code == 429
assert response['X-RateLimit-Reason'] == 'api_ip_blocked' assert response['X-RateLimit-Reason'] == 'api_ip_blocked'

Loading…
Cancel
Save