From 6646c86351f188d9b7e355dfb46e87bfa5c81846 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 17 Oct 2026 11:59:33 +0200 Subject: [PATCH] fix: run bun and npm installs under nsjail sandboxing (#11845) * fix: pass --save-text-lockfile only to bun installs Co-Authored-By: Claude Opus 5.5 * fix: run bun and npm installs under nsjail sandboxing Co-Authored-By: Claude Opus 5.5 * fix: mount npmrc cafile paths given with ${VAR} or ~ in the install jail Co-Authored-By: Claude Opus 5.5 * perf: keep bun's cache hardlinks for jailed installs Co-Authored-By: Claude Opus 5.5 * fix: harden the bun install staging dir and npm-mode jail mounts Co-Authored-By: Claude Opus 5.5 * fix: reach the bun staging dir only through no-follow descriptors Co-Authored-By: Claude Opus 5.5 * fix: create bun staging files new and check outputs after the move Co-Authored-By: Claude Opus 5.5 * feat: add DISABLE_BUN_INSTALL_SANDBOX to run bun installs unjailed Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../windmill-common/src/global_settings.rs | 1 + .../nsjail/install.bun.config.proto | 118 ++++ backend/windmill-worker/src/bun_executor.rs | 576 +++++++++++++++++- backend/windmill-worker/src/worker.rs | 9 + 4 files changed, 694 insertions(+), 10 deletions(-) create mode 100644 backend/windmill-worker/nsjail/install.bun.config.proto diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 65906d9056..94f6df5cc7 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -639,6 +639,7 @@ pub fn parse_allowed_origins_setting( pub const ENV_SETTINGS: &[&str] = &[ "DISABLE_NSJAIL", + "DISABLE_BUN_INSTALL_SANDBOX", "MODE", "NUM_WORKERS", "METRICS_ADDR", diff --git a/backend/windmill-worker/nsjail/install.bun.config.proto b/backend/windmill-worker/nsjail/install.bun.config.proto new file mode 100644 index 0000000000..86fc052102 --- /dev/null +++ b/backend/windmill-worker/nsjail/install.bun.config.proto @@ -0,0 +1,118 @@ +name: "bun install" + +mode: ONCE +hostname: "bun" +log_level: ERROR +time_limit: {TIMEOUT} + +# Bun/JavaScriptCore reserves large virtual ranges up front, and native builds +# size their pools to the host: an address-space cap breaks the install itself. +disable_rl: true + +cwd: "{INSTALL_DIR}" + +clone_newnet: false +clone_newuser: {CLONE_NEWUSER} + +skip_setsid: true +keep_caps: false +keep_env: true +mount_proc: true + +mount { + src: "/bin" + dst: "/bin" + is_bind: true +} + +mount { + src: "/lib" + dst: "/lib" + is_bind: true +} + +mount { + src: "/lib64" + dst: "/lib64" + is_bind: true + mandatory: false +} + +mount { + src: "/usr" + dst: "/usr" + is_bind: true +} + +mount { + src: "/etc" + dst: "/etc" + is_bind: true +} + +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + +mount { + src: "/dev/null" + dst: "/dev/null" + is_bind: true + rw: true +} + +mount { + src: "/dev/urandom" + dst: "/dev/urandom" + is_bind: true +} + +mount { + src: "/dev/random" + dst: "/dev/random" + is_bind: true +} + +{TMP_MOUNT_BLOCK} + +# HOME keeps the worker's path, so the package manager finds the operator's +# ~/.npmrc / ~/.bunfig.toml, but is otherwise a private tmpfs. +{HOME_MOUNT_BLOCK} + +# The install dir (package.json, lockfile, registry config; node_modules is +# written there) and, for bun, the shared bun cache, writable like the other +# install jails' shared caches. A bun install dir inside the cache gets no bind +# of its own: that would be a second mount, and bun can only hardlink from its +# cache within one. +{INSTALL_MOUNTS} + +mount { + src: "{TRACING_PROXY_CA_CERT_PATH}" + dst: "{TRACING_PROXY_CA_CERT_PATH}" + is_bind: true + mandatory: false +} + +{CA_MOUNT} + +#{DEV} diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index d8b206440d..322ccd30d5 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -22,16 +22,17 @@ use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, script_envars, start_child_process, write_file_binary, - MaybeLock, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, + resolve_nsjail_tmp_mount_block, resolve_nsjail_tmpfs_size_bytes, script_envars, + start_child_process, write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::handle_child, is_sandboxing_enabled, nsjail_clone_newnet, read_ee_registry_with_workspace_override, BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_INSTALL_MIN_RELEASE_AGE, - BUN_NO_CACHE, BUN_PATH, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, NPMRC, - NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, - TRACING_PROXY_CA_CERT_PATH, TZ_ENV, + BUN_NO_CACHE, BUN_PATH, DISABLE_BUN_INSTALL_SANDBOX, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, + NODE_PATH, NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, + PROXY_ENVS, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, }; use windmill_common::{ cache, @@ -75,6 +76,7 @@ pub const RELATIVE_BUN_LOADER: &str = include_str!("../loader.bun.windows.js"); pub const RELATIVE_BUN_BUILDER: &str = include_str!("../loader_builder.bun.js"); const NSJAIL_CONFIG_RUN_BUN_CONTENT: &str = include_str!("../nsjail/run.bun.config.proto"); +const NSJAIL_CONFIG_INSTALL_BUN_CONTENT: &str = include_str!("../nsjail/install.bun.config.proto"); pub const BUN_LOCK_SPLIT: &str = "\n//bun.lock\n"; pub const BUN_LOCKB_SPLIT: &str = "\n//bun.lockb\n"; @@ -733,6 +735,358 @@ registry = {} Ok(()) } +/// An nsjail bind mount of `path` onto itself, skipped when the host path is missing. +/// Empty for a path that is not absolute or that the proto string cannot carry verbatim. +fn optional_bind_mount(path: &str, rw: bool) -> String { + if !path.starts_with('/') || path.contains(['"', '\\', '\n', '\r']) { + return String::new(); + } + format!( + "mount {{\n src: \"{path}\"\n dst: \"{path}\"\n is_bind: true\n rw: {rw}\n mandatory: false\n}}\n" + ) +} + +/// node-gyp's header cache under HOME. Kept across jailed installs like the package +/// caches: without it every native build downloads the headers again, which a worker +/// with no internet access cannot. +const NODE_GYP_CACHE: &str = ".cache/node-gyp"; + +/// The install jail's HOME: a private tmpfs at the worker's HOME path, with the +/// package manager's user config read-only and the caches kept there writable (npm's +/// `~/.npm` in npm mode, node-gyp's headers in both). +fn install_home_mount_block(home: &str, npm_mode: bool, tmpfs_size_bytes: &str) -> String { + let home = home.trim_end_matches('/'); + let mut block = String::new(); + // A HOME under /tmp is already private: the jail's /tmp is. + if home.starts_with('/') + && home != "/tmp" + && !home.starts_with("/tmp/") + && !home.contains(['"', '\\', '\n', '\r']) + { + block.push_str(&format!( + "mount {{\n dst: \"{home}\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={tmpfs_size_bytes}\"\n}}\n" + )); + } + block.push_str(&optional_bind_mount(&format!("{home}/.npmrc"), false)); + block.push_str(&optional_bind_mount(&format!("{home}/.bunfig.toml"), false)); + block.push_str(&optional_bind_mount( + &format!("{home}/{NODE_GYP_CACHE}"), + true, + )); + if npm_mode { + block.push_str(&optional_bind_mount(&format!("{home}/.npm"), true)); + } + block +} + +/// An `.npmrc` value as npm reads it: `${VAR}` from the install env, and a leading `~/` +/// as HOME. Without it, a `cafile=~/…` or `${HOME}/…` is never mounted and the jail's +/// private HOME hides the certificate. +fn expand_npmrc_value(value: &str, home: &str, envs: &HashMap) -> String { + let mut out = String::new(); + let mut rest = value; + while let Some(start) = rest.find("${") { + let Some(len) = rest[start..].find('}') else { + break; + }; + out.push_str(&rest[..start]); + let name = rest[start + 2..start + len].trim_end_matches('?'); + out.push_str(envs.get(name).map(String::as_str).unwrap_or_default()); + rest = &rest[start + len + 1..]; + } + out.push_str(rest); + match out.strip_prefix("~/") { + Some(p) => format!("{}/{p}", home.trim_end_matches('/')), + None => out, + } +} + +/// TLS files the install is pointed at (CA bundles, registry client certificates) live +/// outside the jail's mounts: bind them in, read-only, so private registries keep working. +fn install_ca_mounts(job_dir: &str, home: &str, envs: &HashMap) -> String { + const CA_ENVS: [&str; 4] = [ + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NPM_CONFIG_CAFILE", + ]; + // Also registry-scoped (`//host/:certfile=`), hence the match on the last segment. + const TLS_FILE_KEYS: [&str; 3] = ["cafile", "certfile", "keyfile"]; + let from_env = crate::worker::WHITELIST_ENVS + .iter() + .filter(|(k, _)| CA_ENVS.iter().any(|c| c.eq_ignore_ascii_case(k))) + .map(|(_, v)| v.trim().to_string()); + // `.npmrc` lines and bunfig's `[install] cafile = "…"` share the `key = value` shape. + let from_config = [ + format!("{job_dir}/{BUN_NPMRC_FILE}"), + format!("{home}/.npmrc"), + format!("{job_dir}/{BUN_CONFIG_FILE}"), + format!("{home}/.bunfig.toml"), + ] + .into_iter() + .filter_map(|f| fs::read_to_string(f).ok()) + .flat_map(|content| { + content + .lines() + .filter_map(|l| { + let (k, v) = l.split_once('=')?; + let key = k.trim().rsplit(':').next()?; + TLS_FILE_KEYS + .iter() + .any(|t| t.eq_ignore_ascii_case(key)) + .then(|| expand_npmrc_value(v.trim().trim_matches(['"', '\'']), home, envs)) + }) + .collect::>() + }); + from_env + .chain(from_config) + .filter(|p| !p.is_empty()) + .unique() + .map(|p| optional_bind_mount(&p, false)) + .collect() +} + +/// The files `bun install` reads from, and writes to, the directory it installs in. +const BUN_INSTALL_INPUTS: [&str; 3] = ["package.json", "bun.lock", "bun.lockb"]; +const BUN_INSTALL_OUTPUTS: [&str; 3] = ["node_modules", "bun.lock", "bun.lockb"]; +/// What the worker reads or mounts from the job dir after an install that ran in it. +const BUN_JOB_DIR_OUTPUTS: [&str; 5] = [ + "node_modules", + "package.json", + "bun.lock", + "bun.lockb", + "package-lock.json", +]; + +/// Whether an install left `name` at `path`, refusing anything but a real directory +/// (node_modules) or regular file there: the install jail could write it, and the worker +/// follows what it then reads, renames or mounts. +fn check_install_output(path: &str, name: &str) -> Result { + let expect_dir = name == "node_modules"; + match fs::symlink_metadata(path) { + Ok(m) if (expect_dir && m.is_dir()) || (!expect_dir && m.is_file()) => Ok(true), + Ok(_) => Err(error::Error::ExecutionErr(format!( + "dependency install left an unexpected {name}: expected a regular {}", + if expect_dir { "directory" } else { "file" } + ))), + Err(_) => Ok(false), + } +} + +/// Registry configuration bun reads from the install dir. It can hold an auth token, so +/// a staged install gets it bind-mounted read-only from the job dir instead of a copy in +/// the cache, which every later install jail mounts. +const BUN_REGISTRY_FILES: [&str; 2] = [BUN_CONFIG_FILE, BUN_NPMRC_FILE]; + +/// Open a directory without following a symlink at `path`. +#[cfg(unix)] +fn open_dir_nofollow(path: &str) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_DIRECTORY | nix::libc::O_NOFOLLOW) + .open(path) +} + +/// Delete entry `name` of the directory open as `dir`, off the async worker thread: a +/// staging dir can hold a whole node_modules. A symlink there is removed, never followed. +#[cfg(unix)] +fn remove_entry_detached(dir: Arc, name: String) { + use std::os::fd::AsRawFd; + let remove = move || { + let path = format!("/proc/self/fd/{}/{name}", dir.as_raw_fd()); + let _ = match fs::symlink_metadata(&path) { + Ok(m) if m.is_dir() => fs::remove_dir_all(&path), + Ok(_) => fs::remove_file(&path), + Err(e) => Err(e), + }; + }; + match tokio::runtime::Handle::try_current() { + Ok(rt) => { + rt.spawn_blocking(remove); + } + Err(_) => remove(), + } +} + +/// A directory for one jailed bun install inside the bun cache, removed when dropped. +/// +/// The jail binds the job dir and the cache as two mounts, and link(2) fails across +/// mounts, so bun would copy every file out of the cache instead of hardlinking it. +/// Installing here and renaming node_modules into the job dir keeps the hardlinks. +/// +/// Every install jail can write the cache, so any path through it may since have become a +/// symlink. The worker reaches this dir only through descriptors opened without following +/// links before the jail runs ([`Self::entry`]); `path` is for the jail config alone. +struct BunStagingDir { + #[cfg(unix)] + root: Arc, + #[cfg(unix)] + dir: fs::File, + name: String, + path: String, +} + +impl BunStagingDir { + /// Entry `f` of the staging dir, resolved through its descriptor. + fn entry(&self, f: &str) -> String { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + format!("/proc/self/fd/{}/{f}", self.dir.as_raw_fd()) + } + #[cfg(not(unix))] + format!("{}/{f}", self.path) + } +} + +#[cfg(unix)] +impl Drop for BunStagingDir { + fn drop(&mut self) { + remove_entry_detached(self.root.clone(), std::mem::take(&mut self.name)); + } +} + +/// How long a staging dir may outlive its install before the next one deletes it: a +/// worker killed mid-install leaves it behind. Well past the longest install. +const BUN_STAGING_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60); + +/// Whether a bun install needs nothing of the job dir beyond [`BUN_INSTALL_INPUTS`]: no +/// dependency by path, no workspaces or patches, and no root lifecycle script, which bun +/// runs next to the manifest and which may read the job's other files. +fn bun_install_can_stage(package_json: &str, lock: Option<&str>) -> bool { + const DEP_SECTIONS: [&str; 6] = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + "overrides", + "resolutions", + ]; + const ROOT_SCRIPTS: [&str; 6] = [ + "preinstall", + "install", + "postinstall", + "preprepare", + "prepare", + "postprepare", + ]; + const LOCAL_SPECS: [&str; 8] = [ + "file:", + "link:", + "workspace:", + "portal:", + "./", + "../", + "/", + "~/", + ]; + fn has_local_spec(v: &serde_json::Value) -> bool { + match v { + serde_json::Value::String(s) => LOCAL_SPECS.iter().any(|p| s.trim().starts_with(p)), + serde_json::Value::Object(m) => m.values().any(has_local_spec), + _ => false, + } + } + let Ok(serde_json::Value::Object(pkg)) = serde_json::from_str(package_json) else { + return false; + }; + let root_script = pkg + .get("scripts") + .and_then(|s| s.as_object()) + .is_some_and(|s| ROOT_SCRIPTS.iter().any(|k| s.contains_key(*k))); + if root_script + || pkg.contains_key("workspaces") + || pkg.contains_key("patchedDependencies") + || DEP_SECTIONS + .iter() + .any(|k| pkg.get(*k).is_some_and(has_local_spec)) + { + return false; + } + // Every text lockfile has a root `"workspaces"` entry, so only these mark local + // packages: as a spec (`"file:x"`) or in a resolution (`"x@file:x"`). + const LOCK_LOCAL_PROTOCOLS: [&str; 4] = ["file:", "link:", "workspace:", "portal:"]; + !lock.is_some_and(|l| { + l.contains("\"patchedDependencies\"") + || LOCK_LOCAL_PROTOCOLS + .iter() + .any(|p| l.contains(&format!("\"{p}")) || l.contains(&format!("@{p}"))) + }) +} + +/// None when node_modules could not be renamed from the cache into the job dir (another +/// filesystem, or another mount of the same one): moving it back would be a copy, which +/// is what installing in the job dir costs anyway. Also None when +/// [`bun_install_can_stage`] says the install needs the job dir. +fn bun_staging_dir(cache_dir: &str, job_dir: &str) -> Option { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + let package_json = fs::read_to_string(format!("{job_dir}/package.json")).ok()?; + let lock = fs::read_to_string(format!("{job_dir}/bun.lock")).ok(); + if !bun_install_can_stage(&package_json, lock.as_deref()) { + return None; + } + // `cache_dir` is the jail's mount point, which no jail can replace; what is under + // it can be anything. + let root_path = format!("{cache_dir}/.wm-staging"); + if fs::symlink_metadata(&root_path).is_ok_and(|m| !m.is_dir()) { + fs::remove_file(&root_path).ok()?; + } + let _ = fs::create_dir(&root_path); + let root = Arc::new(open_dir_nofollow(&root_path).ok()?); + let root_fd = format!("/proc/self/fd/{}", root.as_raw_fd()); + for e in fs::read_dir(&root_fd).ok()?.flatten() { + let stale = e + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age > BUN_STAGING_MAX_AGE); + if stale { + remove_entry_detached(root.clone(), e.file_name().to_string_lossy().into_owned()); + } + } + let name = Uuid::new_v4().to_string(); + fs::create_dir(format!("{root_fd}/{name}")).ok()?; + let dir = open_dir_nofollow(&format!("{root_fd}/{name}")).ok()?; + let path = format!("{root_path}/{name}"); + let staging = BunStagingDir { root, dir, name, path }; + // A jail sharing the cache can list the new dir and plant a symlink in it before + // the worker writes there: every file is created new, never opened as found. + let create_new = |path: &str| { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + }; + // The move back is a rename, which is the only test that holds: two mounts of + // one filesystem share st_dev and still refuse it. + let probe = staging.entry(".rename-probe"); + let probe_dst = format!("{job_dir}/.wm-staging-probe-{}", Uuid::new_v4()); + create_new(&probe).ok()?; + let renamed = fs::rename(&probe, &probe_dst).is_ok(); + let _ = fs::remove_file(&probe_dst); + if !renamed { + return None; + } + for f in BUN_INSTALL_INPUTS { + let src = format!("{job_dir}/{f}"); + if fs::symlink_metadata(&src).is_ok_and(|m| m.is_file()) { + let mut out = create_new(&staging.entry(f)).ok()?; + std::io::copy(&mut fs::File::open(&src).ok()?, &mut out).ok()?; + } + } + Some(staging) + } + #[cfg(not(unix))] + { + let _ = (cache_dir, job_dir); + None + } +} + pub async fn install_bun_lockfile( mem_peak: &mut i32, canceled_by: &mut Option, @@ -746,9 +1100,37 @@ pub async fn install_bun_lockfile( occupancy_metrics: &mut Option<&mut OccupancyMetrics>, quiet: bool, ) -> Result<()> { - let mut child_cmd = Command::new(if npm_mode { &*NPM_PATH } else { &*BUN_PATH }); + let package_manager = if npm_mode { &*NPM_PATH } else { &*BUN_PATH }; + // The install runs the lifecycle scripts of dependencies (npm: all of them; bun: its + // default trusted list and `trustedDependencies`), so when sandboxing is on it is + // confined under nsjail like the run step. The env is the same either way. + let sandboxed = is_sandboxing_enabled() && !*DISABLE_BUN_INSTALL_SANDBOX; + if is_sandboxing_enabled() && *DISABLE_BUN_INSTALL_SANDBOX { + static WARNED: std::sync::Once = std::sync::Once::new(); + WARNED.call_once(|| { + tracing::warn!( + "DISABLE_BUN_INSTALL_SANDBOX is set: Bun and npm dependency installs run \ + outside nsjail, with their lifecycle scripts unconfined" + ) + }); + } + let home = common_bun_proc_envs + .get("HOME") + .cloned() + .unwrap_or_else(|| HOME_ENV.clone()); + let mut child_cmd = if sandboxed { + let mut cmd = Command::new(NSJAIL_PATH.as_str()); + cmd.args(["--config", "install.config.proto", "--", package_manager]); + cmd + } else { + Command::new(package_manager) + }; - let mut args = vec!["install", "--save-text-lockfile"]; + let mut args = vec!["install"]; + // A bun flag: npm 12 rejects unknown cli flags instead of warning. + if !npm_mode { + args.push("--save-text-lockfile"); + } let no_cache = !npm_mode && *BUN_NO_CACHE; @@ -851,7 +1233,86 @@ pub async fn install_bun_lockfile( gen_bunfig(job_dir, job_id, w_id, db).await?; } - let child_process = start_child_process(child_cmd, &*BUN_PATH, false).await?; + let mut staging = None; + if sandboxed { + // Bind-mounted into the jail, so they must exist before nsjail builds the mount + // tree. + let _ = std::fs::create_dir_all(format!("{home}/{NODE_GYP_CACHE}")); + if npm_mode { + let _ = std::fs::create_dir_all(format!("{home}/.npm")); + } else { + std::fs::create_dir_all(&*BUN_CACHE_DIR)?; + staging = bun_staging_dir(&BUN_CACHE_DIR, job_dir); + } + let install_dir = staging.as_ref().map_or(job_dir, |s| s.path.as_str()); + let bind = |src: &str, dst: &str, rw: bool| { + format!( + "mount {{\n src: \"{src}\"\n dst: \"{dst}\"\n is_bind: true\n rw: {rw}\n}}\n" + ) + }; + // npm never reads the bun cache, and its install scripts get no write to it. + let mut install_mounts = if npm_mode { + String::new() + } else { + bind(&BUN_CACHE_DIR, &BUN_CACHE_DIR, true) + }; + match staging.as_ref() { + // After the cache bind: these land inside it. + Some(s) => { + for f in BUN_REGISTRY_FILES { + let src = format!("{job_dir}/{f}"); + if fs::symlink_metadata(&src).is_ok_and(|m| m.is_file()) { + install_mounts.push_str(&bind(&src, &format!("{}/{f}", s.path), false)); + } + } + } + None => install_mounts.push_str(&bind(job_dir, job_dir, true)), + } + let install_envs: HashMap = child_cmd + .as_std() + .get_envs() + .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string()))) + .collect(); + let nsjail_timeout = match db { + Some(conn) => resolve_nsjail_timeout(conn, w_id, *job_id, None).await, + // no job to bound it: the caller waits for the install unbounded too + None => "0".to_string(), + }; + write_file( + job_dir, + "install.config.proto", + &NSJAIL_CONFIG_INSTALL_BUN_CONTENT + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{INSTALL_MOUNTS}", &install_mounts) + .replace("{INSTALL_DIR}", install_dir) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{HOME_MOUNT_BLOCK}", + &install_home_mount_block( + &home, + npm_mode, + &resolve_nsjail_tmpfs_size_bytes().await, + ), + ) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace( + "{CA_MOUNT}", + &install_ca_mounts(job_dir, &home, &install_envs), + ) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ), + )?; + } + + let child_executable = if sandboxed { + NSJAIL_PATH.as_str() + } else { + package_manager.as_str() + }; + let child_process = start_child_process(child_cmd, child_executable, false).await?; if let Some(db) = db { let mut quiet_buf = String::new(); let result = handle_child( @@ -860,7 +1321,8 @@ pub async fn install_bun_lockfile( mem_peak, canceled_by, child_process, - false, + // jailed when sandboxing is on, so mem sampling finds the real process + sandboxed, worker_name, w_id, "bun install", @@ -895,6 +1357,34 @@ pub async fn install_bun_lockfile( } } + if let Some(staging) = staging { + // Leave the job dir as an install in place would have: what bun wrote or kept + // replaces the job dir's copy, what it removed is removed there too. + for f in BUN_INSTALL_OUTPUTS { + let src = staging.entry(f); + let dst = format!("{job_dir}/{f}"); + match fs::symlink_metadata(&dst) { + Ok(m) if m.is_dir() => tokio::fs::remove_dir_all(&dst).await?, + Ok(_) => tokio::fs::remove_file(&dst).await?, + Err(_) => {} + } + if fs::symlink_metadata(&src).is_ok() { + fs::rename(&src, &dst)?; + // Checked once out of the cache, where no other jail can swap it. + if let Err(e) = check_install_output(&dst, f) { + let _ = fs::remove_file(&dst); + return Err(e); + } + } + } + } else if sandboxed { + // The jail could write the job dir: what the worker reads or mounts from it next + // must still be what an install leaves there. + for f in BUN_JOB_DIR_OUTPUTS { + check_install_output(&format!("{job_dir}/{f}"), f)?; + } + } + if has_file { tokio::fs::remove_file(format!("{job_dir}/.npmrc")).await?; } @@ -1679,7 +2169,10 @@ pub async fn handle_bun_job( && (annotation.nobundling || *DISABLE_BUNDLING) { let script_path = crate::common::use_flow_root_path(job.runnable_path()); - let script_dir = script_path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""); + let script_dir = script_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); let imports = std::iter::once((script_path.clone(), inner_content.as_str())) .chain(modules.iter().flatten().map(|(key, module)| { let stem = key.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(key); @@ -4502,6 +4995,69 @@ pub async fn start_worker( mod tests { use super::*; + #[test] + fn bun_install_stages_only_self_contained_manifests() { + let lock = r#"{"lockfileVersion": 1, "workspaces": {"": {"dependencies": {"zod": "^4"}}}}"#; + assert!(bun_install_can_stage( + r#"{"dependencies": {"zod": "^4"}}"#, + Some(lock) + )); + for manifest in [ + r#"{"dependencies": {"x": "file:./x"}}"#, + r#"{"dependencies": {"x": "../x"}}"#, + r#"{"overrides": {"y": {"x": "link:x"}}}"#, + r#"{"workspaces": ["packages/*"]}"#, + r#"{"patchedDependencies": {"zod@4.0.0": "patches/zod.patch"}}"#, + r#"{"scripts": {"postinstall": "test -f main.ts"}}"#, + "not json", + ] { + assert!(!bun_install_can_stage(manifest, None), "{manifest}"); + } + let local_lock = r#"{"packages": {"x": ["x@file:x", {}]}}"#; + assert!(!bun_install_can_stage( + r#"{"dependencies": {}}"#, + Some(local_lock) + )); + } + + // Install jails can write the bun cache, so the worker must never follow a path + // through it: neither a staging root nor a staging dir swapped for a symlink may + // lead its cleanup outside the cache. + #[cfg(target_os = "linux")] + #[test] + fn bun_staging_cleanup_does_not_follow_symlinks_out_of_the_cache() { + let tmp = tempfile::tempdir().unwrap(); + let (cache, job, outside) = ( + tmp.path().join("cache"), + tmp.path().join("job"), + tmp.path().join("outside"), + ); + for d in [&cache, &job, &outside.join("victim")] { + fs::create_dir_all(d).unwrap(); + } + fs::write(job.join("package.json"), r#"{"dependencies": {}}"#).unwrap(); + // Old enough for the stale sweep if it were reached through the symlink. + fs::File::open(outside.join("victim")) + .unwrap() + .set_modified(std::time::SystemTime::UNIX_EPOCH) + .unwrap(); + std::os::unix::fs::symlink(&outside, cache.join(".wm-staging")).unwrap(); + + let staging = + bun_staging_dir(cache.to_str().unwrap(), job.to_str().unwrap()).expect("staging dir"); + assert!(outside.join("victim").exists()); + assert!(fs::symlink_metadata(cache.join(".wm-staging")) + .unwrap() + .is_dir()); + + let staged = cache.join(".wm-staging").join(&staging.name); + fs::remove_dir_all(&staged).unwrap(); + std::os::unix::fs::symlink(&outside, &staged).unwrap(); + drop(staging); + assert!(outside.join("victim").exists()); + assert!(fs::symlink_metadata(&staged).is_err()); + } + #[test] fn test_bun_lockfile_version() { assert_eq!(bun_lockfile_version(EMPTY_V1_BUN_LOCK), Some(1)); diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 26c83881c8..cef3b1bbfc 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -358,6 +358,15 @@ lazy_static::lazy_static! { .and_then(|x| x.parse::().ok()) .unwrap_or(false); + /// Runs Bun scripts' dependency installs (bun and `//npm`) outside nsjail even when + /// sandboxing is on, for a worker whose installs need host files the install jail + /// does not mount (keys for private git dependencies, a package cache outside HOME). + /// The run step stays jailed; the installs' lifecycle scripts see the worker again. + pub static ref DISABLE_BUN_INSTALL_SANDBOX: bool = std::env::var("DISABLE_BUN_INSTALL_SANDBOX") + .ok() + .and_then(|x| x.parse::().ok()) + .unwrap_or(false); + /// Per-language override for the nsjail `rlimit_as` (virtual address space) cap. /// Value is in MiB, or `unlimited`/`none`/`inf`/`0` to uncap. Unset keeps the /// historical default baked into the proto. See `render_nsjail_rlimit_as`.