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:
@@ -0,0 +1 @@
|
||||
Agi-Asi
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user