Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ REQUIRE_LOGIN=True
USERNAME=admin
PASSWORD=<generate-strong-password>
SECRET_KEY=<generate-with-python3-secrets-token-hex-32>
# 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.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 15 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,14 +27,16 @@ 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:
- "8000:8000"
networks:
- openrtmp
depends_on:
redis:
condition: service_healthy
openrtmp-server:
condition: service_healthy
healthcheck:
Expand Down
70 changes: 70 additions & 0 deletions tests/test_login_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert sum(accepted_per_worker) == 15


def test_login_rejects_bad_password():
with patch("app.Lrtmp2Client"):
import app as app_module
Expand Down