fix(dashboard): secure loopback public URL proxy mode

This commit is contained in:
e-macgregor
2026-07-24 16:44:58 +00:00
committed by Teknium
parent 608a56ed7f
commit d3df14a7e3
8 changed files with 397 additions and 52 deletions
+5 -2
View File
@@ -1669,8 +1669,11 @@ DEFAULT_CONFIG = {
# Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``).
# When set, this is the complete authority — scheme + host +
# optional path prefix (e.g. ``https://example.com/hermes``) —
# the OAuth ``redirect_uri`` is built from. Set this for deploys
# behind reverse proxies that don't reliably forward
# the OAuth ``redirect_uri`` is built from. Its exact hostname is also
# trusted by the HTTP Host / WebSocket Origin guards and engages the
# auth gate when it is non-loopback, even if the backend binds to
# loopback. Set this for deploys behind reverse proxies that don't
# reliably forward
# ``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix``
# (manual nginx setups, on-prem ingresses, custom-domain Fly
# deploys without proper proxy headers). When set,
+13 -15
View File
@@ -11215,28 +11215,29 @@ def _dashboard_listening(host: str, port: int) -> bool:
def _maybe_setup_dashboard_auth_interactively(args) -> None:
"""Offer to configure dashboard auth when a non-loopback bind has none.
"""Offer to configure dashboard auth when the gate engages and none exists.
Called from ``cmd_dashboard`` just before ``start_server``. The auth
gate engages on every non-loopback bind (``--insecure`` is a no-op since
the June 2026 hardening), and ``start_server`` fails closed when no
the June 2026 hardening) and whenever ``dashboard.public_url`` declares a
non-loopback browser-facing hostname. ``start_server`` fails closed when no
``DashboardAuthProvider`` is registered. Rather than greet an interactive
operator with that hard error, prompt them to set up the bundled
username/password provider on the spot — or point them at
``hermes dashboard register`` for OAuth.
operator with that hard error, prompt them to set up the bundled password
provider on the spot — or point them at ``hermes dashboard register`` for
OAuth.
No-ops (so the existing fail-closed ``SystemExit`` remains the backstop)
when:
* the bind is loopback (gate never engages), or
* neither the bind nor configured public URL engages the gate, or
* a provider is already registered, or
* stdin/stdout isn't a TTY (Docker/s6, CI, piped ``--no-open`` runs).
"""
host = getattr(args, "host", "127.0.0.1") or "127.0.0.1"
try:
from hermes_cli.web_server import should_require_auth
if not should_require_auth(host):
return # loopback bind — gate never engages
from hermes_cli.web_server import should_require_dashboard_auth
if not should_require_dashboard_auth(host):
return # local-only bind and URL — gate does not engage
except Exception:
return # if we can't tell, defer to start_server's own gate
@@ -11253,13 +11254,10 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
return
print()
print(f"⚠ Dashboard authentication is required for this configuration ({host}).")
print(
f"⚠ The dashboard is binding to a non-loopback address ({host}) and "
f"needs an auth provider."
)
print(
" Non-loopback binds always require authentication "
"(--insecure no longer bypasses this)."
" Non-loopback binds and configured external dashboard.public_url "
"values require authentication (--insecure does not bypass this)."
)
print()
print(" How do you want to authenticate the dashboard?")
+127 -34
View File
@@ -649,6 +649,28 @@ _LOOPBACK_HOST_VALUES: frozenset = frozenset({
})
def _dashboard_public_hosts() -> frozenset[str]:
"""Return the exact hostname declared by ``dashboard.public_url``.
``public_url`` is already Hermes' canonical browser-facing URL behind a
reverse proxy. Reusing its validated hostname here keeps OAuth redirects,
HTTP Host validation, and WebSocket Origin validation on one source of
truth. Malformed or unset values fail closed as an empty set.
"""
from hermes_cli.dashboard_auth.prefix import resolve_public_url
public_url = resolve_public_url()
if not public_url:
return frozenset()
try:
hostname = urllib.parse.urlparse(public_url).hostname
except ValueError:
return frozenset()
if not hostname:
return frozenset()
return frozenset({hostname.lower()})
def should_require_auth(host: str, allow_public: bool = False) -> bool:
"""Return True iff the dashboard auth gate must be active.
@@ -671,34 +693,83 @@ def should_require_auth(host: str, allow_public: bool = False) -> bool:
return host not in _LOOPBACK_HOST_VALUES
def _is_accepted_host(host_header: str, bound_host: str) -> bool:
def should_require_dashboard_auth(
host: str,
trusted_public_hosts: Optional[frozenset[str]] = None,
) -> bool:
"""Return whether the dashboard auth gate must be active.
The browser-facing URL is part of the exposure boundary: a non-loopback
``dashboard.public_url`` requires authentication even when a reverse proxy
reaches a backend bound to loopback. Callers may pass the already-resolved
host set so startup and request validation use the same snapshot.
"""
if trusted_public_hosts is None:
trusted_public_hosts = _dashboard_public_hosts()
return should_require_auth(host) or any(
candidate not in _LOOPBACK_HOST_VALUES
for candidate in trusted_public_hosts
)
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:
return ""
if any(char in value for char in ('"', "'", "<", ">", " ", "\n", "\r", "\t")):
return ""
if "://" in value or any(char in value for char in ("/", "?", "#", "@")):
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)
"""
if not host_header:
host_only = _host_header_hostname(host_header)
if not host_only:
return False
# Strip port suffix. IPv6 addresses use bracket notation:
# [::1] — no port
# [::1]:9119 — with port
# Plain hosts/v4:
# localhost:9119
# 127.0.0.1:9119
h = host_header.strip()
if h.startswith("["):
# IPv6 bracketed — port (if any) follows "]:"
close = h.find("]")
if close != -1:
host_only = h[1:close] # strip brackets
else:
host_only = h.strip("[]")
else:
host_only = h.rsplit(":", 1)[0] if ":" in h else h
host_only = host_only.lower()
if host_only in trusted_public_hosts:
return True
# 0.0.0.0 bind means operator explicitly opted into all-interfaces
# (requires --insecure per web_server.start_server). No Host-layer
@@ -732,13 +803,18 @@ async def host_header_middleware(request: Request, call_next):
bound_host = getattr(app.state, "bound_host", None)
if bound_host:
host_header = request.headers.get("host", "")
if not _is_accepted_host(host_header, bound_host):
trusted_public_hosts = getattr(
app.state, "trusted_public_hosts", frozenset()
)
if not _is_accepted_host(
host_header, bound_host, trusted_public_hosts
):
return JSONResponse(
status_code=400,
content={
"detail": (
"Invalid Host header. Dashboard requests must use "
"the hostname the server was bound to."
"Invalid Host header. Dashboard requests must use the "
"bound hostname or the configured public hostname."
),
},
)
@@ -16122,8 +16198,14 @@ def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
if not bound_host:
return None
trusted_public_hosts = getattr(
app.state, "trusted_public_hosts", frozenset()
)
host_header = ws.headers.get("host", "")
if not _is_accepted_host(host_header, bound_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", "")
@@ -16140,7 +16222,9 @@ def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
if not parsed.netloc:
return f"origin_mismatch origin={origin} bound={bound_host}"
if not _is_accepted_host(parsed.netloc, bound_host):
if not _is_accepted_host(
parsed.netloc, bound_host, trusted_public_hosts
):
return f"origin_mismatch origin={origin} bound={bound_host}"
return None
@@ -19179,11 +19263,18 @@ def start_server(
except Exception as exc:
_log.debug("Nous auth keepalive did not start: %s", exc)
# Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token
# injection / WS-auth paths can branch on it consistently. Phase 3.5
# uses this to decide whether to refuse the bind, log the gate-on
# banner, and enable uvicorn proxy_headers.
app.state.auth_required = should_require_auth(host)
# A configured browser-facing URL is also the exact Host/Origin trust
# declaration for reverse-proxy deployments. Resolve it once at startup so
# request middleware never reloads config. Any non-loopback public hostname
# engages the auth gate even when the backend itself remains on loopback;
# otherwise the SPA's local session token would become remotely reachable.
app.state.trusted_public_hosts = _dashboard_public_hosts()
# Stash the auth-gate flag on app.state so middleware / SPA-token injection /
# WS-auth paths can branch on it consistently. It also decides whether to
# refuse startup, log the gate-on banner, and enable uvicorn proxy_headers.
app.state.auth_required = should_require_dashboard_auth(
host, app.state.trusted_public_hosts
)
# ``--insecure`` no longer disables the auth gate (June 2026 hardening:
# the hermes-0day MCP-persistence campaign abused unauthenticated public
@@ -19230,8 +19321,10 @@ def start_server(
"print(hash_password('your-password'))\")\n"
" • OAuth: run `hermes dashboard register` (Nous Portal) or "
"install a DashboardAuthProvider plugin.\n"
"There is no unauthenticated public-bind option — to keep it "
"local, bind 127.0.0.1 and tunnel in (SSH / Tailscale)."
"There is no unauthenticated public-dashboard option. For "
"local-only use, bind 127.0.0.1 and leave dashboard.public_url "
"unset; a configured external public URL requires auth even "
"when a local reverse proxy reaches a loopback backend."
)
# Hint when credentials exist but the bundled provider is blocked
# (#54489).
@@ -19270,9 +19363,9 @@ def start_server(
+ _fix_hint
)
raise SystemExit(
f"Refusing to bind dashboard to {host} — the auth gate "
f"engages on non-loopback binds, but no auth providers are "
f"registered.\n\n" + _fix_hint
f"Refusing to bind dashboard to {host} — the auth gate is "
f"required by the bind or configured public URL, but no auth "
f"providers are registered.\n\n" + _fix_hint
)
_log.info(
"Dashboard binding to %s with auth gate enabled. Providers: %s",
@@ -141,6 +141,17 @@ def _stub_uvicorn_run(monkeypatch):
return captured
def _restore_app_state_after_test(monkeypatch, *names):
"""Restore app.state attributes after start_server mutates them."""
for name in names:
monkeypatch.setattr(
web_server.app.state,
name,
getattr(web_server.app.state, name, None),
raising=False,
)
def test_start_server_loopback_sets_auth_required_false(monkeypatch):
"""Loopback bind: app.state.auth_required is False after start_server."""
_stub_uvicorn_run(monkeypatch)
@@ -222,3 +233,85 @@ def test_start_server_gate_with_provider_proceeds_and_sets_proxy_headers(monkeyp
clear_providers()
def test_public_url_aware_gate_requires_auth_for_loopback_proxy(monkeypatch):
"""The shared gate decision includes an external browser-facing URL."""
from hermes_cli.web_server import should_require_dashboard_auth
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.example.test:9443",
)
assert should_require_dashboard_auth("127.0.0.1") is True
def test_public_url_aware_gate_preserves_local_only_mode(monkeypatch):
"""A loopback browser-facing URL does not change local token mode."""
from hermes_cli.web_server import should_require_dashboard_auth
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"http://localhost:9119",
)
assert should_require_dashboard_auth("127.0.0.1") is False
def test_start_server_loopback_public_url_enables_gate(monkeypatch):
"""A declared external URL turns a loopback reverse proxy into gated mode."""
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.example.test:9443",
)
clear_providers()
register_provider(StubAuthProvider())
captured = _stub_uvicorn_run(monkeypatch)
_restore_app_state_after_test(
monkeypatch,
"auth_required",
"bound_host",
"bound_port",
"trusted_public_hosts",
)
try:
web_server.start_server(
host="127.0.0.1", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is True
assert web_server.app.state.trusted_public_hosts == frozenset(
{"dashboard.example.test"}
)
assert captured["kwargs"].get("host") == "127.0.0.1"
assert captured["kwargs"].get("proxy_headers") is True
finally:
clear_providers()
def test_start_server_loopback_public_url_without_provider_fails_closed(monkeypatch):
"""Trusting an external Host must never expose the loopback token mode."""
from hermes_cli.dashboard_auth import clear_providers
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.example.test:9443",
)
clear_providers()
_stub_uvicorn_run(monkeypatch)
_restore_app_state_after_test(
monkeypatch,
"auth_required",
"bound_host",
"bound_port",
"trusted_public_hosts",
)
with pytest.raises(SystemExit, match=r"no auth providers"):
web_server.start_server(
host="127.0.0.1", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is True
@@ -85,5 +85,30 @@ class TestUnifiedDashboardRouting:
assert execs == []
class TestInteractiveDashboardAuthSetup:
def test_loopback_proxy_public_url_offers_auth_setup(
self, main_mod, monkeypatch, capsys
):
"""A TTY operator is prompted when public_url gates a loopback bind."""
from hermes_cli.dashboard_auth import clear_providers
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.example.test:9443",
)
clear_providers()
monkeypatch.setattr(main_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(main_mod.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda _prompt: "3")
with pytest.raises(SystemExit) as exc:
main_mod._maybe_setup_dashboard_auth_interactively(_args())
assert exc.value.code == 1
output = capsys.readouterr().out
assert "configured external dashboard.public_url" in output
@@ -48,6 +48,34 @@ class TestHostHeaderValidator:
assert not _is_accepted_host("localhost", "my-server.corp.net")
def test_trusted_public_host_is_exact_match_only(self):
"""A declared proxy host is accepted without weakening rebinding checks."""
from hermes_cli.web_server import _is_accepted_host
trusted = frozenset({"dashboard.example.test"})
assert _is_accepted_host(
"dashboard.example.test:9443", "127.0.0.1", trusted
)
assert not _is_accepted_host(
"dashboard.example.test.evil.test", "127.0.0.1", trusted
)
assert not _is_accepted_host("evil.test", "127.0.0.1", trusted)
def test_malformed_host_authorities_fail_closed(self):
"""Ports, IPv6 brackets, and authority syntax must be unambiguous."""
from hermes_cli.web_server import _is_accepted_host
trusted = frozenset({"dashboard.example.test"})
for malformed in (
"http://dashboard.example.test:9443",
"dashboard.example.test:",
"dashboard.example.test:notaport",
"[::1].evil.test",
"[::1]:notaport",
"[localhost]",
):
assert not _is_accepted_host(malformed, "127.0.0.1", trusted)
class TestHostHeaderMiddleware:
"""End-to-end test via the FastAPI app — verify the middleware
@@ -75,6 +103,24 @@ class TestHostHeaderMiddleware:
del app.state.bound_host
def test_trusted_public_host_request_accepted(self):
"""A loopback backend may accept its declared reverse-proxy host."""
from fastapi.testclient import TestClient
from hermes_cli.web_server import app
app.state.bound_host = "127.0.0.1"
app.state.trusted_public_hosts = frozenset({"dashboard.example.test"})
try:
client = TestClient(app)
resp = client.get(
"/api/status",
headers={"Host": "dashboard.example.test:9443"},
)
assert resp.status_code != 400
finally:
del app.state.bound_host
del app.state.trusted_public_hosts
def test_no_bound_host_skips_validation(self):
"""If app.state.bound_host isn't set (e.g. running under test
infra without calling start_server), middleware must pass through
@@ -102,6 +148,7 @@ class TestWebSocketHostOriginGuard:
import hermes_cli.web_server as ws
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
@@ -125,6 +172,7 @@ class TestWebSocketHostOriginGuard:
import hermes_cli.web_server as ws
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
@@ -137,3 +185,59 @@ class TestWebSocketHostOriginGuard:
},
):
pass
def test_trusted_public_websocket_host_and_origin_are_accepted(self, monkeypatch):
from fastapi.testclient import TestClient
import hermes_cli.web_server as ws
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(
ws.app.state,
"trusted_public_hosts",
frozenset({"dashboard.example.test"}),
raising=False,
)
monkeypatch.setattr(ws.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with client.websocket_connect(
url,
headers={
"Host": "dashboard.example.test:9443",
"Origin": "https://dashboard.example.test:9443",
},
):
pass
def test_trusted_public_websocket_rejects_cross_site_origin(self, monkeypatch):
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import hermes_cli.web_server as ws
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(
ws.app.state,
"trusted_public_hosts",
frozenset({"dashboard.example.test"}),
raising=False,
)
monkeypatch.setattr(ws.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(
url,
headers={
"Host": "dashboard.example.test:9443",
"Origin": "https://evil.test",
},
):
pass
assert exc.value.code == 4403
@@ -549,7 +549,7 @@ Three dashboard-auth providers ship in the box. For a remote Hermes Desktop conn
| `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | HMAC key (32+ bytes, base64/hex/raw) signing the basic provider's stateless session tokens. Set explicitly so sessions survive restarts / span multiple workers; blank → random per-process (you'll be logged out on every restart). Overrides `dashboard.basic_auth.secret`. |
| `HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS` | Access-token lifetime for the basic provider (default 12h). Overrides `dashboard.basic_auth.session_ttl_seconds`. |
| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard, activating the Nous (`plugins/dashboard_auth/nous`) provider. Overrides `dashboard.oauth.client_id`. Provision it with `hermes dashboard register`. |
| `HERMES_DASHBOARD_PUBLIC_URL` | Complete public URL the dashboard is reached at, for OAuth callback construction behind reverse proxies. Overrides `dashboard.public_url`. |
| `HERMES_DASHBOARD_PUBLIC_URL` | Complete public URL the dashboard is reached at behind a reverse proxy. It controls OAuth callback construction, adds its exact hostname to the HTTP Host/WebSocket Origin guard, and requires the auth gate for non-loopback public hosts even when the backend binds to loopback. Overrides `dashboard.public_url`. |
| `HERMES_DASHBOARD_OIDC_ISSUER` | OIDC issuer URL for the bundled self-hosted OIDC provider (`plugins/dashboard_auth/self_hosted`). Required to activate it. Overrides `dashboard.oauth.self_hosted.issuer`. |
| `HERMES_DASHBOARD_OIDC_CLIENT_ID` | Public OIDC client id (authorization-code + PKCE) for the self-hosted OIDC provider. Required to activate it. Overrides `dashboard.oauth.self_hosted.client_id`. |
| `HERMES_DASHBOARD_OIDC_SCOPES` | Requested OIDC scopes for the self-hosted OIDC provider (default `openid profile email`). Overrides `dashboard.oauth.self_hosted.scopes`. |
@@ -939,6 +939,35 @@ dashboard:
When set, the OAuth callback URL becomes `<public_url>/auth/callback` verbatim — `X-Forwarded-Prefix` is ignored on that code path because the operator has explicitly declared the public URL. This is intentional: stacking the prefix on top would double-prefix the common case where the prefix is already baked into `public_url`.
The hostname in `public_url` is also accepted as an **exact** HTTP `Host` and
WebSocket `Origin` value. This supports a reverse proxy that preserves the
browser-facing hostname while forwarding to a dashboard bound to
`127.0.0.1`. Wildcards and suffix matches are not allowed, so an attacker host
such as `dashboard.example.com.evil.test` remains rejected by the DNS-rebinding
guard.
Declaring a non-loopback `public_url` always engages the dashboard auth gate,
even when the backend binds to loopback. Configure a password or OAuth provider
first; without one, Hermes fails closed at startup. This prevents the local SPA
session token from becoming a remote authentication mechanism through the
proxy. Uvicorn also enables trusted proxy-header processing in this mode so a
local TLS terminator can supply `X-Forwarded-Proto: https` for secure cookies.
```bash
# Backend remains reachable only on this machine.
hermes dashboard --host 127.0.0.1 --port 9119 --no-open
```
Point the TLS reverse proxy at `http://127.0.0.1:9119` and use
the same external origin in `dashboard.public_url`.
Tailscale Serve is one example of this deployment shape: it can terminate
tailnet-only HTTPS on a `https://<machine>.<tailnet>.ts.net` hostname while
proxying to the loopback dashboard. Use that exact HTTPS origin as
`dashboard.public_url`. It is still treated as a non-loopback browser-facing
origin and therefore requires a dashboard auth provider; this does not require
making the service reachable from the public internet.
Same precedence as the other dashboard settings — env wins over `config.yaml`:
| Surface | Override path | When to use |