Skip to content

Commit bd05bca

Browse files
rubenfiszelclaude
andauthored
fix: validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) (#9204)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 664edcd commit bd05bca

3 files changed

Lines changed: 107 additions & 1 deletion

File tree

backend/windmill-api/src/jobs.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ use windmill_common::db::UserDbWithAuthed;
3333
use windmill_common::error::JsonResult;
3434
use windmill_common::flow_status::{JobResult, RestartedFrom};
3535
use windmill_common::jobs::{
36-
format_completed_job_result, format_result, DynamicInput, ENTRYPOINT_OVERRIDE,
36+
format_completed_job_result, format_result, is_valid_entrypoint_name, DynamicInput,
37+
ENTRYPOINT_OVERRIDE,
3738
};
3839
#[cfg(feature = "run_inline")]
3940
use windmill_common::jobs::{
@@ -4514,6 +4515,13 @@ pub async fn run_workflow_as_code(
45144515
check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &authed).await?;
45154516
check_scopes(&authed, || format!("jobs:run"))?;
45164517

4518+
if !is_valid_entrypoint_name(&entrypoint) {
4519+
return Err(error::Error::BadRequest(format!(
4520+
"Invalid entrypoint {entrypoint:?}: must match ^[A-Za-z_][A-Za-z0-9_]*$ \
4521+
(letters, digits and underscores, not starting with a digit)"
4522+
)));
4523+
}
4524+
45174525
let mut i = 1;
45184526

45194527
if *CLOUD_HOSTED {
@@ -6769,6 +6777,14 @@ async fn run_dynamic_select(
67696777
match request.runnable_ref {
67706778
DynamicSelectRunnableRef::Deployed { path, runnable_kind } => match runnable_kind {
67716779
RunnableKind::Script => {
6780+
if !is_valid_entrypoint_name(&request.entrypoint_function) {
6781+
return Err(error::Error::BadRequest(format!(
6782+
"Invalid entrypoint_function {:?}: must match \
6783+
^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits and underscores, \
6784+
not starting with a digit)",
6785+
request.entrypoint_function
6786+
)));
6787+
}
67726788
let mut script_args = request.args.unwrap_or_default();
67736789
script_args.insert(
67746790
"_ENTRYPOINT_OVERRIDE".to_string(),

backend/windmill-types/src/jobs.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,10 +556,85 @@ pub struct OnBehalfOf {
556556
}
557557

558558
pub const ENTRYPOINT_OVERRIDE: &str = "_ENTRYPOINT_OVERRIDE";
559+
560+
/// The entrypoint override (`_ENTRYPOINT_OVERRIDE` job arg ->
561+
/// `v2_job.script_entrypoint_override`) is interpolated verbatim into
562+
/// generated worker wrappers in a code position (e.g. the NativeTS
563+
/// `import(...).then(m => m.<name>(...))` glue, the bun `Main.<name>(...)`
564+
/// call, the deno `import { <name> } from "./main.ts"` line, the PHP
565+
/// `<name>(...)` call and the Python `inner_script.<name>(**args)` call).
566+
/// A caller only needs `jobs:run` to set it, so it MUST be restricted to a
567+
/// conventional identifier or an attacker who can merely run a deployed
568+
/// script could break out of the call expression into arbitrary
569+
/// worker-process code. This ASCII subset is a valid function name in every
570+
/// language Windmill wraps this way (JS/TS, Python, PHP).
571+
pub fn is_valid_entrypoint_name(name: &str) -> bool {
572+
if name.is_empty() || name.len() > 255 {
573+
return false;
574+
}
575+
let mut chars = name.chars();
576+
let Some(first) = chars.next() else {
577+
return false;
578+
};
579+
if !(first.is_ascii_alphabetic() || first == '_') {
580+
return false;
581+
}
582+
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
583+
}
584+
559585
pub const LARGE_LOG_THRESHOLD_SIZE: usize = 9000;
560586
pub const EMAIL_ERROR_HANDLER_USER_EMAIL: &str = "email_error_handler@windmill.dev";
561587

562588
#[inline(always)]
563589
pub fn generate_dynamic_input_key(workspace_id: &str, path: &str) -> String {
564590
format!("{workspace_id}:{path}")
565591
}
592+
593+
#[cfg(test)]
594+
mod tests {
595+
use super::is_valid_entrypoint_name;
596+
597+
#[test]
598+
fn valid_entrypoint_names_are_accepted() {
599+
for name in [
600+
"main",
601+
"preprocessor",
602+
"my_helper",
603+
"_private",
604+
"fn2",
605+
"a",
606+
"MixedCase",
607+
] {
608+
assert!(is_valid_entrypoint_name(name), "expected {name:?} valid");
609+
}
610+
}
611+
612+
#[test]
613+
fn malicious_entrypoint_names_are_rejected() {
614+
// Regression for GHSA-wxjq-w5pj-jqhx: the entrypoint override is
615+
// interpolated verbatim into a code position of generated worker
616+
// wrappers (e.g. bun `Main.<name>(...)`, nativets
617+
// `m.<name>(...)`, python `inner_script.<name>(**args)`). Any value
618+
// that is not a strict identifier could break out of the call
619+
// expression into attacker-controlled worker code.
620+
for name in [
621+
"main(); globalThis.x = 1; //", // breaks out of `Main.<name>(...)`
622+
"x); require('child_process').execSync('id'); (",
623+
"1main", // starts with a digit
624+
"my-fn", // hyphen
625+
"my fn", // space
626+
"my.fn", // member access
627+
"$fn", // dollar
628+
"fn\nother", // newline
629+
"fn;other",
630+
"",
631+
] {
632+
assert!(
633+
!is_valid_entrypoint_name(name),
634+
"expected {name:?} to be rejected"
635+
);
636+
}
637+
// Over-long names are rejected.
638+
assert!(!is_valid_entrypoint_name(&"a".repeat(256)));
639+
}
640+
}

backend/windmill-worker/src/worker.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4436,6 +4436,21 @@ pub async fn run_language_executor(
44364436
modules: &Option<std::collections::HashMap<String, ScriptModule>>,
44374437
run_inline: bool,
44384438
) -> error::Result<Box<RawValue>> {
4439+
// Defense-in-depth (GHSA-wxjq-w5pj-jqhx): the entrypoint override is
4440+
// interpolated verbatim into a code position of the generated language
4441+
// wrappers below. It originates from the `_ENTRYPOINT_OVERRIDE` job arg,
4442+
// which any caller with `jobs:run` can set on a deployed script, so reject
4443+
// anything that is not a strict identifier before it reaches any wrapper.
4444+
if let Some(entrypoint) = job.script_entrypoint_override.as_deref() {
4445+
if !windmill_common::jobs::is_valid_entrypoint_name(entrypoint) {
4446+
return Err(Error::BadRequest(format!(
4447+
"Invalid entrypoint override {entrypoint:?}: must match \
4448+
^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits and underscores, \
4449+
not starting with a digit)"
4450+
)));
4451+
}
4452+
}
4453+
44394454
// Expand WM_INTERNAL_DB markers into real SQL before dispatching
44404455
let expanded_code: String;
44414456
let mut language = language;

0 commit comments

Comments
 (0)