Skip to content

Commit 9f27161

Browse files
fix(security): reject multi-worker deployments with unsupported Redis session URIs
Flask-Limiter accepts redis+cluster:// and redis+unix:// storage URIs, but create_session_store() only recognized redis:// and rediss:// and silently fell back to per-worker MemorySessionStore. On multi-worker Gunicorn deployments this broke logout and session rotation: stolen cookies stayed valid on workers that did not handle the re-login or logout request. Fail startup when multiple workers are configured with URIs that cannot back shared sessions, and add redis+unix:// support via the same normalization limits uses for its Redis storage backend. Co-authored-by: Alexander Wagner <info@alexanderwagnerdev.com>
1 parent a7429f5 commit 9f27161

4 files changed

Lines changed: 75 additions & 9 deletions

File tree

config.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import sys
55
from datetime import timedelta
66

7+
from session_store import shared_session_store_supported
8+
79
_INSECURE_DEFAULTS = frozenset(
810
{
911
"change-me-to-a-random-value",
@@ -277,13 +279,23 @@ def _validate_config():
277279
or RATELIMIT_MEMORY_URI
278280
)
279281
worker_count = _detect_worker_count()
280-
if worker_count > 1 and ratelimit_uri == RATELIMIT_MEMORY_URI:
281-
_emit_config_error(
282-
f"RATELIMIT_STORAGE_URI={RATELIMIT_MEMORY_URI} is per worker process and bypasses "
283-
"login rate limits with multiple Gunicorn workers. Set a shared backend "
284-
"(e.g. redis://redis:6379/0) or run with a single worker."
285-
)
286-
had_error = True
282+
if worker_count > 1:
283+
if not shared_session_store_supported(ratelimit_uri):
284+
if ratelimit_uri == RATELIMIT_MEMORY_URI:
285+
_emit_config_error(
286+
f"RATELIMIT_STORAGE_URI={RATELIMIT_MEMORY_URI} is per worker process and "
287+
"bypasses login rate limits with multiple Gunicorn workers. Set a shared "
288+
"backend (e.g. redis://redis:6379/0) or run with a single worker."
289+
)
290+
else:
291+
_emit_config_error(
292+
"RATELIMIT_STORAGE_URI cannot back shared panel sessions across "
293+
"multiple Gunicorn workers. Use redis://, rediss://, or redis+unix://. "
294+
"Schemes such as redis+cluster:// and redis+sentinel:// are supported "
295+
"by the rate limiter only; the panel would otherwise fall back to "
296+
"per-worker in-memory sessions, breaking logout and session rotation."
297+
)
298+
had_error = True
287299

288300
if _is_insecure_secret(os.environ.get("LRTMP2_API_TOKEN")):
289301
_emit_config_error(

session_store.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@
66
logger = logging.getLogger(__name__)
77

88
_REDIS_SCHEMES = frozenset({"redis", "rediss"})
9+
_REDIS_UNIX_SCHEMES = frozenset({"redis+unix", "valkey+unix"})
10+
_SHARED_SESSION_SCHEMES = _REDIS_SCHEMES | _REDIS_UNIX_SCHEMES
11+
12+
13+
def shared_session_store_supported(storage_uri):
14+
"""Return True when the URI can back sessions across Gunicorn workers."""
15+
return urlparse(storage_uri).scheme in _SHARED_SESSION_SCHEMES
16+
17+
18+
def _normalize_redis_url(storage_uri):
19+
"""Match limits' redis+unix handling so redis.from_url accepts the URI."""
20+
scheme = urlparse(storage_uri).scheme
21+
if scheme in _REDIS_UNIX_SCHEMES:
22+
prefix = scheme.split("+", 1)[0]
23+
return storage_uri.replace(f"{prefix}+unix", "unix", 1)
24+
return storage_uri
925
_REVOKE_SESSION_SCRIPT = """
1026
local active = redis.call("GET", KEYS[1])
1127
if active == ARGV[1] then
@@ -67,7 +83,7 @@ def __init__(self, storage_uri):
6783

6884
self._redis_error = redis.exceptions.RedisError
6985
self._client = redis.from_url(
70-
storage_uri,
86+
_normalize_redis_url(storage_uri),
7187
socket_timeout=2,
7288
socket_connect_timeout=2,
7389
)
@@ -141,6 +157,6 @@ def revoke(self, username, token):
141157

142158
def create_session_store(storage_uri):
143159
scheme = urlparse(storage_uri).scheme
144-
if scheme in _REDIS_SCHEMES:
160+
if scheme in _SHARED_SESSION_SCHEMES:
145161
return RedisSessionStore(storage_uri)
146162
return MemorySessionStore()

tests/test_app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,23 @@ def test_config_rejects_memory_ratelimit_with_gunicorn_argv_workers(monkeypatch)
345345
_forget_config_module()
346346

347347

348+
def test_config_rejects_redis_cluster_ratelimit_with_multiple_workers(monkeypatch):
349+
monkeypatch.setenv("SECRET_KEY", "valid-test-secret-key-for-redis-cluster-check")
350+
monkeypatch.setenv("PASSWORD", "valid-test-password-for-redis-cluster-check")
351+
monkeypatch.setenv("LRTMP2_API_TOKEN", "valid-test-api-token-for-redis-cluster-check")
352+
monkeypatch.setenv("REQUIRE_LOGIN", "true")
353+
monkeypatch.setenv("RATELIMIT_STORAGE_URI", "redis+cluster://redis:6379/0")
354+
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--bind=0.0.0.0:8000 --workers=3")
355+
356+
_forget_config_module()
357+
try:
358+
with pytest.raises(SystemExit) as exc:
359+
importlib.import_module("config")
360+
assert exc.value.code == 1
361+
finally:
362+
_forget_config_module()
363+
364+
348365
def test_config_accepts_long_password_when_login_enabled(monkeypatch):
349366
monkeypatch.setenv("SECRET_KEY", "valid-test-secret-key-for-placeholder-check")
350367
monkeypatch.setenv("PASSWORD", "valid-test-password-for-placeholder-check")

tests/test_session_store.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
RedisSessionStore,
1010
SessionBackendUnavailable,
1111
create_session_store,
12+
shared_session_store_supported,
1213
)
1314

1415

@@ -169,3 +170,23 @@ def test_create_session_store_selects_backend_from_uri_scheme():
169170
with patch("session_store.RedisSessionStore") as redis_store_cls:
170171
assert create_session_store("redis://redis:6379/0") is redis_store_cls.return_value
171172
assert create_session_store("rediss://redis:6379/0") is redis_store_cls.return_value
173+
assert create_session_store("redis+unix:///var/run/redis.sock") is redis_store_cls.return_value
174+
175+
176+
def test_shared_session_store_supported_rejects_cluster_and_sentinel_uris():
177+
assert shared_session_store_supported("redis://redis:6379/0") is True
178+
assert shared_session_store_supported("rediss://redis:6379/0") is True
179+
assert shared_session_store_supported("redis+unix:///var/run/redis.sock") is True
180+
assert shared_session_store_supported("redis+cluster://redis:6379/0") is False
181+
assert shared_session_store_supported("redis+sentinel://redis:6379/0") is False
182+
assert shared_session_store_supported("memory://") is False
183+
184+
185+
def test_redis_store_normalizes_unix_socket_uri():
186+
_store, client, redis_module = _make_redis_store("redis+unix:///var/run/redis.sock")
187+
188+
redis_module.from_url.assert_called_once_with(
189+
"unix:///var/run/redis.sock",
190+
socket_timeout=2,
191+
socket_connect_timeout=2,
192+
)

0 commit comments

Comments
 (0)