Skip to content

Commit acd16fa

Browse files
authored
Merge pull request #2043 from harshmathurx/fix/2040-redis-auth-hello
fix(docker): authenticate rate limit Redis storage
2 parents 9b5a090 + 979e8e6 commit acd16fa

3 files changed

Lines changed: 128 additions & 12 deletions

File tree

‎deploy/docker/redis_config.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Redis URL helpers for the Docker API server.
2+
3+
The Docker entrypoint protects the in-container Redis with a password. These
4+
helpers keep every Redis client (the app and optional rate-limit storage) using
5+
the same authenticated URL shape.
6+
"""
7+
8+
import os
9+
from urllib.parse import quote, urlsplit, urlunsplit
10+
11+
12+
def redis_auth_from_config(config: dict) -> tuple[str, str]:
13+
"""Return Redis ACL username/password from config and environment."""
14+
rc = config.get("redis", {})
15+
username = os.environ.get("REDIS_USERNAME", rc.get("username", "default")) or "default"
16+
password = os.environ.get("REDIS_PASSWORD", rc.get("password", "")) or ""
17+
return str(username), str(password)
18+
19+
20+
def redis_auth_netloc(username: str, password: str) -> str:
21+
"""Build a URL-safe Redis AUTH fragment for redis-py/limits clients.
22+
23+
Redis 6+ HELLO AUTH requires both username and password when negotiating
24+
RESP3. Supplying the explicit default ACL username avoids clients issuing an
25+
unauthenticated HELLO against a password-protected Redis instance.
26+
"""
27+
if not password:
28+
return ""
29+
return f"{quote(username, safe='')}:{quote(password, safe='')}@"
30+
31+
32+
def build_redis_url(config: dict) -> str:
33+
"""Build Redis URL from config fields and environment variables."""
34+
rc = config.get("redis", {})
35+
host = os.environ.get("REDIS_HOST", rc.get("host", "localhost"))
36+
port = os.environ.get("REDIS_PORT", rc.get("port", 6379))
37+
username, password = redis_auth_from_config(config)
38+
db = rc.get("db", 0)
39+
scheme = "rediss" if rc.get("ssl", False) else "redis"
40+
auth = redis_auth_netloc(username, password)
41+
return f"{scheme}://{auth}{host}:{port}/{db}"
42+
43+
44+
def build_rate_limit_storage_uri(config: dict) -> str:
45+
"""Return a rate-limit storage URI that can auth to protected Redis.
46+
47+
Older/self-managed Docker configs may set rate_limiting.storage_uri to an
48+
unauthenticated Redis URL such as redis://localhost:6379. Since the Docker
49+
entrypoint now always protects the in-container Redis with REDIS_PASSWORD,
50+
SlowAPI/limits would fail before route handlers with Redis HELLO auth errors.
51+
If the storage URI is Redis-like and has no credentials, reuse the configured
52+
Redis ACL credentials. Explicit credentials and non-Redis backends are left
53+
unchanged.
54+
"""
55+
storage_uri = config.get("rate_limiting", {}).get("storage_uri", "memory://")
56+
if not isinstance(storage_uri, str):
57+
return "memory://"
58+
parsed = urlsplit(storage_uri)
59+
if parsed.scheme not in {"redis", "rediss", "redis+sentinel"}:
60+
return storage_uri
61+
if parsed.username or parsed.password:
62+
return storage_uri
63+
64+
username, password = redis_auth_from_config(config)
65+
if not password:
66+
return storage_uri
67+
68+
auth = redis_auth_netloc(username, password)
69+
return urlunsplit((
70+
parsed.scheme,
71+
f"{auth}{parsed.netloc}",
72+
parsed.path,
73+
parsed.query,
74+
parsed.fragment,
75+
))

‎deploy/docker/server.py‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@
7272
from slowapi.util import get_remote_address
7373
from prometheus_fastapi_instrumentator import Instrumentator
7474
from redis import asyncio as aioredis
75+
from redis_config import (
76+
build_rate_limit_storage_uri as _build_rate_limit_storage_uri,
77+
build_redis_url as _build_redis_url,
78+
)
7579

7680
# ── internal imports (after sys.path append) ─────────────────
7781
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
@@ -289,23 +293,12 @@ async def root():
289293
return RedirectResponse("/playground")
290294

291295
# ─────────────────── infra / middleware ─────────────────────
292-
def _build_redis_url(config: dict) -> str:
293-
"""Build Redis URL from config fields and environment variables."""
294-
rc = config.get("redis", {})
295-
host = os.environ.get("REDIS_HOST", rc.get("host", "localhost"))
296-
port = os.environ.get("REDIS_PORT", rc.get("port", 6379))
297-
password = os.environ.get("REDIS_PASSWORD", rc.get("password", ""))
298-
db = rc.get("db", 0)
299-
scheme = "rediss" if rc.get("ssl", False) else "redis"
300-
auth = f":{password}@" if password else ""
301-
return f"{scheme}://{auth}{host}:{port}/{db}"
302-
303296
redis = aioredis.from_url(_build_redis_url(config))
304297

305298
limiter = Limiter(
306299
key_func=get_remote_address,
307300
default_limits=[config["rate_limiting"]["default_limit"]],
308-
storage_uri=config["rate_limiting"]["storage_uri"],
301+
storage_uri=_build_rate_limit_storage_uri(config),
309302
)
310303

311304

‎deploy/docker/tests/test_security_default_posture.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
import pytest
2727

28+
from redis_config import build_rate_limit_storage_uri, build_redis_url
29+
2830
pytestmark = pytest.mark.posture
2931

3032
HEALTH = "/health"
@@ -175,6 +177,52 @@ def test_redis_is_not_network_exposed(self, effective_redis_url, effective_confi
175177
"redis is neither loopback-bound nor password-protected"
176178
)
177179

180+
def test_redis_url_includes_acl_username_and_encoded_password(self, monkeypatch):
181+
"""Redis URLs must authenticate during HELLO when Redis is password-protected."""
182+
monkeypatch.setenv("REDIS_PASSWORD", "p@ss/w:rd")
183+
cfg = {
184+
"redis": {
185+
"host": "localhost",
186+
"port": 6379,
187+
"db": 0,
188+
}
189+
}
190+
url = build_redis_url(cfg)
191+
assert url.startswith("redis://default:")
192+
assert "p%40ss%2Fw%3Ard" in url
193+
assert url.endswith("@localhost:6379/0")
194+
195+
def test_rate_limit_redis_storage_reuses_redis_password(self, monkeypatch):
196+
"""SlowAPI must not connect to protected Redis without credentials."""
197+
monkeypatch.setenv("REDIS_PASSWORD", "abc123")
198+
cfg = {
199+
"redis": {"host": "localhost", "port": 6379, "db": 0},
200+
"rate_limiting": {"storage_uri": "redis://localhost:6379/0"},
201+
}
202+
url = build_rate_limit_storage_uri(cfg)
203+
assert url.startswith("redis://default:")
204+
assert "abc123" in url
205+
assert url.endswith("@localhost:6379/0")
206+
207+
def test_rate_limit_storage_preserves_explicit_auth(self, monkeypatch):
208+
"""Do not rewrite operator-provided rate-limit Redis credentials."""
209+
monkeypatch.setenv("REDIS_PASSWORD", "container-secret")
210+
storage_uri = "redis://" + "alice" + ":" + "custom" + "@redis:6379/2"
211+
cfg = {
212+
"redis": {"host": "localhost", "port": 6379, "db": 0},
213+
"rate_limiting": {"storage_uri": storage_uri},
214+
}
215+
assert build_rate_limit_storage_uri(cfg) == storage_uri
216+
217+
def test_rate_limit_storage_keeps_memory_backend(self, monkeypatch):
218+
"""Non-Redis rate-limit backends must not be rewritten."""
219+
monkeypatch.setenv("REDIS_PASSWORD", "abc123")
220+
cfg = {
221+
"redis": {"host": "localhost", "port": 6379, "db": 0},
222+
"rate_limiting": {"storage_uri": "memory://"},
223+
}
224+
assert build_rate_limit_storage_uri(cfg) == "memory://"
225+
178226
def test_trusted_hosts_not_wildcard_when_exposed(self, effective_config):
179227
"""A wildcard trusted_hosts on a non-loopback bind silently disables the host guard."""
180228
host = effective_config["app"]["host"]

0 commit comments

Comments
 (0)