mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 04:30:17 +00:00
* 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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user