mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
With webapp.public_url: https://localhost:8443 in front of a Webapp on 127.0.0.1:9120, a proxy that rewrites Host to the backend's address sends Host 127.0.0.1:9120 with Origin https://localhost:8443. That Origin names neither the bound port nor the Host it dials, so CORS gave no grant and every WebSocket upgrade got 4403. The loopback port check never read the configured public URL, and a loopback public URL does not turn on sign-in. _configure_auth_gate now also records the public URL's exact loopback origin (scheme, host, port; default ports filled in) as app.state.trusted_public_origins, from listener_public_url (the Webapp's webapp.public_url) or dashboard.public_url, and is_trusted_local_origin accepts an Origin equal to it. Nothing else is widened: another loopback port, scheme or host on the same proxy is still refused, and a non-loopback public URL adds nothing (its Origin never reaches the port check). The port check keeps running on 0.0.0.0 / :: binds. Sign-in cookies are SameSite=Lax and http://localhost:3000 is same-site with http://localhost:9119, so sign-in alone does not keep another local page on the same host off the sockets; a new test pins that. The public_url cases fail on the previous sources (4 failed: every trusted case) and pass with this change. web-dashboard.md describes the proxy setup.
286 lines
13 KiB
Python
286 lines
13 KiB
Python
"""Listener boundary primitives shared by the dashboard app and the gateway's session listener:
|
|
Host validation, the auth-gate decision and the native WebSocket ticket subprotocol.
|
|
|
|
Import-light on purpose (no FastAPI): ``gateway.run_api`` serves the native session WebSocket
|
|
without importing the dashboard app (about 690 routes, ~0.6 s), and must apply the same
|
|
DNS-rebinding Host check and gate decision. ``hermes_cli.web_server`` and
|
|
``hermes_cli.web_server_chat`` re-export these names, so ``web_server.<name>`` keeps working.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import urllib.parse
|
|
from typing import Optional
|
|
|
|
# Accepted Host values for loopback binds. DNS rebinding TTL-flips an attacker
|
|
# hostname to 127.0.0.1 so the browser treats it as same-origin; validating Host
|
|
# at the app layer rejects it. See GHSA-ppp5-vxwm-4cf7.
|
|
_LOOPBACK_HOST_VALUES: frozenset = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
|
|
# Starlette's TestClient reports the peer as "testclient"; treat it as
|
|
# loopback so tests don't need to rewrite request scope.
|
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
|
|
|
# Desktop file.attach sends a whole base64 data URL in one JSON-RPC frame;
|
|
# uvicorn's 16 MiB default rejects files under the 256 MiB raw attach cap.
|
|
_DESKTOP_ATTACHMENT_WS_MAX_BYTES = 384 * 1024 * 1024
|
|
|
|
|
|
def _dashboard_public_hosts() -> frozenset[str]:
|
|
"""Return the exact hostname declared by ``dashboard.public_url``.
|
|
|
|
One source of truth for OAuth redirects, Host and WS Origin validation.
|
|
Malformed or unset values fail closed as an empty set.
|
|
"""
|
|
from hermes_cli.dashboard_auth.prefix import resolve_public_url
|
|
|
|
return public_url_hosts(resolve_public_url())
|
|
|
|
|
|
def public_url_hosts(public_url: str) -> frozenset[str]:
|
|
"""The exact hostname *public_url* declares, as the trusted public host set; empty when unset or
|
|
malformed."""
|
|
try:
|
|
hostname = urllib.parse.urlparse(public_url).hostname if public_url else None
|
|
except ValueError:
|
|
hostname = None
|
|
return frozenset({hostname.lower()}) if hostname else frozenset()
|
|
|
|
|
|
def _host_header_hostname(host_header: str) -> str:
|
|
"""Return a normalized hostname from a valid HTTP Host authority.
|
|
|
|
Host headers are authorities, not full URLs. Reject ambiguous ports,
|
|
malformed IPv6 brackets, and URL syntax so validation always fails closed.
|
|
"""
|
|
value = (host_header or "").strip()
|
|
if not value or "://" in value or any(c in value for c in '"\'<> \n\r\t/?#@'):
|
|
return ""
|
|
|
|
if value.startswith("["):
|
|
close = value.find("]")
|
|
if close == -1:
|
|
return ""
|
|
hostname = value[1:close]
|
|
# Bracket notation is reserved for IPv6 literals.
|
|
if ":" not in hostname:
|
|
return ""
|
|
suffix = value[close + 1:]
|
|
if suffix and not re.fullmatch(r":\d+", suffix):
|
|
return ""
|
|
return hostname.lower()
|
|
|
|
# Unbracketed IPv6 authorities are ambiguous with a port separator.
|
|
if value.count(":") > 1:
|
|
return ""
|
|
if ":" in value:
|
|
hostname, port = value.rsplit(":", 1)
|
|
if not hostname or not port.isdigit():
|
|
return ""
|
|
return hostname.lower()
|
|
return value.lower()
|
|
|
|
|
|
def _is_accepted_host(
|
|
host_header: str,
|
|
bound_host: str,
|
|
trusted_public_hosts: frozenset[str] = frozenset(),
|
|
) -> bool:
|
|
"""True if the Host header targets the interface we bound to.
|
|
|
|
Accepts:
|
|
- Exact bound host (with or without port suffix)
|
|
- Loopback aliases when bound to loopback
|
|
- Exact operator-declared public hosts (with or without port suffix)
|
|
- Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback,
|
|
no protection possible at this layer)
|
|
"""
|
|
host_only = _host_header_hostname(host_header)
|
|
if not host_only:
|
|
return False
|
|
# All-interfaces bind: no Host-layer defence is possible; rely on operator
|
|
# network controls.
|
|
if host_only in trusted_public_hosts or bound_host in {"0.0.0.0", "::"}:
|
|
return True
|
|
bound_lc = bound_host.lower()
|
|
if bound_lc in _LOOPBACK_HOST_VALUES:
|
|
return host_only in _LOOPBACK_HOST_VALUES
|
|
return host_only == bound_lc
|
|
|
|
|
|
def should_require_auth(host: str, allow_public: bool = False) -> bool:
|
|
"""True iff the auth gate must be active: any non-loopback bind.
|
|
|
|
RFC1918 / CGNAT / link-local are deliberately PUBLIC — a hostile LAN device
|
|
is the threat model. ``allow_public`` (legacy ``--insecure``) is accepted for
|
|
old launch scripts but IGNORED since the June 2026 hermes-0day campaign.
|
|
"""
|
|
return host not in _LOOPBACK_HOST_VALUES
|
|
|
|
|
|
def public_hosts_engage_gate(trusted_public_hosts: frozenset[str]) -> bool:
|
|
"""A non-loopback ``dashboard.public_url`` host engages the auth gate even on a loopback bind."""
|
|
return any(h not in _LOOPBACK_HOST_VALUES for h in trusted_public_hosts)
|
|
|
|
|
|
def _desktop_loopback_auth_exempt(
|
|
host: str,
|
|
ssh_session_token: Optional[str] = None,
|
|
ssh_owner_nonce: Optional[str] = None,
|
|
) -> bool:
|
|
"""True for a Desktop-owned loopback backend (#96490).
|
|
|
|
A non-loopback ``dashboard.public_url`` would otherwise engage the
|
|
ticket-only gate for the private loopback backends Desktop spawns, whose
|
|
per-spawn session token the gate's WS path refuses — Desktop could not boot.
|
|
The public dashboard is a separate non-loopback process that stays gated, so
|
|
this never opens the public surface. Requires ALL of: loopback bind,
|
|
``HERMES_DESKTOP=1``, and an operator-minted credential (env token, SSH
|
|
session token, or owner nonce).
|
|
"""
|
|
return (
|
|
host in _LOOPBACK_HOST_VALUES
|
|
and os.environ.get("HERMES_DESKTOP") == "1"
|
|
and bool(os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or ssh_session_token or ssh_owner_nonce)
|
|
)
|
|
|
|
|
|
def listener_auth_required(host: str, trusted_public_hosts: frozenset[str]) -> bool:
|
|
"""The ``app.state.auth_required`` value ``web_server._configure_auth_gate(host, False, None,
|
|
None)`` resolves for a listener with no SSH credentials (the gateway's session listener)."""
|
|
if _desktop_loopback_auth_exempt(host):
|
|
return should_require_auth(host)
|
|
return should_require_auth(host) or public_hosts_engage_gate(trusted_public_hosts)
|
|
|
|
|
|
_GATEWAY_WS_PROTOCOL = "hermes-gateway-v1"
|
|
_GATEWAY_WS_TICKET_PROTOCOL_PREFIX = "hermes-gateway-ticket."
|
|
|
|
|
|
def _gateway_ws_ticket_from_subprotocol(ws) -> tuple[str, str]:
|
|
"""Return ``(ticket, reason)`` from an unambiguous gateway protocol set."""
|
|
raw = str(ws.headers.get("sec-websocket-protocol", "") or "")
|
|
protocols = [value.strip() for value in raw.split(",") if value.strip()]
|
|
ticket_protocols = [
|
|
value for value in protocols if value.startswith(_GATEWAY_WS_TICKET_PROTOCOL_PREFIX)]
|
|
if not ticket_protocols:
|
|
return "", "none"
|
|
if _GATEWAY_WS_PROTOCOL not in protocols or len(ticket_protocols) != 1:
|
|
return "", "invalid"
|
|
ticket = ticket_protocols[0][len(_GATEWAY_WS_TICKET_PROTOCOL_PREFIX):]
|
|
return (ticket, "ok") if ticket else ("", "invalid")
|
|
|
|
|
|
def ws_client_reason(ws, state) -> Optional[str]:
|
|
"""Rejection reason token for the WebSocket peer IP under listener *state*
|
|
(``auth_required`` / ``bound_host``), or None when allowed.
|
|
|
|
Loopback bind: only loopback peers (the legacy ``?token=`` is the only auth,
|
|
LAN hosts must not get to guess it); an empty peer fails closed. Explicit
|
|
non-loopback bind (``--insecure``) or gated mode: any peer — DNS-rebinding is
|
|
blocked by :func:`ws_host_origin_reason`, and in gated mode
|
|
``ws.client.host`` is the X-Forwarded-For value anyway.
|
|
"""
|
|
if getattr(state, "auth_required", False):
|
|
return None
|
|
bound_host = (getattr(state, "bound_host", "") or "").strip().lower()
|
|
if bound_host and bound_host not in _LOOPBACK_HOSTS:
|
|
return None
|
|
client_host = ws.client.host if ws.client else ""
|
|
if not client_host:
|
|
return f"missing_or_empty_peer bound={bound_host or '?'}"
|
|
if client_host in _LOOPBACK_HOSTS:
|
|
return None
|
|
return f"peer_not_loopback peer={client_host} bound={bound_host or '?'}"
|
|
|
|
|
|
# CORS: this backend's own loopback origin (the port actually bound) and the desktop dev renderer it
|
|
# was spawned for, nothing else (web-dashboard.md § CORS). allow_origins=["*"] on 0.0.0.0 would let
|
|
# any website read/modify config and secrets; ANY local port would let a page previewed from another
|
|
# port (an agent-built site, Live Server, a compromised dev app) read index.html's session token and
|
|
# drive /api/pty. No fixed dev port is trusted either: 3000, 5173 and 5174 are where most local dev
|
|
# servers land (Vite takes 5174 when 5173 is busy). The dashboard's Vite dev server proxies with
|
|
# Host and Origin unchanged (web/vite.config.ts), so its page's upgrades name the Host they dial and
|
|
# ws_host_origin_reason admits them; it needs no grant here. The WebSocket Origin gate applies the
|
|
# same rule (ws_host_origin_reason).
|
|
|
|
|
|
def _loopback_origin(origin: str) -> Optional[tuple[str, str, int]]:
|
|
"""``(scheme, host, port)`` of an http(s) loopback origin (default port filled in), else None."""
|
|
try:
|
|
parsed = urllib.parse.urlparse(origin)
|
|
port = parsed.port or {"http": 80, "https": 443}.get(parsed.scheme)
|
|
except ValueError: # malformed authority, out-of-range / non-numeric port
|
|
return None
|
|
if parsed.scheme not in {"http", "https"} or parsed.hostname not in _LOOPBACK_HOST_VALUES:
|
|
return None
|
|
return parsed.scheme, parsed.hostname, port
|
|
|
|
|
|
def public_url_origins(public_url: str) -> frozenset[tuple[str, str, int]]:
|
|
"""The loopback origin a ``public_url`` declares (a reverse proxy on another local port), as the
|
|
set :func:`is_trusted_local_origin` reads from ``state.trusted_public_origins``; empty for an
|
|
unset, malformed or non-loopback URL (a non-loopback Origin never meets the port check)."""
|
|
origin = _loopback_origin(public_url) if public_url else None
|
|
return frozenset({origin}) if origin else frozenset()
|
|
|
|
|
|
def _dashboard_public_origins() -> frozenset[tuple[str, str, int]]:
|
|
""":func:`public_url_origins` of ``dashboard.public_url``."""
|
|
from hermes_cli.dashboard_auth.prefix import resolve_public_url
|
|
|
|
return public_url_origins(resolve_public_url())
|
|
|
|
|
|
def is_trusted_local_origin(origin: str, state) -> bool:
|
|
"""True for a loopback origin on *state*'s bound port, the exact origin of the configured public
|
|
URL (``state.trusted_public_origins``: a proxy at ``https://localhost:8443`` that rewrites Host to
|
|
the backend's), or the desktop dev renderer this backend serves. Electron hands its
|
|
``HERMES_DESKTOP_DEV_SERVER`` down to the backend it spawns, and worktree-ui-dev.md's ``hgui``
|
|
slots run that renderer on 5174+N."""
|
|
loopback = _loopback_origin(origin)
|
|
if loopback is None:
|
|
return False
|
|
if loopback[2] == getattr(state, "bound_port", None):
|
|
return True
|
|
if loopback in (getattr(state, "trusted_public_origins", None) or frozenset()):
|
|
return True
|
|
return _loopback_origin(os.environ.get("HERMES_DESKTOP_DEV_SERVER", "")) == loopback
|
|
|
|
|
|
def ws_host_origin_reason(ws, state) -> Optional[str]:
|
|
"""``host_mismatch …`` / ``origin_mismatch …`` under listener *state*, or None when allowed.
|
|
|
|
HTTP middleware does not run for WebSocket routes, so the DNS-rebinding
|
|
Host check is repeated here; an Origin header, when present, must target the
|
|
bound host. Non-web origins (packaged Electron: file://, null, app://) are
|
|
trusted — the credential check is the real auth boundary there.
|
|
"""
|
|
bound_host = getattr(state, "bound_host", None)
|
|
if not bound_host:
|
|
return None
|
|
trusted_public_hosts = getattr(state, "trusted_public_hosts", frozenset())
|
|
host_header = ws.headers.get("host", "")
|
|
if not _is_accepted_host(host_header, bound_host, trusted_public_hosts):
|
|
return f"host_mismatch host={host_header or '?'} bound={bound_host}"
|
|
origin = ws.headers.get("origin", "")
|
|
if not origin:
|
|
return None
|
|
try:
|
|
parsed = urllib.parse.urlparse(origin)
|
|
except ValueError: # malformed authority, e.g. "http://[::1" — fail closed
|
|
parsed = None
|
|
if parsed is not None and parsed.scheme not in {"http", "https"}:
|
|
return None
|
|
if parsed is None or not parsed.netloc or not _is_accepted_host(parsed.netloc, bound_host, trusted_public_hosts):
|
|
return f"origin_mismatch origin={origin} bound={bound_host}"
|
|
# _is_accepted_host drops the port: a page on ANOTHER loopback port is a different origin, the
|
|
# same one CORS refuses — without this it opens /api/pty with a token it read off index.html.
|
|
# An Origin naming the very authority the upgrade targets is this server's own page reached
|
|
# through a port forward (``ssh -L 8000:localhost:9119``, ``docker -p 8080:9119``): same-origin
|
|
# by definition, and a page on another port cannot make the two match.
|
|
if (parsed.hostname in _LOOPBACK_HOST_VALUES and parsed.netloc.lower() != host_header.strip().lower()
|
|
and not is_trusted_local_origin(origin, state)):
|
|
return f"origin_mismatch origin={origin} bound={bound_host}"
|
|
return None
|