From 34342b9f4d96d00cbfdc6ada9ad20eaaeae2cc58 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 9 Jul 2026 15:33:16 -0700 Subject: [PATCH] fix(security): trusted-proxy-verified local-origin gate (fixes #5764 terminal-gate bypass) (#5857) * fix(security): trusted-proxy-verified local-origin gate (maintainer fix of #5764) Rebuild _onboarding_request_is_local with a single, symmetric trust model that closes the embedded-terminal local-gate bypass AND fixes the direct-LAN lockout regression from the contributor's #5764 attempt (bounced with these findings): 1. SPOOF CLOSED: a forwarded client IP is honored ONLY when the un-spoofable raw socket peer is a trusted proxy (loopback, or HERMES_WEBUI_TRUSTED_PROXY_CIDRS). A public direct client sending X-Forwarded-For: 127.0.0.1 is judged on its raw public peer -> DENY. New _raw_peer_is_trusted_proxy + _forwarded_client_ip_from _trusted_proxy consume the FULL XFF chain (get_all, repeated headers), walk it right-to-left skipping trusted hops, and fail closed on empty/blank/garbage. 2. NO LAN LOCKOUT: a direct loopback/LAN client with no proxy header stays local (onboarding, first-password/passkey, passwordless terminal keep working). 3. COMPAT: TRUST_FORWARDED_FOR=1 stays the opt-in that consults the chain; the new TRUSTED_PROXY_CIDRS only WIDENS trust to a non-loopback proxy (loopback always trusted). Shipped private-peer-with-forwarded-header deny nuance preserved. Updated the one existing test that encoded the vulnerable behavior; added a 21-case adversarial truth-table regression (tests/test_cvd3_terminal_local_origin_gate.py). 66 gate tests + 720 auth/terminal regression pass. Co-authored-by: Hinotoi-agent * fix(security): family-aware trusted-proxy membership (IPv4-mapped-IPv6) Codex re-gate CORE: a mapped-IPv6 address (::ffff:10.9.9.9) never matched an IPv4 CIDR allowlist via 'addr in net' -> (1) legit proxy treated as untrusted = lockout; (2) mapped trusted HOP in the XFF chain mis-returned as client -> a preceding public client admitted to the terminal gate. New _ip_in_networks helper checks addr AND addr.ipv4_mapped; both the peer check and the chain-walk _is_trusted_hop route through it. +3 mapped-IPv6 regression cases (incl the public-client-hidden-behind-mapped-trusted-hop security case). --------- Co-authored-by: t Co-authored-by: Hinotoi-agent --- CHANGELOG.md | 2 + api/routes.py | 243 ++++++++++++++---- tests/test_cvd3_terminal_local_origin_gate.py | 117 +++++++++ tests/test_security_review_fixes.py | 16 ++ 4 files changed, 334 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3455e9621..be70f72b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Fixed +- **Security: the embedded-terminal local-origin gate can no longer be bypassed via a spoofed `X-Forwarded-For` header, and no longer locks out direct LAN clients.** Previously, with `HERMES_WEBUI_TRUST_FORWARDED_FOR=1`, the gate honored the forwarded client IP without first verifying the request actually came through a trusted proxy — so a public client connecting directly to a passwordless WebUI could send `X-Forwarded-For: 127.0.0.1` and reach the embedded terminal (which runs a PTY shell). The forwarded chain is now consulted **only when the un-spoofable raw socket peer is a trusted proxy** (loopback, or an address in the new `HERMES_WEBUI_TRUSTED_PROXY_CIDRS` allowlist), it consumes the full `X-Forwarded-For` chain right-to-left (skipping trusted-proxy hops to find the real client), and it fails closed on malformed/empty chains. Direct loopback/LAN clients with no proxy header remain local, so onboarding, first-password/passkey setup, and passwordless terminal access keep working on the common direct-LAN deployment. Thanks @Hinotoi-agent for the original report. (#5764) + - **Workspace image previews and file downloads now work when the WebUI is served under a subpath.** Behind a reverse proxy that mounts the app at a subpath (e.g. `/hermes/`), workspace image previews and downloads 404'd: those consumers built root-relative `/api/...` URLs that resolved to the server root instead of the mount, while text/code previews (which route through `api()`) kept working — a confusing partial failure. The workspace route helper now resolves against `document.baseURI`, so every consumer (image preview, media/pdf/html frames, download link, open-in-browser) gets a correctly-mounted URL. The normal root-mount deployment is unaffected (identical URLs). Thanks @tomtong2015. (#5830) - **Imported external-agent sessions (Claude Code, Codex) no longer show a non-zero "WebUI sessions" count with an empty list.** The server session-count classifier treated a read-only external-agent import (which carries a real title) as non-CLI — counting it under `webui_session_count` — while the client renderer filed the same row in the CLI bucket. The result was a confusing "WebUI sessions (N)" chip over an empty list, with those sessions only reachable under the CLI tab. The server now classifies `external_agent`/`external-agent` sources as CLI, matching the client, so the tab counts agree with where rows render. WebUI, messaging, and delegated-subagent sessions are unaffected. Thanks @nesquena-hermes. (#5846, #5831) diff --git a/api/routes.py b/api/routes.py index 5f64defe7..1f1a7a6d6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5472,53 +5472,85 @@ def _request_client_ip(handler) -> str: return "" -def _onboarding_request_is_local(handler) -> bool: - """Return True when an unauthenticated onboarding request is local/private. +def _ip_is_loopback_or_private(raw: str): + """Parse an IP string; return (parsed_ok, is_loopback_or_private). - Forwarded client-IP headers are ignored by default because direct clients can - spoof them. Operators behind a trusted reverse proxy may opt in with - HERMES_WEBUI_TRUST_FORWARDED_FOR=1, matching the explicit forwarded-header - trust model used elsewhere in the server. - - When forwarded headers are PRESENT but not trusted, the request arrived - through a proxy, so the raw socket address is the proxy's (typically - loopback/private) and tells us nothing about the real client's locality. - In that case we deny rather than fall back to the proxy socket — otherwise a - public client behind any reverse proxy would be treated as local. Operators - who front the WebUI with a trusted proxy must set - HERMES_WEBUI_TRUST_FORWARDED_FOR=1 (or HERMES_WEBUI_ONBOARDING_OPEN=1). + Returns (False, False) for empty/malformed input so callers fail closed. """ import ipaddress - trust_forwarded = _truthy_env("HERMES_WEBUI_TRUST_FORWARDED_FOR") - if trust_forwarded: - candidates = [ - handler.headers.get("X-Forwarded-For", "").split(",")[-1].strip(), - handler.headers.get("X-Real-IP", "").strip(), - _request_client_ip(handler), - ] - for raw in candidates: - if not raw: - continue - try: - addr = ipaddress.ip_address(raw) - except ValueError: - continue - return bool(addr.is_loopback or addr.is_private) - return False + raw = (raw or "").strip() + if not raw: + return (False, False) + try: + addr = ipaddress.ip_address(raw) + except ValueError: + return (False, False) + return (True, bool(addr.is_loopback or addr.is_private)) + + +def _trusted_proxy_networks(): + """Networks whose socket peer is allowed to assert a forwarded client IP. + + Loopback is ALWAYS trusted implicitly (the common same-host reverse-proxy + deployment). Operators fronting the WebUI with a LAN/remote proxy add its + address(es) via HERMES_WEBUI_TRUSTED_PROXY_CIDRS (comma-separated CIDRs or + bare IPs). Malformed entries are skipped, never widening trust. + """ + import ipaddress + + nets = [ + ipaddress.ip_network("127.0.0.0/8"), + ipaddress.ip_network("::1/128"), + ipaddress.ip_network("::ffff:127.0.0.0/104"), + ] + raw = os.getenv("HERMES_WEBUI_TRUSTED_PROXY_CIDRS", "") or "" + for token in raw.replace(";", ",").split(","): + token = token.strip() + if not token: + continue + try: + nets.append(ipaddress.ip_network(token, strict=False)) + except ValueError: + # Invalid CIDR/IP → skip (fail closed: never widens trust). + continue + return nets + + +def _ip_in_networks(addr, networks) -> bool: + """Family-aware membership test. + + Checks the parsed address against each network, and — for an IPv4-mapped + IPv6 address (e.g. ``::ffff:10.9.9.9``) — ALSO checks its embedded IPv4 form + against IPv4 networks. Without this, a mapped-IPv6 proxy peer would never + match an IPv4 CIDR allowlist: the trusted proxy would be treated as + untrusted (locking out legitimate clients behind it) and, inside an XFF + chain, a mapped trusted hop would be mis-returned as the client (admitting a + public client that preceded it). See #5764. + """ + candidates = [addr] + mapped = getattr(addr, "ipv4_mapped", None) + if mapped is not None: + candidates.append(mapped) + for cand in candidates: + for net in networks: + try: + if cand in net: + return True + except TypeError: + # IPv4/IPv6 family mismatch between candidate and net → skip. + continue + return False + + +def _raw_peer_is_trusted_proxy(handler) -> bool: + """True when the immediate socket peer is loopback or an allowlisted proxy. + + Only such a peer is allowed to assert a forwarded client IP. Judged on the + RAW socket address (never a header), so it cannot be spoofed. + """ + import ipaddress - # Untrusted forwarded headers present → the request arrived through a proxy. - # Ignore the spoofable header and judge by the raw socket, but only LOOPBACK - # counts as local in that case: a loopback raw socket is a genuine same-host - # client (or a same-host proxy the operator controls), whereas a PRIVATE/LAN - # raw socket is a separate proxy box that could be forwarding an arbitrary - # (public) client we can't see without trusting the header. Operators who - # front the WebUI with a LAN proxy must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1 - # (or HERMES_WEBUI_ONBOARDING_OPEN=1). - forwarded_present = bool( - handler.headers.get("X-Forwarded-For", "").strip() - or handler.headers.get("X-Real-IP", "").strip() - ) raw = _request_client_ip(handler) if not raw: return False @@ -5526,9 +5558,132 @@ def _onboarding_request_is_local(handler) -> bool: addr = ipaddress.ip_address(raw) except ValueError: return False + return _ip_in_networks(addr, _trusted_proxy_networks()) + + +def _forwarded_client_ip_from_trusted_proxy(handler): + """Resolve the real client IP from a chain fronted by a trusted proxy. + + Precondition: the caller has verified the raw socket peer is a trusted proxy. + Consumes ALL X-Forwarded-For values (across repeated headers), preserves wire + order, walks RIGHT-TO-LEFT skipping hops that are themselves trusted-proxy + addresses, and returns the first non-trusted (i.e. real-client) hop. Falls + back to X-Real-IP, then the raw socket peer. Returns None when the chain is + present-but-empty / malformed so the caller fails closed. + """ + import ipaddress + + try: + xff_values = handler.headers.get_all("X-Forwarded-For") or [] + except AttributeError: + single = handler.headers.get("X-Forwarded-For", "") + xff_values = [single] if single else [] + + hops: list[str] = [] + for header_value in xff_values: + for token in str(header_value or "").split(","): + hops.append(token.strip()) + + if xff_values: + # A present-but-empty / all-blank XFF is malformed → fail closed. + if not any(hops): + return None + trusted_nets = _trusted_proxy_networks() + + def _is_trusted_hop(ip_str: str) -> bool: + try: + addr = ipaddress.ip_address(ip_str) + except ValueError: + return False + return _ip_in_networks(addr, trusted_nets) + + for hop in reversed(hops): + if not hop: + # An empty hop inside the chain is malformed → fail closed + # rather than skip past it (an attacker could inject blanks). + return None + try: + ipaddress.ip_address(hop) + except ValueError: + # Non-IP token in the chain → malformed → fail closed. + return None + if _is_trusted_hop(hop): + continue + return hop + # Every hop was a trusted proxy → no distinct client; treat as the proxy + # tier itself (loopback/private), i.e. resolve to the raw peer below. + return _request_client_ip(handler) + + real_ip = handler.headers.get("X-Real-IP", "").strip() + if real_ip: + return real_ip + # No forwarded header at all → the trusted proxy is speaking for itself. + return _request_client_ip(handler) + + +def _onboarding_request_is_local(handler) -> bool: + """Return True when an unauthenticated onboarding request is local/private. + + Trust model (single, symmetric — see the full truth table in + tests/test_cvd3_terminal_local_origin_gate.py): + + * Forwarded client-IP headers are honored ONLY when the RAW socket peer is a + trusted proxy (loopback, or an address in HERMES_WEBUI_TRUSTED_PROXY_CIDRS). + This is checked on the un-spoofable socket address, so a direct client + cannot promote itself to "local" by sending X-Forwarded-For: 127.0.0.1. + * When the peer is NOT a trusted proxy, forwarded headers are ignored and the + request is classified by the raw socket peer directly. A direct loopback or + private/LAN client (no proxy) is therefore still correctly local — so + onboarding, first-password/passkey setup, and passwordless embedded-terminal + access keep working on the common direct-LAN deployment. + * HERMES_WEBUI_TRUST_FORWARDED_FOR=1 is the opt-in that makes us CONSULT the + forwarded chain at all; without it the raw peer is authoritative. Either + way the classification fails closed on malformed/empty chains. + """ + trust_forwarded = _truthy_env("HERMES_WEBUI_TRUST_FORWARDED_FOR") + peer_is_trusted_proxy = _raw_peer_is_trusted_proxy(handler) + + if trust_forwarded and peer_is_trusted_proxy: + client_ip = _forwarded_client_ip_from_trusted_proxy(handler) + if client_ip is None: + # Malformed/empty forwarded chain from a trusted proxy → fail closed. + return False + parsed_ok, is_local = _ip_is_loopback_or_private(client_ip) + return parsed_ok and is_local + + # Not consulting the forwarded chain (either the opt-in is off, or the raw + # peer is not a trusted proxy). Classify by the raw socket peer — it cannot + # be spoofed by a header. A public peer sending X-Forwarded-For: 127.0.0.1 is + # therefore correctly rejected (its raw peer is public). + raw = _request_client_ip(handler) + parsed_ok, is_local = _ip_is_loopback_or_private(raw) + if not parsed_ok: + return False + + import ipaddress + + addr = ipaddress.ip_address(raw.strip()) + if addr.is_loopback: + # A loopback TCP source is genuinely same-host and unspoofable → local + # even if a (ignored) forwarded header is present. + return True + + # Non-loopback raw peer. A forwarded header being PRESENT here means the + # request most likely arrived through a proxy we have NOT been told to trust + # (no trusted-proxy env, or the peer isn't in the allowlist) — so a + # private/LAN raw peer could be an untrusted proxy relaying an arbitrary + # (public) client we can't see. Deny in that case; require the operator to + # opt in via HERMES_WEBUI_TRUST_FORWARDED_FOR (+ HERMES_WEBUI_TRUSTED_PROXY_CIDRS + # for a non-loopback proxy). With NO forwarded header, a direct private/LAN + # client (the common direct-LAN deployment) stays local so onboarding, + # first-password/passkey setup, and passwordless terminal keep working. + forwarded_present = bool( + (handler.headers.get("X-Forwarded-For", "") or "").strip() + or (handler.headers.get("X-Real-IP", "") or "").strip() + ) if forwarded_present: - return bool(addr.is_loopback) - return bool(addr.is_loopback or addr.is_private) + return False + return bool(is_local) def _onboarding_gate_allows(handler, auth_enabled: bool | None = None) -> bool: diff --git a/tests/test_cvd3_terminal_local_origin_gate.py b/tests/test_cvd3_terminal_local_origin_gate.py index 7570cd44f..ea514e51f 100644 --- a/tests/test_cvd3_terminal_local_origin_gate.py +++ b/tests/test_cvd3_terminal_local_origin_gate.py @@ -200,3 +200,120 @@ def test_terminal_start_loopback_client_passes_gate(monkeypatch): # Gate passed → lookup ran → 404 (no such session), NOT 403. assert reached["called"] is True assert handler.status == 404 + + +# --------------------------------------------------------------------------- +# #5764 — trusted-proxy forwarded-client trust model, full truth table. +# The gate honors a forwarded client IP ONLY when the un-spoofable raw socket +# peer is a trusted proxy (loopback, or in HERMES_WEBUI_TRUSTED_PROXY_CIDRS), +# and only when HERMES_WEBUI_TRUST_FORWARDED_FOR=1. It must (a) never let a +# direct public client spoof itself local, (b) never lock out a direct +# loopback/LAN client with no proxy header, and (c) fail closed on malformed +# chains. See api/routes.py::_onboarding_request_is_local. +# --------------------------------------------------------------------------- + + +class _MultiHeaders(dict): + """Headers stub supporting repeated X-Forwarded-For via get_all().""" + + def get(self, key, default=None): + for k, v in self.items(): + if k.lower() == key.lower(): + return v[-1] if isinstance(v, list) else v + return default + + def get_all(self, key): + for k, v in self.items(): + if k.lower() == key.lower(): + return v if isinstance(v, list) else [v] + return [] + + +class _MHandler: + def __init__(self, *, client_ip, headers=None): + self.client_address = (client_ip, 12345) + self.headers = _MultiHeaders(headers or {}) + + +def _clear_fwd_env(monkeypatch): + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + monkeypatch.delenv("HERMES_WEBUI_TRUSTED_PROXY_CIDRS", raising=False) + + +import pytest + + +@pytest.mark.parametrize( + "name,client_ip,headers,env,expected", + [ + # --- spoof attempts: direct client sets a forwarded header --- + ("spoof_xff_loopback_default", "8.8.8.8", {"X-Forwarded-For": "127.0.0.1"}, {}, False), + ("spoof_xrealip_default", "8.8.8.8", {"X-Real-IP": "127.0.0.1"}, {}, False), + ("spoof_xff_loopback_trust_on", "8.8.8.8", {"X-Forwarded-For": "127.0.0.1"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, False), + # --- direct clients, no proxy --- + ("direct_loopback", "127.0.0.1", {}, {}, True), + ("direct_lan", "192.168.1.50", {}, {}, True), + ("direct_public", "8.8.8.8", {}, {}, False), + ("direct_lan_trust_on_no_header", "192.168.1.50", {}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, True), + # --- trusted loopback proxy, TRUST on --- + ("loopback_proxy_public_client", "127.0.0.1", {"X-Forwarded-For": "8.8.8.8"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, False), + ("loopback_proxy_private_client", "127.0.0.1", {"X-Forwarded-For": "192.168.1.50"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, True), + # right-to-left: first non-trusted hop is the client + ("chain_public_then_proxy", "127.0.0.1", {"X-Forwarded-For": "8.8.8.8, 127.0.0.1"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, False), + # ATTACK: hide a public field behind a trusted first field + ("attack_hide_public_behind_trusted", "127.0.0.1", + {"X-Forwarded-For": "127.0.0.1, 8.8.8.8"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, False), + # repeated XFF headers (get_all): "8.8.8.8" then "127.0.0.1" + ("attack_repeated_xff_headers", "127.0.0.1", + {"X-Forwarded-For": ["127.0.0.1", "8.8.8.8"]}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, False), + # --- malformed chains fail closed --- + ("malformed_empty_xff", "127.0.0.1", {"X-Forwarded-For": ","}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, False), + ("malformed_garbage_xff", "127.0.0.1", {"X-Forwarded-For": "notanip"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, False), + ("malformed_blank_hop_in_chain", "127.0.0.1", {"X-Forwarded-For": "192.168.1.5, , 127.0.0.1"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1"}, False), + # --- remote proxy via CIDR allowlist --- + ("remote_trusted_proxy_private_client", "10.9.9.9", {"X-Forwarded-For": "192.168.1.50"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1", "HERMES_WEBUI_TRUSTED_PROXY_CIDRS": "10.9.9.0/24"}, True), + ("remote_trusted_proxy_public_client", "10.9.9.9", {"X-Forwarded-For": "8.8.8.8"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1", "HERMES_WEBUI_TRUSTED_PROXY_CIDRS": "10.9.9.0/24"}, False), + # invalid CIDR is skipped (never widens trust); peer 10.9.9.9 is a direct + # private LAN box with a forwarded header present but no trusted proxy → + # denied (could be relaying an unseen client). + ("invalid_cidr_private_peer_with_header", "10.9.9.9", {"X-Forwarded-For": "8.8.8.8"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1", "HERMES_WEBUI_TRUSTED_PROXY_CIDRS": "not-a-cidr"}, False), + # --- opt-in OFF: raw peer authoritative, header ignored --- + ("trust_off_loopback_proxy_xff_public", "127.0.0.1", {"X-Forwarded-For": "8.8.8.8"}, {}, True), + ("trust_off_lan_peer_with_header", "10.0.0.5", {"X-Real-IP": "203.0.113.7"}, {}, False), + # --- #5764 re-gate: IPv4-mapped-IPv6 must be family-aware --- + # mapped-IPv6 proxy peer matches an IPv4 CIDR allowlist -> trusted -> private client local + ("mapped_ipv6_proxy_peer_in_ipv4_cidr", "::ffff:10.9.9.9", {"X-Forwarded-For": "192.168.1.50"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1", "HERMES_WEBUI_TRUSTED_PROXY_CIDRS": "10.9.9.0/24"}, True), + # mapped-IPv6 proxy peer, public client -> DENY + ("mapped_ipv6_proxy_peer_public_client", "::ffff:10.9.9.9", {"X-Forwarded-For": "8.8.8.8"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1", "HERMES_WEBUI_TRUSTED_PROXY_CIDRS": "10.9.9.0/24"}, False), + # mapped-IPv6 TRUSTED HOP inside the chain must be skipped so the preceding + # PUBLIC client is returned -> DENY (the security-critical case). + ("mapped_ipv6_trusted_hop_hides_public", "127.0.0.1", + {"X-Forwarded-For": "8.8.8.8, ::ffff:10.9.9.9"}, + {"HERMES_WEBUI_TRUST_FORWARDED_FOR": "1", "HERMES_WEBUI_TRUSTED_PROXY_CIDRS": "10.9.9.0/24"}, False), + ], +) +def test_onboarding_local_gate_trust_model_truth_table( + monkeypatch, name, client_ip, headers, env, expected +): + from api import routes + + _clear_fwd_env(monkeypatch) + for k, v in env.items(): + monkeypatch.setenv(k, v) + handler = _MHandler(client_ip=client_ip, headers=headers) + assert routes._onboarding_request_is_local(handler) is expected, name diff --git a/tests/test_security_review_fixes.py b/tests/test_security_review_fixes.py index 5cd682faf..f97a9c767 100644 --- a/tests/test_security_review_fixes.py +++ b/tests/test_security_review_fixes.py @@ -45,14 +45,30 @@ def test_onboarding_local_gate_ignores_forwarded_ip_unless_trusted(monkeypatch): def test_onboarding_local_gate_uses_forwarded_ip_when_explicitly_trusted(monkeypatch): + """Even with HERMES_WEBUI_TRUST_FORWARDED_FOR=1, a forwarded header is only + honored when the RAW socket peer is a trusted proxy (loopback or in + HERMES_WEBUI_TRUSTED_PROXY_CIDRS). A PUBLIC direct client (raw peer 8.8.8.8) + that merely SETS X-Forwarded-For can NOT promote itself to local — otherwise + a passwordless WebUI with the opt-in enabled would admit any remote attacker + to the embedded terminal (#5764). The forwarded IP is consulted only after + the un-spoofable socket peer is confirmed to be a trusted proxy. + """ from api import routes monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", "1") + monkeypatch.delenv("HERMES_WEBUI_TRUSTED_PROXY_CIDRS", raising=False) handler = _Handler( client_ip="8.8.8.8", headers={"X-Forwarded-For": "10.0.0.2", "X-Real-IP": "203.0.113.11"}, ) + # Raw peer 8.8.8.8 is NOT a trusted proxy → forwarded header ignored → + # classified by the public raw peer → DENY. + assert routes._onboarding_request_is_local(handler) is False + + # With the peer's own network in the trusted-proxy allowlist, the forwarded + # client IP (private 10.0.0.2) is now honored → local. + monkeypatch.setenv("HERMES_WEBUI_TRUSTED_PROXY_CIDRS", "8.8.8.0/24") assert routes._onboarding_request_is_local(handler) is True