Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ only begin at a future `1.0.0`.

## [Unreleased]

### Security
- `SESSION_COOKIE_SECURE` defaults to enabled when neither `PANEL_PUBLIC_URL`
nor `TRUSTED_PROXY_COUNT` indicates plain HTTP. Set
`PANEL_ALLOW_INSECURE_HTTP=1` for a local HTTP setup; the bundled compose
files do.
- The rate limiter keys on the proxy-appended value (the last
`X-Forwarded-For` entry, or the header named by
`TRUSTED_PROXY_CLIENT_IP_HEADER`), not on the position selected by
`TRUSTED_PROXY_COUNT`, so an over-counted hop number can no longer let a
client choose its own bucket.

## [0.1.14] — 2026-11-01

### Security
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --only-binary :all: --require-hashes --prefix=/install -r requirements.txt

FROM python:3.15-alpine
FROM python:3.14-alpine

ARG APP_VERSION=""

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,16 @@ Copy `.env.example` to `.env` for a manual setup and adjust:
| `LOG_LEVEL` | `0`=error `1`=warn `2`=info `3`=debug (same scale as librtmp2-server) or a level name; default `2`. Info logs logins, logouts and stream/player/cluster changes; debug adds every librtmp2-server API call with its status and duration | No |
| `PANEL_PUBLIC_URL` | Public panel URL, used for secure-cookie detection | No |
| `SESSION_COOKIE_SECURE` | Force secure cookies when served over HTTPS | No |
| `PANEL_ALLOW_INSECURE_HTTP` | Allow the session cookie without `Secure` when no HTTPS URL/proxy is configured; only for local HTTP setups | No |
| `TRUSTED_PROXY_COUNT` | Trusted value count used independently for `X-Forwarded-For` and `X-Forwarded-Proto`; default `0` ignores both | No |
| `TRUSTED_PROXY_IPS` | Comma-separated proxy IPs/CIDR ranges trusted for rate-limit client IP selection | When `TRUSTED_PROXY_COUNT > 0` |
| `TRUSTED_PROXY_CLIENT_IP_HEADER` | Header the edge proxy overwrites with the client IP (e.g. `X-Real-IP`); only used for rate-limit keys when set | No |

### Reverse proxy deployments

Keep `TRUSTED_PROXY_COUNT=0` when port `8000` is directly reachable. Enable it only when the panel is reachable **exclusively** through trusted proxies.

`ProxyFix` applies the configured count independently to `X-Forwarded-For` and `X-Forwarded-Proto`. Therefore every trusted proxy in the chain must append or normalize both headers so they contain the same number of trusted values. Whenever `TRUSTED_PROXY_COUNT > 0`, `TRUSTED_PROXY_IPS` must also list the trusted proxies' IPs/CIDR ranges — the panel refuses to start without it. Rate limiting only buckets by the forwarded `X-Forwarded-For` address when the direct TCP peer is one of these trusted proxies; otherwise it falls back to the peer's own address, so a client reaching the panel directly can't spoof `X-Forwarded-For` to bypass per-IP rate limits. A common safe one-proxy setup is:
`ProxyFix` applies the configured count independently to `X-Forwarded-For` and `X-Forwarded-Proto`. Therefore every trusted proxy in the chain must append or normalize both headers so they contain the same number of trusted values. Whenever `TRUSTED_PROXY_COUNT > 0`, `TRUSTED_PROXY_IPS` must also list the trusted proxies' IPs/CIDR ranges - the panel refuses to start without it. Rate limiting only uses a forwarded address when the direct TCP peer is one of these trusted proxies, and then only a proxy-appended value: the header named by `TRUSTED_PROXY_CLIENT_IP_HEADER` when configured, otherwise the last `X-Forwarded-For` entry. The position selected by `TRUSTED_PROXY_COUNT` is never used as a limiter key, because an over-counted hop number would let the client supply it. With several proxies in the chain, set `TRUSTED_PROXY_CLIENT_IP_HEADER` at the edge (and make the edge overwrite it); otherwise every client behind the innermost proxy shares one bucket. Without either header the proxy's own address is used. A common safe one-proxy setup is:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

