diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 996ec308ee..2545df5935 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 73fc5135b2..2b12dfdcf8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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?") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 432a72fd5d..cdbf43e5c0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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", diff --git a/tests/hermes_cli/test_dashboard_auth_gate.py b/tests/hermes_cli/test_dashboard_auth_gate.py index c018530949..c084ebbeb8 100644 --- a/tests/hermes_cli/test_dashboard_auth_gate.py +++ b/tests/hermes_cli/test_dashboard_auth_gate.py @@ -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 + + diff --git a/tests/hermes_cli/test_dashboard_unified_launch.py b/tests/hermes_cli/test_dashboard_unified_launch.py index c4bc58b012..3bb122136f 100644 --- a/tests/hermes_cli/test_dashboard_unified_launch.py +++ b/tests/hermes_cli/test_dashboard_unified_launch.py @@ -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 + + diff --git a/tests/hermes_cli/test_web_server_host_header.py b/tests/hermes_cli/test_web_server_host_header.py index 83a23f2a27..41b8df35db 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -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 diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index e35fbcaf0a..6d77c33a2a 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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`. | diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 50a11f2c28..9cc93d2cc8 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -939,6 +939,35 @@ dashboard: When set, the OAuth callback URL becomes `/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://..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 |