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:
@@ -735,18 +735,29 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
|
||||
wrong ?token= query param must be rejected with policy-violation."""
|
||||
"""Loopback mode: a missing or wrong ?token= must be rejected with
|
||||
policy-violation; the correct token is accepted. The kanban WS now
|
||||
delegates to web_server._ws_auth_ok, so we stub that with the real
|
||||
loopback-token semantics (auth_required False → constant-time token
|
||||
compare)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
# Stub web_server so _check_ws_token has a token to compare against.
|
||||
# Stub web_server with a loopback-mode _ws_auth_ok (auth_required False →
|
||||
# accept only the correct ?token=). Mirrors the real gate's loopback path.
|
||||
import hermes_cli
|
||||
import types
|
||||
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
||||
|
||||
def _fake_ws_auth_ok(ws):
|
||||
return ws.query_params.get("token", "") == "secret-xyz"
|
||||
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=_fake_ws_auth_ok,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
@@ -774,6 +785,51 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||
assert ws is not None # handshake succeeded
|
||||
|
||||
|
||||
def test_ws_events_accepts_gated_ticket(tmp_path, monkeypatch):
|
||||
"""Gated OAuth mode: the WS must accept a single-use ?ticket= (and reject
|
||||
a bare ?token=, even one matching _SESSION_TOKEN). This is the regression
|
||||
for the hosted-dashboard bug where the kanban live-events WS 1008'd on
|
||||
every gated deployment because its bespoke check only knew _SESSION_TOKEN.
|
||||
We stub _ws_auth_ok with the real gated semantics (ticket-only)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
import hermes_cli
|
||||
import types
|
||||
|
||||
def _fake_ws_auth_ok(ws):
|
||||
# Gated mode: only a known ticket is accepted; token path rejected.
|
||||
return ws.query_params.get("ticket", "") == "good-ticket"
|
||||
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=_fake_ws_auth_ok,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
c = TestClient(app)
|
||||
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# Legacy token is rejected in gated mode, even if it's the real one.
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with c.websocket_connect("/api/plugins/kanban/events?token=secret-xyz"):
|
||||
pass
|
||||
assert exc.value.code == 1008
|
||||
|
||||
# A valid ticket is accepted.
|
||||
with c.websocket_connect(
|
||||
"/api/plugins/kanban/events?ticket=good-ticket"
|
||||
) as ws:
|
||||
assert ws is not None
|
||||
|
||||
|
||||
def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp_path, monkeypatch):
|
||||
"""The event stream must honor ``board=default`` even when the global
|
||||
current-board pointer targets a different board.
|
||||
@@ -806,7 +862,10 @@ def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp
|
||||
import hermes_cli
|
||||
import types
|
||||
|
||||
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=lambda ws: ws.query_params.get("token", "") == "secret-xyz",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
@@ -842,10 +901,10 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
# Short-circuit the token check — this test is about the cancellation
|
||||
# Short-circuit the auth check — this test is about the cancellation
|
||||
# path, not auth.
|
||||
import plugins.kanban.dashboard.plugin_api as pa
|
||||
monkeypatch.setattr(pa, "_check_ws_token", lambda t: True)
|
||||
monkeypatch.setattr(pa, "_ws_upgrade_authorized", lambda ws: True)
|
||||
|
||||
class _FakeWS:
|
||||
def __init__(self):
|
||||
|
||||
Reference in New Issue
Block a user