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 <Hinotoi-agent@users.noreply.github.com>

* 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 <a@b>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-07-09 15:33:16 -07:00
committed by GitHub
parent 1a64d7d313
commit 34342b9f4d
4 changed files with 334 additions and 44 deletions
+2
View File
@@ -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)
+199 -44
View File
@@ -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:
@@ -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
+16
View File
@@ -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