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``).
|
# Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``).
|
||||||
# When set, this is the complete authority — scheme + host +
|
# When set, this is the complete authority — scheme + host +
|
||||||
# optional path prefix (e.g. ``https://example.com/hermes``) —
|
# optional path prefix (e.g. ``https://example.com/hermes``) —
|
||||||
# the OAuth ``redirect_uri`` is built from. Set this for deploys
|
# the OAuth ``redirect_uri`` is built from. Its exact hostname is also
|
||||||
# behind reverse proxies that don't reliably forward
|
# 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``
|
# ``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix``
|
||||||
# (manual nginx setups, on-prem ingresses, custom-domain Fly
|
# (manual nginx setups, on-prem ingresses, custom-domain Fly
|
||||||
# deploys without proper proxy headers). When set,
|
# 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:
|
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
|
Called from ``cmd_dashboard`` just before ``start_server``. The auth
|
||||||
gate engages on every non-loopback bind (``--insecure`` is a no-op since
|
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
|
``DashboardAuthProvider`` is registered. Rather than greet an interactive
|
||||||
operator with that hard error, prompt them to set up the bundled
|
operator with that hard error, prompt them to set up the bundled password
|
||||||
username/password provider on the spot — or point them at
|
provider on the spot — or point them at ``hermes dashboard register`` for
|
||||||
``hermes dashboard register`` for OAuth.
|
OAuth.
|
||||||
|
|
||||||
No-ops (so the existing fail-closed ``SystemExit`` remains the backstop)
|
No-ops (so the existing fail-closed ``SystemExit`` remains the backstop)
|
||||||
when:
|
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
|
* a provider is already registered, or
|
||||||
* stdin/stdout isn't a TTY (Docker/s6, CI, piped ``--no-open`` runs).
|
* 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"
|
host = getattr(args, "host", "127.0.0.1") or "127.0.0.1"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from hermes_cli.web_server import should_require_auth
|
from hermes_cli.web_server import should_require_dashboard_auth
|
||||||
if not should_require_auth(host):
|
if not should_require_dashboard_auth(host):
|
||||||
return # loopback bind — gate never engages
|
return # local-only bind and URL — gate does not engage
|
||||||
except Exception:
|
except Exception:
|
||||||
return # if we can't tell, defer to start_server's own gate
|
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
|
return
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
print(f"⚠ Dashboard authentication is required for this configuration ({host}).")
|
||||||
print(
|
print(
|
||||||
f"⚠ The dashboard is binding to a non-loopback address ({host}) and "
|
" Non-loopback binds and configured external dashboard.public_url "
|
||||||
f"needs an auth provider."
|
"values require authentication (--insecure does not bypass this)."
|
||||||
)
|
|
||||||
print(
|
|
||||||
" Non-loopback binds always require authentication "
|
|
||||||
"(--insecure no longer bypasses this)."
|
|
||||||
)
|
)
|
||||||
print()
|
print()
|
||||||
print(" How do you want to authenticate the dashboard?")
|
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:
|
def should_require_auth(host: str, allow_public: bool = False) -> bool:
|
||||||
"""Return True iff the dashboard auth gate must be active.
|
"""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
|
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.
|
"""True if the Host header targets the interface we bound to.
|
||||||
|
|
||||||
Accepts:
|
Accepts:
|
||||||
- Exact bound host (with or without port suffix)
|
- Exact bound host (with or without port suffix)
|
||||||
- Loopback aliases when bound to loopback
|
- 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,
|
- Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback,
|
||||||
no protection possible at this layer)
|
no protection possible at this layer)
|
||||||
"""
|
"""
|
||||||
if not host_header:
|
host_only = _host_header_hostname(host_header)
|
||||||
|
if not host_only:
|
||||||
return False
|
return False
|
||||||
# Strip port suffix. IPv6 addresses use bracket notation:
|
|
||||||
# [::1] — no port
|
if host_only in trusted_public_hosts:
|
||||||
# [::1]:9119 — with port
|
return True
|
||||||
# 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()
|
|
||||||
|
|
||||||
# 0.0.0.0 bind means operator explicitly opted into all-interfaces
|
# 0.0.0.0 bind means operator explicitly opted into all-interfaces
|
||||||
# (requires --insecure per web_server.start_server). No Host-layer
|
# (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)
|
bound_host = getattr(app.state, "bound_host", None)
|
||||||
if bound_host:
|
if bound_host:
|
||||||
host_header = request.headers.get("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(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
content={
|
content={
|
||||||
"detail": (
|
"detail": (
|
||||||
"Invalid Host header. Dashboard requests must use "
|
"Invalid Host header. Dashboard requests must use the "
|
||||||
"the hostname the server was bound to."
|
"bound hostname or the configured public hostname."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -16122,8 +16198,14 @@ def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
|
|||||||
if not bound_host:
|
if not bound_host:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
trusted_public_hosts = getattr(
|
||||||
|
app.state, "trusted_public_hosts", frozenset()
|
||||||
|
)
|
||||||
|
|
||||||
host_header = ws.headers.get("host", "")
|
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}"
|
return f"host_mismatch host={host_header or '?'} bound={bound_host}"
|
||||||
|
|
||||||
origin = ws.headers.get("origin", "")
|
origin = ws.headers.get("origin", "")
|
||||||
@@ -16140,7 +16222,9 @@ def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
|
|||||||
if not parsed.netloc:
|
if not parsed.netloc:
|
||||||
return f"origin_mismatch origin={origin} bound={bound_host}"
|
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 f"origin_mismatch origin={origin} bound={bound_host}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -19179,11 +19263,18 @@ def start_server(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.debug("Nous auth keepalive did not start: %s", 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
|
# A configured browser-facing URL is also the exact Host/Origin trust
|
||||||
# injection / WS-auth paths can branch on it consistently. Phase 3.5
|
# declaration for reverse-proxy deployments. Resolve it once at startup so
|
||||||
# uses this to decide whether to refuse the bind, log the gate-on
|
# request middleware never reloads config. Any non-loopback public hostname
|
||||||
# banner, and enable uvicorn proxy_headers.
|
# engages the auth gate even when the backend itself remains on loopback;
|
||||||
app.state.auth_required = should_require_auth(host)
|
# 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:
|
# ``--insecure`` no longer disables the auth gate (June 2026 hardening:
|
||||||
# the hermes-0day MCP-persistence campaign abused unauthenticated public
|
# the hermes-0day MCP-persistence campaign abused unauthenticated public
|
||||||
@@ -19230,8 +19321,10 @@ def start_server(
|
|||||||
"print(hash_password('your-password'))\")\n"
|
"print(hash_password('your-password'))\")\n"
|
||||||
" • OAuth: run `hermes dashboard register` (Nous Portal) or "
|
" • OAuth: run `hermes dashboard register` (Nous Portal) or "
|
||||||
"install a DashboardAuthProvider plugin.\n"
|
"install a DashboardAuthProvider plugin.\n"
|
||||||
"There is no unauthenticated public-bind option — to keep it "
|
"There is no unauthenticated public-dashboard option. For "
|
||||||
"local, bind 127.0.0.1 and tunnel in (SSH / Tailscale)."
|
"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
|
# Hint when credentials exist but the bundled provider is blocked
|
||||||
# (#54489).
|
# (#54489).
|
||||||
@@ -19270,9 +19363,9 @@ def start_server(
|
|||||||
+ _fix_hint
|
+ _fix_hint
|
||||||
)
|
)
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"Refusing to bind dashboard to {host} — the auth gate "
|
f"Refusing to bind dashboard to {host} — the auth gate is "
|
||||||
f"engages on non-loopback binds, but no auth providers are "
|
f"required by the bind or configured public URL, but no auth "
|
||||||
f"registered.\n\n" + _fix_hint
|
f"providers are registered.\n\n" + _fix_hint
|
||||||
)
|
)
|
||||||
_log.info(
|
_log.info(
|
||||||
"Dashboard binding to %s with auth gate enabled. Providers: %s",
|
"Dashboard binding to %s with auth gate enabled. Providers: %s",
|
||||||
|
|||||||
@@ -141,6 +141,17 @@ def _stub_uvicorn_run(monkeypatch):
|
|||||||
return captured
|
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):
|
def test_start_server_loopback_sets_auth_required_false(monkeypatch):
|
||||||
"""Loopback bind: app.state.auth_required is False after start_server."""
|
"""Loopback bind: app.state.auth_required is False after start_server."""
|
||||||
_stub_uvicorn_run(monkeypatch)
|
_stub_uvicorn_run(monkeypatch)
|
||||||
@@ -222,3 +233,85 @@ def test_start_server_gate_with_provider_proceeds_and_sets_proxy_headers(monkeyp
|
|||||||
clear_providers()
|
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 == []
|
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")
|
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:
|
class TestHostHeaderMiddleware:
|
||||||
"""End-to-end test via the FastAPI app — verify the middleware
|
"""End-to-end test via the FastAPI app — verify the middleware
|
||||||
@@ -75,6 +103,24 @@ class TestHostHeaderMiddleware:
|
|||||||
del app.state.bound_host
|
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):
|
def test_no_bound_host_skips_validation(self):
|
||||||
"""If app.state.bound_host isn't set (e.g. running under test
|
"""If app.state.bound_host isn't set (e.g. running under test
|
||||||
infra without calling start_server), middleware must pass through
|
infra without calling start_server), middleware must pass through
|
||||||
@@ -102,6 +148,7 @@ class TestWebSocketHostOriginGuard:
|
|||||||
import hermes_cli.web_server as ws
|
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, "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)
|
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||||
|
|
||||||
client = TestClient(ws.app)
|
client = TestClient(ws.app)
|
||||||
@@ -125,6 +172,7 @@ class TestWebSocketHostOriginGuard:
|
|||||||
import hermes_cli.web_server as ws
|
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, "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)
|
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||||
|
|
||||||
client = TestClient(ws.app)
|
client = TestClient(ws.app)
|
||||||
@@ -137,3 +185,59 @@ class TestWebSocketHostOriginGuard:
|
|||||||
},
|
},
|
||||||
):
|
):
|
||||||
pass
|
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_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_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_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_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_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`. |
|
| `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`.
|
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`:
|
Same precedence as the other dashboard settings — env wins over `config.yaml`:
|
||||||
|
|
||||||
| Surface | Override path | When to use |
|
| Surface | Override path | When to use |
|
||||||
|
|||||||
Reference in New Issue
Block a user