diff --git a/CHANGELOG.md b/CHANGELOG.md index 901ea91..35195bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile index e6584e9..c9880ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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="" diff --git a/README.md b/README.md index 98be54d..13d5752 100644 --- a/README.md +++ b/README.md @@ -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: ```env PANEL_PUBLIC_URL=https://panel.example.com diff --git a/app.py b/app.py index be10106..7d9ecc7 100644 --- a/app.py +++ b/app.py @@ -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): diff --git a/compose.quickstart.yml b/compose.quickstart.yml index 9a860f2..c67402a 100644 --- a/compose.quickstart.yml +++ b/compose.quickstart.yml @@ -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} 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: diff --git a/config.py b/config.py index 41e6c30..70af990 100644 --- a/config.py +++ b/config.py @@ -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://" @@ -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]) + 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 @@ -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 @@ -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"): @@ -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 @@ -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 {} ) @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 6c97517..c071210 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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//stats.json (per minute): # - STATS_RATE_LIMIT_PER_IP=600 # - STATS_RATE_LIMIT_PER_STREAM=25 diff --git a/tests/test_app.py b/tests/test_app.py index 939a8d6..747a47e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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) + 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) diff --git a/tests/test_dockerfile_python_version.py b/tests/test_dockerfile_python_version.py new file mode 100644 index 0000000..624ad09 --- /dev/null +++ b/tests/test_dockerfile_python_version.py @@ -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}" + ) diff --git a/tests/test_gunicorn_implicit_dunder_workers.py b/tests/test_gunicorn_implicit_dunder_workers.py new file mode 100644 index 0000000..c94a854 --- /dev/null +++ b/tests/test_gunicorn_implicit_dunder_workers.py @@ -0,0 +1,151 @@ +"""Regression tests for implicit dunder dispatch on constructed config classes. + +A Gunicorn config file is ordinary Python executed at import. A class whose +dunder hook rebinds ``workers`` therefore changes the worker count even when +the config never calls a method directly: ``Bump() + 1`` runs ``__add__``, +``f"{Bump()}"`` runs ``__format__``, ``for _ in Bump():`` runs ``__iter__`` +and ``if Bump():`` runs ``__bool__``. The scanner must treat constructing such +a class as an import-time workers side effect; otherwise it reports +``workers = 1`` while Gunicorn forks the mutated count. +""" + +import ast + +import pytest + +_DYNAMIC_CASES = [ + ( + "binary operator dispatch through __add__", + "workers = 1\n" + "class Bump:\n" + " def __add__(self, other):\n" + " global workers\n" + " workers = 4\n" + " return 0\n" + "Bump() + 1\n", + ), + ( + "reflected binary operator dispatch through __radd__", + "workers = 1\n" + "class Bump:\n" + " def __radd__(self, other):\n" + " global workers\n" + " workers = 4\n" + " return 0\n" + "1 + Bump()\n", + ), + ( + "f-string dispatch through __format__", + "workers = 1\n" + "class Bump:\n" + " def __format__(self, spec):\n" + " global workers\n" + " workers = 4\n" + " return ''\n" + "f'{Bump()}'\n", + ), + ( + "for-loop dispatch through __iter__", + "workers = 1\n" + "class Bump:\n" + " def __iter__(self):\n" + " global workers\n" + " workers = 4\n" + " return iter(())\n" + "for _ in Bump():\n" + " pass\n", + ), + ( + "truth-test dispatch through __bool__", + "workers = 1\n" + "class Bump:\n" + " def __bool__(self):\n" + " global workers\n" + " workers = 4\n" + " return False\n" + "if Bump():\n" + " pass\n", + ), + ( + "membership dispatch through __contains__", + "workers = 1\n" + "class Bump:\n" + " def __contains__(self, item):\n" + " global workers\n" + " workers = 4\n" + " return False\n" + "1 in Bump()\n", + ), + ( + "comparison dispatch through __eq__", + "workers = 1\n" + "class Bump:\n" + " def __eq__(self, other):\n" + " global workers\n" + " workers = 4\n" + " return False\n" + "Bump() == 1\n", + ), + ( + "an inherited implicit hook is resolved through the MRO", + "workers = 1\n" + "class Bump:\n" + " def __iter__(self):\n" + " global workers\n" + " workers = 4\n" + " return iter(())\n" + "class Child(Bump):\n" + " pass\n" + "for _ in Child():\n" + " pass\n", + ), + ( + "in-place operator dispatch through __iadd__", + "workers = 1\n" + "class Bump:\n" + " def __iadd__(self, other):\n" + " global workers\n" + " workers = 4\n" + " return self\n" + "h = Bump()\n" + "h += 1\n", + ), +] + +_SAFE_CASES = [ + ( + "a mutating dunder that is never instantiated stays static", + "workers = 1\n" + "class Bump:\n" + " def __add__(self, other):\n" + " global workers\n" + " workers = 4\n" + " return 0\n", + ), + ( + "a constructed class with a non-mutating dunder stays static", + "workers = 1\n" + "class Helper:\n" + " def __add__(self, other):\n" + " return 0\n" + "Helper() + 1\n", + ), +] + + +@pytest.mark.parametrize( + "source", + [case[1] for case in _DYNAMIC_CASES], + ids=[case[0] for case in _DYNAMIC_CASES], +) +def test_implicit_dunder_dispatch_is_dynamic(config_module, source): + assert config_module._scan_gunicorn_config_workers(ast.parse(source)) == (1, True) + + +@pytest.mark.parametrize( + "source", + [case[1] for case in _SAFE_CASES], + ids=[case[0] for case in _SAFE_CASES], +) +def test_implicit_dunder_scan_stays_static(config_module, source): + assert config_module._scan_gunicorn_config_workers(ast.parse(source)) == (1, False) diff --git a/tests/test_proxy_rate_limit_security.py b/tests/test_proxy_rate_limit_security.py index 850945e..b5b5f78 100644 --- a/tests/test_proxy_rate_limit_security.py +++ b/tests/test_proxy_rate_limit_security.py @@ -34,10 +34,64 @@ def test_client_ip_for_rate_limit_uses_forwarded_ip_from_trusted_proxy(): forwarded_addr="198.51.100.25", trusted_proxy_count=1, trusted_networks=networks, + xff_header="198.51.100.25", ) assert client_ip == "198.51.100.25" +def test_client_ip_for_rate_limit_ignores_a_spoofable_xff_position(): + networks = [ipaddress.ip_network("10.0.0.0/8")] + # An over-counted TRUSTED_PROXY_COUNT makes ProxyFix select the first + # entry (5.6.7.8), which the client wrote. The limiter must use the + # proxy-appended last entry instead. + client_ip = config.client_ip_for_rate_limit( + direct_addr="10.0.0.2", + forwarded_addr="5.6.7.8", + trusted_proxy_count=2, + trusted_networks=networks, + xff_header="5.6.7.8, 198.51.100.25", + ) + assert client_ip == "198.51.100.25" + + +def test_client_ip_for_rate_limit_prefers_the_configured_client_ip_header(): + networks = [ipaddress.ip_network("10.0.0.0/8")] + client_ip = config.client_ip_for_rate_limit( + direct_addr="10.0.0.2", + forwarded_addr="5.6.7.8", + trusted_proxy_count=1, + trusted_networks=networks, + xff_header="5.6.7.8", + proxy_client_ip="198.51.100.25", + ) + assert client_ip == "198.51.100.25" + + +def test_client_ip_for_rate_limit_ignores_an_unconfigured_client_header(): + networks = [ipaddress.ip_network("10.0.0.0/8")] + # The caller only passes the header value when the operator configured it; + # the X-Real-IP request header alone must not move the bucket. + client_ip = config.client_ip_for_rate_limit( + direct_addr="10.0.0.2", + forwarded_addr="5.6.7.8", + trusted_proxy_count=1, + trusted_networks=networks, + xff_header="6.6.6.6, 198.51.100.25", + ) + assert client_ip == "198.51.100.25" + + +def test_client_ip_for_rate_limit_falls_back_to_the_proxy_without_headers(): + networks = [ipaddress.ip_network("10.0.0.0/8")] + client_ip = config.client_ip_for_rate_limit( + direct_addr="10.0.0.2", + forwarded_addr="5.6.7.8", + trusted_proxy_count=1, + trusted_networks=networks, + ) + assert client_ip == "10.0.0.2" + + def test_client_ip_for_rate_limit_uses_forwarded_ip_from_dual_stack_proxy(): networks = [ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("127.0.0.1/32")] assert config.ip_in_trusted_proxy_networks("::ffff:10.1.2.3", networks) is True @@ -49,6 +103,7 @@ def test_client_ip_for_rate_limit_uses_forwarded_ip_from_dual_stack_proxy(): forwarded_addr="198.51.100.25", trusted_proxy_count=1, trusted_networks=networks, + xff_header="198.51.100.25", ) == "198.51.100.25" ) @@ -97,6 +152,41 @@ def test_config_requires_trusted_proxy_ips_when_proxy_count_enabled(monkeypatch) assert exc.value.code == 1 +def test_config_requires_proxy_count_for_a_client_ip_header(monkeypatch): + monkeypatch.setenv("SECRET_KEY", "test-secret-key-for-ci-validation-only-32chars") + monkeypatch.setenv("PASSWORD", "test-password-for-ci-only") + monkeypatch.setenv("LRTMP2_API_TOKEN", "test-api-token-for-ci-only") + monkeypatch.setenv("REQUIRE_LOGIN", "true") + monkeypatch.setenv("TRUSTED_PROXY_CLIENT_IP_HEADER", "X-Real-IP") + monkeypatch.delenv("TRUSTED_PROXY_COUNT", raising=False) + + import importlib + import sys + + sys.modules.pop("config", None) + with pytest.raises(SystemExit) as exc: + importlib.import_module("config") + assert exc.value.code == 1 + + +def test_config_accepts_a_client_ip_header_with_proxy_count(monkeypatch): + monkeypatch.setenv("SECRET_KEY", "test-secret-key-for-ci-validation-only-32chars") + monkeypatch.setenv("PASSWORD", "test-password-for-ci-only") + monkeypatch.setenv("LRTMP2_API_TOKEN", "test-api-token-for-ci-only") + monkeypatch.setenv("REQUIRE_LOGIN", "true") + monkeypatch.setenv("TRUSTED_PROXY_COUNT", "1") + monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1") + monkeypatch.setenv("TRUSTED_PROXY_CLIENT_IP_HEADER", "X-Real-IP") + + import importlib + import sys + + sys.modules.pop("config", None) + config = importlib.import_module("config") + assert config.Config.TRUSTED_PROXY_CLIENT_IP_HEADER == "X-Real-IP" + importlib.reload(config) + + def test_session_cookie_secure_defaults_true_when_trusted_proxy_count_enabled( monkeypatch, ):