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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user