fix(dashboard): secure loopback public URL proxy mode
This commit is contained in:
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user