Affected: station-v0.93 (current)
Station: verified on station-v0.93 (current). File: workbench/studio_integration_send.py approve() (163-182). Class: improper verification of cryptographic signature / missing tamper-evidence (CWE-347 / CWE-345).
The guarantee
studio_integration_send is, per its docstring, "the ONLY path that can touch the provider" for the governed send connectors (teams/webhook/gsheets/gdocs/telegram/resend/notion, and discord/slack). stage() signs the staged delta (staged["signature"] = signing.sign_block(integrity)), and approve()'s own docstring promises: "reload → recompute integrity → verify signature (!= SIG_VERIFIED refuses)." The signature is the tamper-evidence binding the executed send to what was staged/approved.
The break — signature verification is conditional on the signature being present
approve() (163-182):
staged = json.load(open(spath))
plan = staged.get("plan", {})
integrity = _integrity({"staging_id": staged["staging_id"], "provider": provider, "plan": plan})
if integrity != staged.get("integrity"):
return {"ok": False, "error": "staged delta integrity mismatch — refusing to execute"}
if staged.get("signature"): # <-- verification GATED on presence
if signing.verify_against_install(integrity, staged["signature"]) != signing.SIG_VERIFIED:
return {"ok": False, "error": "staged signature invalid — refusing"}
# ... proceeds to policy_gate, then executes the send
There is no mandatory-signature check. integrity is an unkeyed sha256 recomputed from the on-disk plan, so it is not tamper-evidence — anyone who edits the plan can recompute it. The staged file's local tamper-evidence — the Ed25519 signature — is verified only when staged.get("signature") is truthy (on a team-quorum deployment teammate co-signatures also bind the recomputed integrity; on a solo/no-quorum station the local signature is the only tamper-evidence). Strip the signature (set it to null/absent) and the check is skipped entirely.
The attack
Threat model — the same one the staged-signature exists to defend: an actor who can write the on-disk staged file (WS/{provider}_staging/{staging_id}.json) between stage and approve. They: (1) modify plan (recipient, message, amount), (2) recompute and set integrity, (3) delete signature. approve() then recomputes the matching integrity, skips the (absent) signature check, and executes the tampered send. The human who staged saw the original preview and approves by staging_id; the tampered plan fires.
Proof (container, real approve(), v0.93; policy_gate stubbed to BLOCK so no send fires)
provider: linear (ready) verb: issue_create
approve(UNSIGNED tampered staged {signature:null, integrity matches tampered plan})
-> "blocked by approval policy: repro-stop" # PASSED the signature gate, reached policy_gate
approve(GARBAGE-signed staged)
-> "staged signature invalid — refusing" # gate DOES fire when a signature is present
The unsigned tampered delta passes the signature gate (verification is skipped, not passed); had policy allowed instead of BLOCK, approve() would have proceeded toward team gating and the provider apply. A present-but-invalid signature is correctly refused — proving the defect is specifically the conditional verification. (The repro's plan fields are illustrative, not a valid provider payload; it establishes the signature-gate bypass and the reach past it, not a completed live send.)
Distinctness
The sibling apply path, workflow_mcp.apply_workflow, makes the signature mandatory for exactly this reason — its own comment: "The integrity hash is an unkeyed sha256 anyone can recompute; only the Ed25519 signature over the pinned install key makes the staged plan tamper-evident. An unsigned staged file (or one with the signature stripped) must be refused — otherwise the advertised 'verifies integrity + Ed25519' is a lie." studio_integration_send.approve() is the connector-send apply path and omits that mandatory check, so the guarantee workflow_mcp enforces is absent here. Not found on the community board (no thread about studio_integration_send / the connector-send approve accepting an unsigned staged delta).
Honest scope
- Requires write access to the workspace staging file between
stageandapprove— but that is precisely the tamper threat the staged signature is meant to make evident, so it is in scope for that guarantee (same threat model as the workflow plan-pin). approve()still evaluatespolicy_gate(ablockrule / global freeze still stops it) and team approval. Note the team gate re-binds to the recomputed integrity as itsaction_hash, so a team-quorum deployment forces a fresh co-sign for the tampered hash — the exposure is a solo/no-quorum station, or one where the tampered action lands onauto_approveor a human clicking approve bystaging_id. The send is live only if the connector is in live mode with credentials (mock mode = DRY_RUN). The defect is that tamper-evidence is defeated, not that policy is bypassed.- Not a remote/unauthenticated bypass.
Fix
Make the signature mandatory in approve(), mirroring workflow_mcp.apply_workflow: if staged.get("signature") is missing/None, refuse ("unsigned staged delta — refusing") before executing. The unkeyed integrity hash must never be the sole gate on a real send.
Reviewed adversarially against the source before posting.