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:
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user