diff --git a/.env.example b/.env.example index b29f475..3f1cff2 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,10 @@ REQUIRE_LOGIN=True USERNAME=admin PASSWORD= SECRET_KEY= +# Shared rate-limit store for multi-worker Gunicorn (docker-compose defaults to redis://redis:6379/0). +# Leave unset so docker-compose selects Redis. +# For single-process local development (python3 app.py), set this explicitly: +# RATELIMIT_STORAGE_URI=memory:// # Public URL the panel itself is reached at (e.g. https://panel.example.com). # Used only to auto-detect SESSION_COOKIE_SECURE below when it is unset — # leave blank if the panel is plain HTTP or you set SESSION_COOKIE_SECURE explicitly. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cca629..ee8f3c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ only begin at a future `1.0.0`. ## [Unreleased] +### Security +- Default Docker Compose stack now includes Redis and uses + `RATELIMIT_STORAGE_URI=redis://redis:6379/0` so the `/login` rate limit is + shared across Gunicorn workers instead of allowing `5 × worker_count` + attempts per minute with in-memory storage + ## [0.1.1] — 2026-07-10 ### Security diff --git a/README.md b/README.md index f219d8b..8beb68c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Copy `.env.example` to `.env` and adjust: | `REQUIRE_LOGIN` | Enable panel login (`True`/`False`, default `True`) | No | | `USERNAME` / `PASSWORD` | Panel admin credentials | If login enabled | | `SECRET_KEY` | Flask session secret | Yes | +| `RATELIMIT_STORAGE_URI` | Shared rate-limit store for multi-worker Gunicorn (`redis://…`; docker-compose defaults to Redis) | No | ## Quick Start (Docker Compose) diff --git a/docker-compose.yml b/docker-compose.yml index b749b7a..b5a0bc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,16 @@ services: + redis: + image: redis:7-alpine + container_name: librtmp2-panel-redis + restart: unless-stopped + networks: + - openrtmp + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + openrtmp-panel: build: . image: ghcr.io/OpenRTMP/librtmp2-server-panel:latest @@ -15,7 +27,7 @@ services: - USERNAME=${USERNAME:-admin} - PASSWORD=${PASSWORD:?Set PASSWORD in .env} - SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY in .env} - - RATELIMIT_STORAGE_URI=${RATELIMIT_STORAGE_URI:-memory://} + - RATELIMIT_STORAGE_URI=${RATELIMIT_STORAGE_URI:-redis://redis:6379/0} - PANEL_PUBLIC_URL=${PANEL_PUBLIC_URL:-} - SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-} ports: @@ -23,6 +35,8 @@ services: networks: - openrtmp depends_on: + redis: + condition: service_healthy openrtmp-server: condition: service_healthy healthcheck: diff --git a/tests/test_login_routes.py b/tests/test_login_routes.py index bc191bf..d28309c 100644 --- a/tests/test_login_routes.py +++ b/tests/test_login_routes.py @@ -53,6 +53,76 @@ def test_cache_control_no_store_on_json_responses(app_client): assert r.headers.get("Cache-Control") == "no-store" +def _attempt_login_rate_limit_in_subprocess(queue): + """Run login attempts in a child process for memory:// rate-limit isolation tests.""" + with patch("app.Lrtmp2Client"), patch("app.Config.RATELIMIT_STORAGE_URI", "memory://"): + import app as app_module + + application = app_module.create_app() + application.config["TESTING"] = True + application.config["WTF_CSRF_ENABLED"] = False # NOSONAR - test client posts without CSRF tokens + client = application.test_client() + accepted = 0 + for i in range(8): + r = client.post( + "/login", + data={"username": "admin", "password": f"wrong-password-{i}"}, + ) + if r.status_code == 200 and b"Invalid credentials" in r.data: + accepted += 1 + queue.put(accepted) + + +def test_login_rate_limit_blocks_after_five_attempts(): + with patch("app.Lrtmp2Client"), patch("app.Config.RATELIMIT_STORAGE_URI", "memory://"): + import app as app_module + + application = app_module.create_app() + application.config["TESTING"] = True + application.config["WTF_CSRF_ENABLED"] = False # NOSONAR - test client posts without CSRF tokens + client = application.test_client() + for i in range(5): + r = client.post( + "/login", + data={"username": "admin", "password": f"wrong-password-{i}"}, + ) + assert r.status_code == 200 + assert b"Invalid credentials" in r.data + + r = client.post( + "/login", + data={"username": "admin", "password": "wrong-password-final"}, + ) + assert r.status_code == 429 + + +def test_login_rate_limit_is_per_process_with_memory_storage(): + """Documents why docker-compose defaults to a shared redis:// limiter backend.""" + from multiprocessing import Process, Queue + + queue = Queue() + workers = [ + Process(target=_attempt_login_rate_limit_in_subprocess, args=(queue,)) + for _ in range(3) + ] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=30) + + alive = [worker for worker in workers if worker.is_alive()] + for worker in alive: + worker.terminate() + worker.join() + + assert not alive, "Login rate-limit workers timed out" + assert all(worker.exitcode == 0 for worker in workers) + + accepted_per_worker = [queue.get(timeout=5) for _ in workers] + assert accepted_per_worker == [5, 5, 5] + assert sum(accepted_per_worker) == 15 + + def test_login_rejects_bad_password(): with patch("app.Lrtmp2Client"): import app as app_module