```env
PANEL_PUBLIC_URL=https://panel.example.com
Expand Down
6 changes: 6 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,11 +412,17 @@ def handle_limiter_storage_error(self, error):
def _rate_limit_remote_addr(self):
"""Rate-limit by real client IP, ignoring spoofed XFF from untrusted peers."""
direct = request.environ.get(DIRECT_REMOTE_ADDR_KEY) or ""
client_ip_header = self.app.config.get("TRUSTED_PROXY_CLIENT_IP_HEADER", "")
proxy_client_ip = (
request.headers.get(client_ip_header, "") if client_ip_header else ""
)
return client_ip_for_rate_limit(
direct_addr=direct,
forwarded_addr=get_remote_address(),
trusted_proxy_count=self.app.config["TRUSTED_PROXY_COUNT"],
trusted_networks=self.app.config["TRUSTED_PROXY_NETWORKS"],
xff_header=request.headers.get("X-Forwarded-For", ""),
proxy_client_ip=proxy_client_ip,
)

def _stats_rate_limit_key(self):
Expand Down
6 changes: 6 additions & 0 deletions compose.quickstart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,14 @@ services:
RATELIMIT_STORAGE_URI: redis://redis:6379/0
PANEL_PUBLIC_URL: ${PANEL_PUBLIC_URL:-}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-}
# The quickstart serves plain HTTP on localhost:8000. For HTTPS,
# set PANEL_PUBLIC_URL=https://... or SESSION_COOKIE_SECURE=true and
# PANEL_ALLOW_INSECURE_HTTP=0. The opt-out alone cannot override an
# HTTP public URL or SESSION_COOKIE_SECURE=false.
PANEL_ALLOW_INSECURE_HTTP: ${PANEL_ALLOW_INSECURE_HTTP:-1}
Comment thread
AlexanderWagnerDev marked this conversation as resolved.
Resolved
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-0}
TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-}
TRUSTED_PROXY_CLIENT_IP_HEADER: ${TRUSTED_PROXY_CLIENT_IP_HEADER:-}
ports:
- "8000:8000"
networks:
Expand Down
153 changes: 150 additions & 3 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,78 @@
ast.UAdd: "__pos__",
ast.Invert: "__invert__",
}
# Instance hooks Python can invoke implicitly on an object the config merely
# constructed: arithmetic/comparison operators, ``f"{obj}"``/``format(obj)``,
# ``for``/``in``/``reversed``, and truth tests. They are not covered by the
# explicit call/attribute analysis, so a class whose hook mutates ``workers``
# must make its construction count as an eager side effect; otherwise
# ``Bump() + 1``, ``f"{Bump()}"``, ``for _ in Bump():`` or ``if Bump():``
# leaves ``workers = 1`` looking static.
_IMPLICIT_INSTANCE_HOOKS = frozenset(
{
"__add__",
"__radd__",
"__sub__",
"__rsub__",
"__mul__",
"__rmul__",
"__matmul__",
"__rmatmul__",
"__truediv__",
"__rtruediv__",
"__floordiv__",
"__rfloordiv__",
"__mod__",
"__rmod__",
"__divmod__",
"__rdivmod__",
"__pow__",
"__rpow__",
"__lshift__",
"__rlshift__",
"__rshift__",
"__rrshift__",
"__and__",
"__rand__",
"__xor__",
"__rxor__",
"__or__",
"__ror__",
"__iadd__",
"__isub__",
"__imul__",
"__imatmul__",
"__itruediv__",
"__ifloordiv__",
"__imod__",
"__ipow__",
"__ilshift__",
"__irshift__",
"__iand__",
"__ixor__",
"__ior__",
"__lt__",
"__le__",
"__gt__",
"__ge__",
"__eq__",
"__ne__",
"__format__",
"__str__",
"__repr__",
"__bytes__",
"__hash__",
"__iter__",
"__reversed__",
"__contains__",
"__bool__",
"__len__",
"__index__",
"__int__",
"__float__",
"__complex__",
}
)

MIN_PASSWORD_LEN = 12
RATELIMIT_MEMORY_URI = "memory://"
Expand Down Expand Up @@ -294,17 +366,47 @@ def ip_in_trusted_proxy_networks(address, networks):
)


def _appended_client_ip(xff_header, proxy_client_ip):
"""Return a client IP the trusted proxy chain appended.

``proxy_client_ip`` is the value of the explicitly configured
``TRUSTED_PROXY_CLIENT_IP_HEADER`` (normally overwritten by the edge); the
last ``X-Forwarded-For`` entry is the one appended by the proxy closest to
us. The spoofable entries to their left are never used, so an over-counted
``TRUSTED_PROXY_COUNT`` cannot select a client-written value.
"""
candidates = []
if proxy_client_ip:
candidates.append(proxy_client_ip)
if xff_header:
candidates.append(xff_header.split(",")[-1])
Comment thread
AlexanderWagnerDev marked this conversation as resolved.
Resolved
for value in candidates:
try:
return str(ipaddress.ip_address(value.strip()))
except ValueError:
continue
return ""


def client_ip_for_rate_limit(
*,
direct_addr,
forwarded_addr,
trusted_proxy_count,
trusted_networks,
xff_header="",
proxy_client_ip="",
):
"""Choose the client IP bucket for rate limiting.

When forwarded headers are trusted only from known proxy IPs, direct clients
cannot pick arbitrary X-Forwarded-For values to bypass per-IP limits.
cannot pick arbitrary X-Forwarded-For values to bypass per-IP limits. A
trusted proxy's forwarded value is only used when it is one the proxy
appended (the explicitly configured client-IP header, or the last XFF
entry); the position selected by ``TRUSTED_PROXY_COUNT`` is not used
because an over-counted hop number would let the client supply it. With
several proxies choose ``TRUSTED_PROXY_CLIENT_IP_HEADER`` at the edge so
the bucket still identifies the client.
"""
if not trusted_proxy_count:
return forwarded_addr
Expand All @@ -315,7 +417,7 @@ def client_ip_for_rate_limit(
# bucket all such requests together so they cannot rotate identities.
return "unknown"
if ip_in_trusted_proxy_networks(direct_addr, trusted_networks):
return forwarded_addr
return _appended_client_ip(xff_header, proxy_client_ip) or direct_addr
return direct_addr


Expand Down Expand Up @@ -346,7 +448,16 @@ def _session_cookie_secure_default():
max_value=10,
name="TRUSTED_PROXY_COUNT",
)
return trusted_proxy_count > 0
if trusted_proxy_count > 0:
return True
# Neither an explicit public URL nor a trusted proxy: fail closed. An
# operator who deliberately serves the panel over plain HTTP (local
# quickstart, a test client) must opt in explicitly; otherwise the admin
# session cookie would travel without Secure by default.
allow_insecure = _parse_optional_bool(
os.environ.get("PANEL_ALLOW_INSECURE_HTTP", "")
)
return allow_insecure is not True


def _parse_positive_int(value, default, *, min_value=1, max_value=10_000, name="value"):
Expand Down Expand Up @@ -12840,6 +12951,14 @@ def _record_class_side_effect_target(
constructors.add(class_name)
methods.add((class_name, stmt.name))
return True
if stmt.name in _IMPLICIT_INSTANCE_HOOKS:
# Python can invoke this hook implicitly on an instance the config
# constructed (operator, f-string, iteration, membership or truth
# test), so treat the construction itself as an eager side effect and
# fail closed instead of modelling every trigger site.
constructors.add(class_name)
methods.add((class_name, stmt.name))
return True
target = (class_name, stmt.name)
if stmt.name == "__init_subclass__" or _function_is_static_or_class_method(
stmt, operator_bindings
Expand Down Expand Up @@ -14469,6 +14588,16 @@ def _class_call_triggers_workers(expr, constructors, class_targets, reference_li
return False
if class_name in constructors:
return True
# An inherited implicit hook (``class Child(Bump): pass`` then
# ``Child() + 1``) runs exactly like the base's; resolve it through the
# same MRO and binding checks used for __new__/__init__ below.
if any(
_class_mro_hook_triggers_workers(
class_name, hook_name, class_targets, reference_line
)
for hook_name in _IMPLICIT_INSTANCE_HOOKS
):
return True
metaclass_bypass_events = (
class_targets[12] if len(class_targets) > 12 else {}
)
Expand Down Expand Up @@ -25568,6 +25697,24 @@ class Config:
TRUSTED_PROXY_NETWORKS = _parse_trusted_proxy_networks(
os.environ.get("TRUSTED_PROXY_IPS")
)
# Optional header the edge proxy overwrites with the client address (for
# example X-Real-IP). Only used when explicitly configured: an unconfigured
# header may be passed through from the client, which would let it choose
# its own rate-limit bucket.
TRUSTED_PROXY_CLIENT_IP_HEADER = os.environ.get(
"TRUSTED_PROXY_CLIENT_IP_HEADER", ""
).strip()
if TRUSTED_PROXY_CLIENT_IP_HEADER:
if TRUSTED_PROXY_COUNT == 0:
_emit_config_error(
"TRUSTED_PROXY_CLIENT_IP_HEADER requires TRUSTED_PROXY_COUNT > 0."
)
sys.exit(1)
if re.fullmatch(r"[A-Za-z0-9-]+", TRUSTED_PROXY_CLIENT_IP_HEADER) is None:
_emit_config_error(
"TRUSTED_PROXY_CLIENT_IP_HEADER must be a header name."
)
sys.exit(1)

# Shared limiter backend for multi-worker deployments (e.g. redis://redis:6379/0).
RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI", RATELIMIT_MEMORY_URI)
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ services:
- RATELIMIT_STORAGE_URI=${RATELIMIT_STORAGE_URI:-redis://redis:6379/0}
- PANEL_PUBLIC_URL=${PANEL_PUBLIC_URL:-}
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-}
# This compose publishes plain HTTP on 8000. For HTTPS deployments set
# PANEL_PUBLIC_URL=https://... (or SESSION_COOKIE_SECURE=true) and this to 0.
# Remove conflicting PANEL_PUBLIC_URL=http://... or SESSION_COOKIE_SECURE=false overrides.
- PANEL_ALLOW_INSECURE_HTTP=${PANEL_ALLOW_INSECURE_HTTP:-1}
- TRUSTED_PROXY_COUNT=${TRUSTED_PROXY_COUNT:-0}
- TRUSTED_PROXY_IPS=${TRUSTED_PROXY_IPS:-}
- TRUSTED_PROXY_CLIENT_IP_HEADER=${TRUSTED_PROXY_CLIENT_IP_HEADER:-}
# Live stats polling limits for /streams/<id>/stats.json (per minute):
# - STATS_RATE_LIMIT_PER_IP=600
# - STATS_RATE_LIMIT_PER_STREAM=25
Expand Down
19 changes: 17 additions & 2 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,24 @@ def _assert_config_import_with_gunicorn_file(
_forget_config_module()


def test_session_cookie_secure_defaults_false(monkeypatch):
def test_session_cookie_secure_defaults_secure_without_public_url(monkeypatch):
monkeypatch.delenv("SESSION_COOKIE_SECURE", raising=False)
import importlib
monkeypatch.delenv("PANEL_PUBLIC_URL", raising=False)
monkeypatch.delenv("TRUSTED_PROXY_COUNT", raising=False)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
monkeypatch.delenv("TRUSTED_PROXY_CLIENT_IP_HEADER", raising=False)
monkeypatch.delenv("PANEL_ALLOW_INSECURE_HTTP", raising=False)
import config

importlib.reload(config)
assert config.Config.SESSION_COOKIE_SECURE is True


def test_session_cookie_secure_allows_explicit_insecure_http(monkeypatch):
monkeypatch.delenv("SESSION_COOKIE_SECURE", raising=False)
monkeypatch.delenv("PANEL_PUBLIC_URL", raising=False)
monkeypatch.delenv("TRUSTED_PROXY_COUNT", raising=False)
monkeypatch.delenv("TRUSTED_PROXY_CLIENT_IP_HEADER", raising=False)
monkeypatch.setenv("PANEL_ALLOW_INSECURE_HTTP", "1")
import config

importlib.reload(config)
Expand Down
26 changes: 26 additions & 0 deletions tests/test_dockerfile_python_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""The builder and runtime stages must use the same Python interpreter.

``pip install --only-binary :all: --require-hashes`` runs in the builder and
selects wheels for that interpreter's ABI. Copying ``/install`` into a runtime
stage with a different Python minor ships ``cp3XX`` extensions that the runtime
cannot import, and the hash-pinned set no longer describes the running image.
"""

from pathlib import Path

import re

_DOCKERFILE = Path(__file__).resolve().parent.parent / "Dockerfile"


def _python_tags(text):
return re.findall(r"^FROM\s+python:([0-9]+\.[0-9]+)-", text, re.MULTILINE)


def test_dockerfile_builder_and_runtime_share_the_python_minor():
tags = _python_tags(_DOCKERFILE.read_text(encoding="utf-8"))
assert len(tags) >= 2, "expected a builder and a runtime python stage"
assert len(set(tags)) == 1, (
"builder and runtime stages must use the same Python minor: "
f"found {tags}"
)
Loading
Loading