From 664edcdfb746f6c8513e2b487383b5d9ab9f5434 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 17 May 2026 14:57:24 +0000 Subject: [PATCH] fix: enforce jobs:run scope on job preview and inline endpoints (#9198) * fix: enforce jobs:run scope on job preview and inline endpoints Preview/inline endpoints (run/preview, run/preview_bundle, run/preview_flow, run/dynamic_select inline) execute arbitrary request-supplied code but only checked folder/namespace read access, which is a no-op when path is null. A token scoped to a specific script/flow could escape its scope and run any code. Add a jobs:run scope check, matching other arbitrary-execution endpoints. Advisory GHSA-vxc5-w28p-m9xw. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: scope-check dynamic_select flow branch and inline preview Address CI review: the dynamic_select Deployed{Flow} branch ran a deployed flow's dynamic-select code without any scope check (only the Script branch delegated to a scope-checked handler), and run_inline_preview_script executed request-supplied code with no in-handler scope check. Add jobs:run:flows:{path} to the flow branch and jobs:run to inline preview; correct the misleading comment. Expand regression tests (preview_flow case, assert success for the broad-token case). Advisory GHSA-vxc5-w28p-m9xw. Co-Authored-By: Claude Opus 4.7 (1M context) * test: remove preview scope enforcement test after local validation The regression test passed locally (3/3) and validated the fix end-to-end; removed from the PR per maintainer preference. Advisory GHSA-vxc5-w28p-m9xw. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-api/src/jobs.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index dcec542cb8..231b62e234 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -5635,6 +5635,11 @@ async fn run_preview_script( "Operators cannot run preview jobs for security reasons".to_string(), )); } + // Preview runs arbitrary, request-supplied code. require_path_read_access_for_preview + // only checks folder/namespace *read* access (and is a no-op when path is null), so a + // token scoped to a specific script/flow could otherwise escape its scope and run any + // code. Require the broad jobs:run scope, like other arbitrary-execution endpoints. + check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &preview.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); @@ -5716,6 +5721,9 @@ async fn run_inline_preview_script( Path(w_id): Path, Json(preview): Json, ) -> error::Result { + // Same arbitrary-code class as run_preview_script: a narrowly-scoped token + // must not be able to run request-supplied code through inline preview. + check_scopes(&authed, || format!("jobs:run"))?; if let Some(job_id) = job_id { register_potential_assets_on_inline_execution(job_id, &w_id, &preview); } @@ -5965,6 +5973,9 @@ async fn run_bundle_preview_script( "Operators cannot run preview jobs for security reasons".to_string(), )); } + // Bundle preview runs arbitrary, request-supplied code; require the broad jobs:run + // scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. + check_scopes(&authed, || format!("jobs:run"))?; let mut job_id = None; let mut tx = None; @@ -6632,6 +6643,9 @@ async fn run_preview_flow_job( "Operators cannot run preview jobs for security reasons".to_string(), )); } + // Flow preview runs an arbitrary, request-supplied flow definition; require the broad + // jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. + check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &raw_flow.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(raw_flow.tag.clone()); @@ -6779,6 +6793,11 @@ async fn run_dynamic_select( return Ok((StatusCode::CREATED, uuid.to_string()).into_response()); } RunnableKind::Flow => { + // Runs the deployed flow's dynamic-select code. Enforce the same + // path-scoped check the script branch gets via + // push_script_job_by_path_into_queue, so a token not scoped to this + // flow cannot trigger its code through dynamic select. + check_scopes(&authed, || format!("jobs:run:flows:{path}"))?; let mut conn = user_db.clone().begin(&authed).await?; let dynamic_input_res = match DYNAMIC_INPUT_CACHE.get(&format!("{}:{}", w_id, path)) @@ -6826,6 +6845,11 @@ async fn run_dynamic_select( } }, DynamicSelectRunnableRef::Inline { code, lang: language } => { + // Inline dynamic select runs arbitrary, request-supplied code; require the broad + // jobs:run scope so a narrowly-scoped token cannot escape its scope. The Deployed + // branches are path-scoped instead (scripts via push_script_job_by_path_into_queue, + // flows via the check_scopes above). + check_scopes(&authed, || format!("jobs:run"))?; dynamic_input = DynamicInput { x_windmill_dyn_select_code: code, x_windmill_dyn_select_lang: language.unwrap_or_default(),