fix(dashboard): don't gate Desktop-owned loopback backends on public_url

A non-loopback dashboard.public_url engaged the ticket-only auth gate for
EVERY hermes serve on the machine — including the private loopback
backends the Desktop app spawns for itself (HERMES_DESKTOP=1). Those
backends authenticate with the per-spawn session token, which the gated
WS path refuses outright, so Desktop failed to boot with:

  Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws)
  rejected the session token.

The public_url describes a DIFFERENT deployment: the actual public
dashboard is a separate process on a non-loopback bind whose own startup
keeps its gate. Exempting Desktop-owned loopback backends therefore never
opens the public surface.

Exemption requires ALL of: loopback bind, HERMES_DESKTOP=1 (set by every
Desktop spawn path, local and SSH), and an operator-minted credential
(HERMES_DASHBOARD_SESSION_TOKEN, SSH session token, or owner nonce).
Non-Desktop serves and non-loopback binds keep the exact previous
behaviour — verified by regression tests on both sides of the boundary.

Fixes #96490
This commit is contained in:
Agi-Asi
2026-08-28 04:06:13 +00:00
committed by Teknium
parent c7f04c9971
commit 54ee290bcb
3 changed files with 117 additions and 3 deletions
@@ -0,0 +1 @@
Agi-Asi
+53 -3
View File
@@ -836,6 +836,43 @@ def should_require_dashboard_auth(
)
def _desktop_loopback_auth_exempt(
host: str,
ssh_session_token: Optional[str] = None,
ssh_owner_nonce: Optional[str] = None,
) -> bool:
"""True for a Desktop-owned loopback backend (#96490).
A non-loopback ``dashboard.public_url`` engages the ticket-only auth gate
for EVERY ``hermes serve`` on the machine — including the private loopback
backends the Desktop app spawns for itself. Those backends authenticate
with the per-spawn session token (injected via
``HERMES_DASHBOARD_SESSION_TOKEN`` for local spawns, ``--ssh-session-token
-file``/``--ssh-owner-nonce`` for Desktop SSH), which the gate's WS path
refuses outright — Desktop could not boot with a ``public_url`` configured.
The public_url describes a DIFFERENT deployment: the actual public
dashboard is a separate process on a non-loopback bind, whose own startup
computes ``should_require_dashboard_auth`` from its host and stays gated.
Exempting this process therefore never opens the public surface.
Exemption requires ALL of: loopback bind, ``HERMES_DESKTOP=1`` (set by
every Desktop spawn path — local and SSH), and an operator-minted
credential (env token, SSH session token, or owner nonce). A plain
``hermes serve`` with ``HERMES_DESKTOP=1`` exported but no credential is
NOT exempt.
"""
if host not in _LOOPBACK_HOST_VALUES:
return False
if os.environ.get("HERMES_DESKTOP") != "1":
return False
return bool(
os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN")
or ssh_session_token
or ssh_owner_nonce
)
def _host_header_hostname(host_header: str) -> str:
"""Return a normalized hostname from a valid HTTP Host authority.
@@ -19566,9 +19603,22 @@ def start_server(
# 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
)
if _desktop_loopback_auth_exempt(host, ssh_session_token, ssh_owner_nonce):
# A configured dashboard.public_url describes the operator's PUBLIC
# deployment, not this private Desktop-owned loopback backend (#96490).
# Desktop authenticates with the per-spawn session token; forcing the
# ticket-only gate here broke every Desktop boot while the actual
# public dashboard — a separate non-loopback process — stayed gated.
app.state.auth_required = should_require_auth(host)
_log.info(
"Desktop-owned loopback backend: dashboard.public_url does not "
"engage the ticket gate for this process; the public deployment "
"keeps its own gate.",
)
else:
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
+63
View File
@@ -3044,6 +3044,69 @@ class TestNewEndpoints:
mock_generate.assert_not_called()
assert any(tool["tool"] == "read_file" for tool in resp.json()["tools"])
# ---------------------------------------------------------------------------
# Desktop-owned loopback backends are not gated by dashboard.public_url (#96490)
# ---------------------------------------------------------------------------
class TestDesktopLoopbackAuthExemption:
"""``_desktop_loopback_auth_exempt`` decides the #96490 exemption."""
def test_exempt_with_desktop_env_and_session_token_on_loopback(self, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-minted")
assert web_server._desktop_loopback_auth_exempt("127.0.0.1") is True
assert web_server._desktop_loopback_auth_exempt("::1") is True
def test_exempt_via_ssh_spawn_credentials_without_env_token(self, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
assert web_server._desktop_loopback_auth_exempt(
"127.0.0.1", ssh_session_token="tok"
)
assert web_server._desktop_loopback_auth_exempt(
"127.0.0.1", ssh_owner_nonce="nonce"
)
def test_not_exempt_without_desktop_env(self, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "tok")
assert web_server._desktop_loopback_auth_exempt("127.0.0.1") is False
def test_not_exempt_without_any_credential(self, monkeypatch):
import hermes_cli.web_server as web_server
# HERMES_DESKTOP=1 alone is not enough: a plain serve with the env var
# exported must stay gated.
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
assert web_server._desktop_loopback_auth_exempt("127.0.0.1") is False
def test_not_exempt_on_non_loopback_bind(self, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "tok")
assert web_server._desktop_loopback_auth_exempt("0.0.0.0") is False
assert web_server._desktop_loopback_auth_exempt("192.168.1.10") is False
def test_public_url_engages_gate_for_non_desktop_loopback(self, monkeypatch):
import hermes_cli.web_server as web_server
# Sanity: the base behaviour is untouched — a non-Desktop loopback
# serve with a public_url configured stays ticket-gated.
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
assert web_server.should_require_dashboard_auth(
"127.0.0.1", frozenset({"dash.example.com"})
) is True
# ---------------------------------------------------------------------------
# Model context length: normalize/denormalize + /api/model/info
# ---------------------------------------------------------------------------