One listener cannot serve two threat models¶
What happened¶
src/dashboard/server.js bound a single port and served two things on it:
| written against | endpoints |
|---|---|
| "an untrusted caller reaches this" | POST /api/chat (token-gated), POST /webhook/{source} (HMAC-verified) |
| "only an operator inside the cluster reaches this" | GET /, /api/state, /api/usage, /api/admin/session-inits, the WebSocket |
The second group has no authentication and was never meant to. That was a reasonable design for a listener nothing routes to.
Then rig-e.dashecorp.com was mapped whole-host through the Cloudflare tunnel with no
Access app, on this stated justification:
the origin's own token is the gate (POST /api/chat fails closed on CHAT_API_TOKEN; the cockpit worker injects it), so no Access app.
That sentence is true about one endpoint and was used to license the whole hostname. So
"cluster-internal" quietly became "the public internet", and GET /api/state returned the
log ring, every tool call, live session ids, cumulative USD spend, the character bio and the
connected MCP server names to an anonymous curl. Verified 2026-09-11.
Why the first fix was not enough¶
#697/#698 found that the WebSocket was worse than the HTTP half: bound with no
options.path, ws 8.x accepted an Upgrade on every path, and because Node routes an
Upgrade to the 'upgrade' event rather than 'request', the CHAT_API_TOKEN check was not
on that code path at all. wss://rig-e.dashecorp.com/api/chat returned 101 and then
streamed the log ring live. Scoping the socket to /ws closed that path.
It did not close the exposure. wss://<host>/ws served the same payload to anyone who could
reach the routed port, and GET /api/state was untouched. Scoping the leaking path is not
the same as closing the leak — the path was the symptom; the listener was the bug.
The decision¶
Two listeners, and which one an endpoint lands on is a security decision.
| port | serves | |
|---|---|---|
| Agent HTTP (routed) | PUBLIC_PORT, default 3000 |
POST /api/chat, POST /webhook/{source}, GET /api/health |
| Dashboard (not routed) | DASHBOARD_PORT, default 3001 |
everything else, plus the /ws WebSocket |
Three properties carry the weight:
The routed listener is default-deny. PUBLIC_ROUTES is the complete allowlist; anything
else 404s without reaching a handler. This is the part that prevents a recurrence. The bug was
not a mistake in any one endpoint — it was endpoints inheriting a listener's reachability by
being written on it. Now an endpoint becomes public only by being added to that table.
3000 stays the routed port. The tunnel ingress, the cockpit worker and the chart's Services already point there, so the hole closes with no infrastructure change, no client change, and no window where chat is down or the leak is still open. The alternative — move chat to 3001 — would have needed an OpenTofu change, a chart change and a Service change to land in lockstep, and any lag in that sequence is either an outage or a continued exposure.
A port collision fails closed on the dashboard side. If both resolve to the same non-zero port, the dashboard is not bound at all. The outcome that must never happen is the dashboard winning the routed socket, which is the original exposure restored by a typo.
DASHBOARD_ENABLED=false now disables only the dashboard. Before the split it took
/api/chat with it, which is why it was rejected as an interim mitigation in
dashecorp/infra#447; it is a real one now.
What to take from this¶
- An auth check on an HTTP handler does not protect a WebSocket on the same server.
- A per-endpoint security property cannot license a whole-host mapping. If the justification names one route, the mapping has to name one route too.
- Reachability is inherited, and that is the dangerous direction. An endpoint written for a private listener is public the moment anything routes to that listener, and nothing in the endpoint's own code changes to say so. Default-deny on the routed surface is what turns that silent inheritance into an explicit decision.
- The chart's
tunnel.enabled: truedeploys acloudflaredwith its own token whose routing lives Cloudflare-side, outside this repo and outside OpenTofu. Nothing in the chart can stop a hostname being pointed at the dashboard port. The split is the defence; the chart is not.
The asymmetry worth remembering¶
src/chat.js deliberately returns a generic 502 {error: 'the agent provider failed'}
instead of the provider's own error, with this comment:
provider errors routinely carry env var names, credential paths and prompt fragments, and this response crosses a network boundary to a phone.
The dashboard then published those same provider errors, unauthenticated, on the public internet. One boundary in this codebase was drawn with real care and the other was wide open, by a team that understood the risk perfectly well. The gap was not knowledge. It was that nobody re-examined the dashboard's threat model when a hostname was pointed at its port.
Source¶
| File | Role |
|---|---|
src/dashboard/server.js |
PUBLIC_ROUTES, classifyPublicRoute, resolveListenerPorts — the policy |
src/dashboard/server.test.js |
The allowlist from both sides, and the collision guard |
docs/2026-09-11-dashboard-ws-path-scope.md |
The WebSocket half (#697/#698) |
This lands in two PRs. The policy above is the first and is inert: exported and
fully tested, but createDashboard does not consult it yet. The adapter is the
second — the two listeners, the WebSocket move, docs/dashboard.md and the
operator-facing env vars. The split is deliberate: which routes may be reached from
outside is a decision worth reviewing on its own, separately from the plumbing that
enforces it.
Related: dashecorp/infra#447 (the exposure), dashecorp/infra#448 (the conductor on a
NodePort with no auth), #701 (the chart never renders these env vars).