Station: verified on station-v0.96 (current). Files: workbench/studio_server.py::_require_session (~6545), workbench/routes/dispatch_misc.py::_handle_freeze (primary proof), workbench/routes/schedules.py::_require_human (88-99). Class: incorrect authorization — the route authenticates the caller but does not authorize by trust channel (CWE-863).
The intended model (stated by the code itself)
_require_session() authenticates FOUR distinct token channels minted at boot and sets self._session_channel to which one matched: session (the browser UI token, embedded in served pages), mcp (the Claude-Desktop sidecar token), cli (the railcall CLI token), and scheduler (the clock daemon token). The docstrings are explicit that the last three are low-trust and must not reach governance: the scheduler token "authenticates /api/schedules/tick and nothing more … so a daemon credential can never mint the standing grants it exists to execute"; the mcp sidecar "can never opt itself into payloads no matter what it sends." The invariant is enforced in several places by inspecting _session_channel after authentication — e.g. schedules._require_human 403s the scheduler channel, and team.py/_handle_operator_set/_handle_identity_link 403 the scheduler/mcp channels.
The break — a family of governance mutators authenticate but never check the channel
Several routes that change security-critical state call only _require_session() (any of the 4 channels) and never inspect _session_channel, so a caller holding the low-trust scheduler/mcp/cli token passes. The maximal instance is the global kill-switch:
dispatch_misc._handle_freeze (verbatim):
def _handle_freeze(body, handler):
# GLOBAL KILL-SWITCH — freeze/unfreeze all live sends; unfreeze needs typed confirm
if not handler._require_session():
return
want = bool((body or {}).get("frozen"))
if not want and str((body or {}).get("confirm") or "") != "UNFREEZE":
return handler._send(200, {"ok": False, "error": "unfreeze requires confirm:'UNFREEZE'", ...})
fs = set_freeze(want, ...) # <-- lifts the freeze for ANY authenticated channel
No _session_channel check; _require_approve (dual-control) is not called and is OFF by default; the only extra "gate" is the plaintext string confirm:"UNFREEZE" in the request body — not a second credential. Lifting the freeze removes the barrier that was halting every live send (each remaining send is still subject to the policy/plan/credential/team gates) and defeats the operator's single emergency stop.
Proof (real HTTP request through the live do_POST router, v0.96)
The station's own studio_server was started (port 8799), a scheduler_session_token written where _require_session reads it, and the emergency freeze engaged. Then a genuine HTTP POST to /api/freeze carrying only the scheduler token (and a loopback Origin, which the daemon/sidecar trivially sets) was issued — exercising the full do_POST → CSRF _guard → route-dispatch → _handle_freeze path:
POST /api/freeze {"frozen":true,...} (engage) -> {"ok":true,"frozen":true,"reason":"operator emergency stop"}
GET /api/freeze -> frozen:true
POST /api/freeze {"frozen":false,"confirm":"UNFREEZE"} X-RailCall-Session: <scheduler token>
-> {"ok":true,"frozen":false,"since":null,"reason":""}
GET /api/freeze -> frozen:false # kill-switch DISABLED over HTTP by the scheduler channel
do_POST applies only _guard() (Host/Origin loopback CSRF) before routing — no pre-dispatch channel gate — so the scheduler daemon token, whose only sanctioned power is POST /api/schedules/tick, cleared freeze.json over a real HTTP request, with no operator gesture and no approve code. (_handle_freeze never inspects _session_channel.)
A same-class second instance (verified by source)
schedules._require_human (88-99) refuses only the scheduler channel (if _channel(handler) == "scheduler": 403) and return True for everything else — so the mcp and cli tokens pass and can POST /api/schedules/create/enable (mutation at lines 194/240) to mint or re-arm a timing/standing instruction, even though the module docstring says creation "requires the browser session … a human at the keyboard." The defensible claim, tightened: the mcp sidecar (docs reserve creation for Studio) can create or re-arm schedules that then execute only within previously granted live authority — effective disposition is still capped by execution policy, and each tick re-enters the normal DAG path (policy/plan/credential/team gates still apply). (cli is plausibly an intended human authority, so the sharp conflict is mcp vs. the "browser session" documentation.)
(Not included as a claim: dispatch_sends' ten send handlers do not themselves call _require_session, and only the sibling _handle_webhook_out_test does — but I have not traced whether router-level middleware authenticates them before delegation, so I do not assert those routes are under-gated here.)
Impact
The emergency freeze — the operator's global kill-switch — is cleared over HTTP by the low-trust scheduler token, which is documented as authorizing only /api/schedules/tick. That does not by itself fire any send; it removes the freeze barrier so effects already authorized by the remaining policy / plan-pin / credential / team gates can resume, and it defeats the operator's one-switch emergency stop. Combined with the schedules instance, a compromised scheduler daemon or a prompt-injected model on the mcp sidecar can lift the emergency stop and arm standing timing instructions — actions the code elsewhere explicitly forbids those channels from taking. _guard() only pins Host/Origin to loopback (CSRF), so the actor is any local holder of a low-trust boot token, not a remote one.
Honest scope
- Not a remote/unauthenticated bypass: the caller needs one of the boot-minted local tokens (
scheduler/mcp/cli, each 0600 same-user) or, for the send handlers, to reach the loopback route past the_guard()Origin pin. The point is that these channels are explicitly designated low-trust and are supposed to be refused on governance mutators — the design intent is stated in the code and enforced on some routes but missing on these. - The freeze proof is end-to-end and self-contained. The
schedulesmcp/cli gap is verified by reading_require_human. Thedispatch_sendsno-gate observation is by source inspection; whether a broader router-level gate ever covers those handlers should be confirmed by the maintainer, but_handle_webhook_out_testcalling_require_sessionwhile its siblings do not is the tell. - Dual-control (
_require_approve) would add the terminal approve code, but it is opt-in and off by default, and_handle_freezedoes not call it regardless.
Distinctness
A single authorization-channel class across _handle_freeze (proven over HTTP) and schedules, distinct from every prior report (SSRF connectors, egress, plan-pin, backup/verify, module-signature, team-quorum, transform RCE). I did not find a community thread about governance routes accepting the scheduler/mcp/cli channels where only session should be allowed.
Fix
On every governance-mutating route, after _require_session() succeeds, refuse when _session_channel in {"scheduler","mcp","cli"} (allow only session, plus cli where a CLI governance surface is intentionally supported), mirroring schedules._require_human — and extend that refusal to mcp/cli in _require_human itself. Add _require_session() (and the channel check) to every dispatch_sends handler, matching _handle_webhook_out_test. Consider requiring _require_approve() (dual-control) for the freeze and policy mutators regardless of the default.
Reviewed adversarially against the source before posting.