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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-05-17 14:57:24 +00:00
committed by GitHub
parent 9dbce4a8c4
commit 664edcdfb7
+24
View File
@@ -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<String>,
Json(preview): Json<PreviewInline>,
) -> error::Result<Response> {
// 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(),