diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index f2f7ca2637..da56b9fff6 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -743,13 +743,16 @@ pub fn has_raw_app_sdk_sentinel(scopes: Option<&[String]>) -> bool { /// Without this, a captured SDK token escalates to the viewer's full account: /// these jobs run attacker-chosen code (or resolve and install attacker-chosen /// packages on a worker) and their own ephemeral credential is unscoped and -/// permissioned as the viewer. `workflow_as_code` counts even though its body is -/// just args: it copies the named queued job's `raw_code` into a new job without -/// an ownership check, so a pre-staged preview job replays as the viewer. +/// permissioned as the viewer. The last two count even though their bodies carry +/// no code, because they re-run code a *named* job already holds: +/// `workflow_as_code` copies the named queued job's `raw_code` into a new job +/// with no ownership check, and restarting a completed preview flow re-runs its +/// stored `raw_flow`. Either replays a pre-staged preview as the viewer, and the +/// scope layer can't tell a preview restart from a deployed-flow one. /// The SDK never calls these — its run helpers are all by path/hash. fn is_request_supplied_code_route(suffix: &str) -> bool { // Prefixes, so the `_async` variants are covered too. - const CODE_ROUTES: [&str; 9] = [ + const CODE_ROUTES: [&str; 10] = [ "jobs/run/preview", "jobs/run_inline/preview", "jobs/run_wait_result/preview", @@ -759,6 +762,7 @@ fn is_request_supplied_code_route(suffix: &str) -> bool { "jobs/run/dependencies", "jobs/run/flow_dependencies", "jobs/run/workflow_as_code", + "jobs/restart/f", ]; CODE_ROUTES.iter().any(|p| suffix.starts_with(p)) } diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 0b12cfdc4c..4ff1c5c69d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4918,12 +4918,13 @@ mod embed_token_tests { .includes(&required)); } - /// The raw-app frontend SDK token is bounded by two properties: no `apps:*` - /// scope (so the Apps-domain mint endpoints are unreachable and a captured - /// token can't renew itself past its expiry), and the `raw_app_sdk` sentinel - /// (so `jobs:run` can't reach the request-supplied-code endpoints, which - /// would escalate a captured token to arbitrary execution as the viewer). - /// Lock both, plus the viewer-permissioned surface each curated scope grants. + /// The raw-app frontend SDK token is bounded by no `apps:*` scope (so the + /// Apps-domain mint endpoints are unreachable and a captured token can't renew + /// itself past its expiry) and by the `raw_app_sdk` sentinel (so `jobs:run` + /// can't reach the endpoints that run caller-supplied or caller-named code — + /// which would escalate a captured token to arbitrary execution as the viewer + /// — and `users:read` can't reach the workspace member directory). + /// Lock all of it, plus the viewer-permissioned surface each curated scope grants. #[test] fn frontend_sdk_scopes_reach_declared_domains_but_never_mint_routes() { // Mirror mint_raw_app_sdk_token: declared scopes + the narrowing sentinel. @@ -4981,11 +4982,13 @@ mod embed_token_tests { ("/api/w/test/jobs/run/dependencies_async", "POST"), ("/api/w/test/jobs/run/flow_dependencies", "POST"), ("/api/w/test/jobs/run/flow_dependencies_async", "POST"), - // Replays a named queued preview job's raw code as the caller. + // Replay a named job's already-stored code as the caller: the queued + // preview's raw_code, or a completed preview flow's raw_flow. ( "/api/w/test/jobs/run/workflow_as_code/some-uuid/main", "POST", ), + ("/api/w/test/jobs/restart/f/some-uuid", "POST"), // `users:read` is presented to the viewer as "read your identity", so // the workspace member directory must stay out of reach. ("/api/w/test/users/list", "GET"),