fix(dashboard): sanction plugin WS/upload auth via SDK helpers (gated mode)

Dashboard plugins (kanban, hermes-achievements) read
window.__HERMES_SESSION_TOKEN__ directly and hand-assembled WebSocket
URLs with ?token=. That works in loopback/--insecure mode but is
rejected on OAuth-gated deployments, where the session token is absent
and _ws_auth_ok only accepts single-use ?ticket= auth. The result was
401s on plugin REST calls and 1008/403 on the kanban live-events WS
whenever the dashboard ran behind OAuth (e.g. hosted Fly agents).

Make the plugin SDK the single sanctioned auth surface:

- web/src/lib/api.ts: add authedFetch() (raw Response for FormData
  uploads / blob downloads, token-or-cookie auth, no throw / no 401
  redirect) and buildWsUrl() (assembles a ws(s):// URL with the correct
  auth param for the active mode — fresh single-use ticket in gated
  mode, token in loopback).
- web/src/plugins/registry.ts: expose authedFetch, buildWsUrl,
  buildWsAuthParam, and sdkVersion on window.__HERMES_PLUGIN_SDK__;
  add SDK_CONTRACT_VERSION.
- web/src/plugins/sdk.d.ts: hand-authored typed contract for the
  plugin SDK + registry globals (single source of truth for the
  Window declarations).
- plugins/kanban + hermes-achievements dist bundles: stop reading the
  session token directly; route uploads/downloads through
  SDK.authedFetch and the live-events WS through SDK.buildWsUrl.
- plugins/kanban plugin_api.py: _ws_upgrade_authorized() delegates the
  /events WS upgrade to the canonical web_server._ws_auth_ok gate, so
  it transparently accepts loopback token / gated ticket / internal
  credential and can never drift from core auth again.
- tests: guard test asserting no plugin dist reads
  __HERMES_SESSION_TOKEN__ directly; kanban gated-ticket WS test.

Verified live on a gated staging Fly agent: kanban /events upgrades
101 with a minted ticket (ticket_len=43, ws_auth_ok=True) where the
old code got 403.
This commit is contained in:
Ben
2026-06-03 16:59:36 -07:00
committed by Teknium
parent 1c88360fed
commit a6e47314f9
8 changed files with 500 additions and 94 deletions
+27 -16
View File
@@ -36,7 +36,6 @@ the port.
from __future__ import annotations
import asyncio
import hmac
import json
import logging
import os
@@ -63,15 +62,29 @@ router = APIRouter()
# existing plugin-bypass; this is documented above).
# ---------------------------------------------------------------------------
def _check_ws_token(provided: Optional[str]) -> bool:
"""Constant-time compare against the dashboard session token.
def _ws_upgrade_authorized(ws: "WebSocket") -> bool:
"""Authorize a WebSocket upgrade by delegating to the dashboard's canonical
WS auth gate (``hermes_cli.web_server._ws_auth_ok``).
Delegating (rather than re-implementing a ``_SESSION_TOKEN``-only check)
means this endpoint transparently accepts whatever the core gate accepts
in each mode:
* loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>``
* gated OAuth: single-use ``?ticket=`` (the browser SDK's
``buildWsUrl`` mints one per connect)
* server-internal: the process-lifetime ``?internal=`` credential
The previous bespoke check only understood ``_SESSION_TOKEN``, so the
kanban live-events WS was rejected on every OAuth-gated deployment even
though the rest of the dashboard worked. Routing through the shared gate
also means this can never drift from core auth again.
Imported lazily so the plugin still loads in test contexts where the
dashboard web_server module isn't importable (e.g. the bare-FastAPI
test harness).
dashboard ``web_server`` module isn't importable (e.g. the bare-FastAPI
test harness); there we accept so the tail loop stays testable, matching
the prior behaviour.
"""
if not provided:
return False
try:
from hermes_cli import web_server as _ws
except Exception:
@@ -79,10 +92,7 @@ def _check_ws_token(provided: Optional[str]) -> bool:
# testable; in production the dashboard module always imports
# cleanly because it's the caller.
return True
expected = getattr(_ws, "_SESSION_TOKEN", None)
if not expected:
return True
return hmac.compare_digest(str(provided), str(expected))
return bool(_ws._ws_auth_ok(ws))
def _resolve_board(board: Optional[str]) -> Optional[str]:
@@ -2375,11 +2385,12 @@ def set_orchestration_settings(payload: OrchestrationSettingsBody):
@router.websocket("/events")
async def stream_events(ws: WebSocket):
# Enforce the dashboard session token as a query param — browsers can't
# set Authorization on a WS upgrade. This matches how the PTY bridge
# authenticates in hermes_cli/web_server.py.
token = ws.query_params.get("token")
if not _check_ws_token(token):
# Authorize the upgrade via the dashboard's canonical WS gate so the
# correct credential is accepted in every mode (loopback token / gated
# single-use ticket / server-internal credential). Browsers can't set
# Authorization on a WS upgrade, so the credential rides in the query
# string — the browser SDK's buildWsUrl() assembles it.
if not _ws_upgrade_authorized(ws):
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
return
await ws.accept()