From 6d62dfec7e295bffd1e8713124199e87d4f0dde6 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Mon, 11 May 2026 23:25:39 -0300 Subject: [PATCH] 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 --- sapl/middleware/ratelimit.py | 10 +++++----- sapl/middleware/test_ratelimiter.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/sapl/middleware/ratelimit.py b/sapl/middleware/ratelimit.py index 278364687..c4d59d28a 100644 --- a/sapl/middleware/ratelimit.py +++ b/sapl/middleware/ratelimit.py @@ -76,8 +76,8 @@ 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_API_IP_REQUESTS = 'rl:api:ns:{ns}:ip:{ip}:reqs' +RL_API_IP_BLOCKED = 'rl:api:ns:{ns}:ip:{ip}:blocked' RL_INDEX_API_BLOCKED_IPS = 'rl:index:api_blocked_ips' # --------------------------------------------------------------------------- @@ -404,7 +404,7 @@ class RateLimitMiddleware: 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)): + if self._rl_cache.get(RL_API_IP_BLOCKED.format(ns=_NAMESPACE, 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', ''), @@ -430,9 +430,9 @@ class RateLimitMiddleware: # Auth is not exempt: authenticating must not bypass this cap. # Writes rl:api:ip::blocked only — never rl:ip::blocked. 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: - _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( 'api_rate_limit_block reason=api_threshold_exceeded ' 'ip=%s path=%s user_agent=%s count=%s threshold=%s', diff --git a/sapl/middleware/test_ratelimiter.py b/sapl/middleware/test_ratelimiter.py index cc69a66e0..c4b049b64 100644 --- a/sapl/middleware/test_ratelimiter.py +++ b/sapl/middleware/test_ratelimiter.py @@ -527,7 +527,7 @@ def test_api_external_request_increments_api_counter(): response = mw(request) mw.get_response.assert_called_once_with(request) 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(): @@ -540,7 +540,7 @@ def test_api_threshold_exceeded_creates_api_block_not_global_block(): assert response.status_code == 429 mock_set_block.assert_called_once() 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') @@ -561,7 +561,7 @@ def test_api_specific_block_blocks_api_only(): mw._check_api_quota = MagicMock(return_value=None) mw._incr_with_ttl = MagicMock() 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)) assert response.status_code == 429 assert response['X-RateLimit-Reason'] == 'api_ip_blocked'