fix: close privilege escalation via workflow TargetTags on REST create/update

CreateWorkflow and UpdateWorkflow validated TargetServerIDs against the
caller's scope but never checked TargetTags, letting a restricted token
save a workflow with an empty ID list and an out-of-scope tag selector,
then reach those hosts once the scheduler fires it as the system with no
restriction of its own. Add validateWorkflowTargetScope, applying the
same all-or-nothing rule the MCP create_workflow tool already enforces:
resolve the full target set unscoped and scoped, refuse unless they
match. Update the PUT /api/workflows/:id/schedule fleetWide comment to
say why it is safe now (targets are constrained at write time) rather
than repeating the falsified claim that scheduling reaches nothing new.
This commit is contained in:
2026-09-09 11:45:39 +00:00
parent 52ac966cba
commit 2e3d2a33f9
3 changed files with 163 additions and 12 deletions
+23 -12
View File
@@ -216,18 +216,29 @@ var serverScopedRoutes = map[string]scopeDecl{
"GET /api/runs/:runId/servers/:serverId/logs": scoped,
"GET /api/runs/:runId/servers/:serverId/logs/stream": scoped,
// Deleting a workflow, cancelling a run and arming a schedule all act on a
// definition rather than on a server, and none of them returns server
// data. Each can nevertheless reach a definition whose targets a
// restricted token cannot see — a cancel stops work on out-of-scope hosts,
// a schedule arms it there. That reach is real but bounded: the caller
// learns nothing about which hosts are involved (both /workflows listings
// are scoped), and a scope-narrowed variant of "cancel this run" would
// have to either half-cancel a run or refuse one whose targets are mixed,
// neither of which is a better answer than the current one. Recorded as a
// deliberate choice, not an oversight.
"DELETE /api/workflows/:id": fleetWide,
"POST /api/runs/:runId/cancel": fleetWide,
// Deleting a workflow and cancelling a run both act on a definition rather
// than on a server, and neither returns server data. Each can
// nevertheless reach a definition whose targets a restricted token cannot
// see — a cancel stops work on out-of-scope hosts. That reach is real but
// bounded: the caller learns nothing about which hosts are involved (both
// /workflows listings are scoped), and a scope-narrowed variant of
// "cancel this run" would have to either half-cancel a run or refuse one
// whose targets are mixed, neither of which is a better answer than the
// current one. Recorded as a deliberate choice, not an oversight.
"DELETE /api/workflows/:id": fleetWide,
"POST /api/runs/:runId/cancel": fleetWide,
// Arming a schedule applies no scope check of its own, and that is safe
// only because it has nothing left to check: CreateWorkflow and
// UpdateWorkflow (internal/services/workflows.go) already refuse to save
// a workflow whose resolved targets — TargetServerIDs union TargetTags —
// reach outside the acting credential's scope, the same all-or-nothing
// rule the MCP create_workflow tool applies. So by the time a workflow
// exists to be scheduled, its targets were already constrained to
// whichever scope wrote them. The scheduler later fires it with a nil
// token scope, acting as the system rather than as any caller, and that
// is fine precisely because the targets were fixed at write time, not at
// fire time.
"PUT /api/workflows/:id/schedule": fleetWide,
"GET /api/workflows/:id/schedule/preview": exempt,