← Community
bugfixed

A caller-supplied `invoker` + `schedule_id` on /api/workflow/dag/run lets any session holder forge a run's accountability,

ShwetaShweta#138d ago · 35 views
affected: station-v0.80fixed in: station-v0.84

A caller-supplied invoker + schedule_id on
/api/workflow/dag/run lets any session holder forge a run's accountability,
attaching a DIFFERENT person's name/schedule ownership to their own action

/api/workflow/dag/run's own inline comment states the invoker field is
resolved with "SERVER-SIDE authority, not caller self-declaration." That
is true for the MCP channel (invoker is hard-forced to "mcp", ignoring
whatever the caller sends) but not for the session channel: there,
invoker is read directly out of the request body and only checked
against a 6-value allowlist (studio/mcp/cli/relay/webhook/scheduler) --
the actual VALUE is entirely the caller's choice. Any caller holding a
valid session token can label their own, manually triggered run as
invoker: "scheduler".

v0.80 makes this consequential in a new way. A run whose resolved actor
is kind: "automation" (reachable via the forged "scheduler" invoker)
now also accepts a caller-supplied schedule_id, looks that schedule up,
and stamps its real, server-resolved owner into the receipt as
configured_by -- with no check that the schedule named actually belongs
to the caller, or that it is what triggered this particular run. The
schedule's own owner field is properly protected now (v0.80 also fixed
schedule creation to resolve owner server-side instead of trusting the
client) -- but nothing stops a caller from reading someone ELSE's
already-legitimate schedule_id (schedule ids are not secrets) and
attaching that person's name to a run they never configured or
triggered.

primitives/operator_identity.py's actor_for() makes the identical
"resolved server-side... never caller-supplied text" claim about the
channel parameter it receives -- _actor_block() in
routes/dispatch_workflow.py passes the same caller-controlled invoker
string straight through as that parameter.

Reproduction steps:

  1. Extract a clean station-v0.80 tarball, sys.path.insert(0, "workbench").
  2. Create a real schedule owned by one identity (e.g. alice@example.com)

via primitives.schedule_store.create() -- standing in for a schedule
she legitimately configured for her own recurring workflow.

  1. As a DIFFERENT caller, build the request body

{"invoker": "scheduler", "schedule_id": "<alice's schedule id>"} --
exactly what /api/workflow/dag/run accepts from any session-
authenticated POST for an ordinary, manually-triggered run.

  1. Run the exact parsing logic _handle_dag_run uses on that body, then

call routes.dispatch_workflow._actor_block(invoker, schedule_id=...)
-- the function that builds the actor block sealed into the signed
dag/run receipt.

Expected: a manually triggered run either records the actual caller/
session as its actor, or is refused the "scheduler" label and
configured_by attribution entirely, since nothing here was actually
triggered by alice's schedule.

Actual: the sealed actor block reads `{"kind": "automation", "channel":
"scheduler", ..., "configured_by": {"schedule_id": "...", "owner":
"alice@example.com"}}` -- a signed governance receipt naming alice as
the accountable party for a run she never configured or triggered,
produced by a caller who only needed to know her (non-secret) schedule
id.

Root cause: workbench/routes/dispatch_workflow.py _handle_dag_run()
reads invoker = str((body or {}).get("invoker") or "studio").strip().lower()
and _sched_id = str((body or {}).get("schedule_id") or "").strip() or None
directly from the caller-supplied request body for the session channel,
despite this function's own comment claiming invoker resolution has
"SERVER-SIDE authority, not caller self-declaration" (true only for the
MCP branch). _actor_block() then passes this caller-chosen invoker
straight into operator_identity.actor_for() as channel -- which
carries the identical "resolved server-side... never caller-supplied
text" claim in its own docstring -- and, when the result is
kind == "automation", looks up the caller-supplied schedule_id with
no ownership check before sealing its real owner into the receipt.

Suggested fix: for the session channel, do not accept an arbitrary
invoker value that claims a non-human channel ("scheduler", "relay",
"webhook") from an ordinary interactive request -- derive "this run was
actually triggered by schedule X" from the scheduler daemon's own
internal call path (the same way the MCP channel is force-set,
ignoring caller input), not from a body field. At minimum, verify that
schedule_id genuinely triggered this run (e.g. a one-time token minted
by the scheduler daemon itself when it fires a tick) before attaching
that schedule's owner to the receipt as configured_by.

3 pts

0 replies

Sign in to reply.