From 1727271e197b34026efeaf1b6561bb404a440baa Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 5 Jun 2026 10:35:51 +0200 Subject: [PATCH] feat: sandboxed daemonless container runtime via '# sandbox ' (#9453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add sandboxed docker v2 runtime via '# docker ' Run a container image as a subprogram of the job's own nsjail sandbox: extract the image rootfs with podman (rootless) and run it chrooted inside the job's nsjail, so the container inherits the job's confinement and is safe under nsjail / for untrusted code. Selected by '# docker '; a bare '# docker' keeps the v1 (dind) path untouched. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: default to daemonless docker (drop dind from compose, allow docker on cloud) docker-compose no longer ships the dind sidecar (v2 is daemonless: podman + nsjail in the worker); removed the dind service, DOCKER_HOST env, depends_on and volume. Removed the language-picker guard that blocked Docker scripts on the multi-tenant platform, now that v2 makes docker safe to run sandboxed. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: select sandboxed container via # sandbox ; add pull policy + size guards - Surface moved from '# docker ' to '# sandbox ' (groups under the sandbox annotation; '# docker' stays v1-only, '# sandbox' stays nsjail-bash). - SANDBOX_IMAGE_PULL_POLICY (default 'newer') so moving tags don't go stale. - SANDBOX_IMAGE_MAX_SIZE_MB rejects oversized images before extraction. - SANDBOX_IMAGE_CACHE_MAX_MB best-effort LRU eviction of podman's image store. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sandbox): support # volume, honor nsjail tmp instance settings, v2 docker template - Thread shared_mount into the sandbox container nsjail config so '# volume' mounts (and the same-worker /tmp/shared folder) apply inside the container. - Use resolve_nsjail_tmp_mount_block for the container's /tmp so it honors the same nsjail_tmp_backing / nsjail_tmpfs_size_mb instance settings as other nsjail jobs. - docker-compose comment + the editor's Docker template now use '# sandbox '. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sandbox): make image size/cache/pull-policy UI instance settings Convert SANDBOX_IMAGE_* from worker env vars to DB-backed instance settings (sandbox_image_max_size_mb, sandbox_image_cache_max_mb, sandbox_image_pull_policy), hot-reloaded via the same mechanism as nsjail_tmpfs_size_mb and configurable in #superadmin-settings. No worker restart needed. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sandbox): windmill-managed registry — default registry + private auth Two new instance settings: - sandbox_image_default_registry: prepended to unqualified image refs (alpine -> /alpine); fully-qualified refs untouched. - sandbox_registry_auth: docker/podman auth.json blob written to a per-job authfile (0600, removed with the job) and passed to podman --authfile for private registries. Both hot-reloaded and configurable in #superadmin-settings. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sandbox): protobuf-safe proto_str escaper, atomic 0600 authfile, registry tests Addresses local-review P2s: proto_str now emits valid protobuf octal escapes for control/non-ASCII bytes (not Rust \u{..} that nsjail would reject); the registry authfile is created 0600 atomically (no world-readable window); add a registry_qualified table test + a non-ASCII proto_str case. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sandbox): P0 — deliver image env via nsjail envar:, never the launcher process env CI review (P0): the image's OCI Env (attacker-controlled keys+values) was applied to the nsjail launcher process via .envs(), so a hostile image could set LD_PRELOAD/ LD_LIBRARY_PATH/LD_AUDIT on nsjail itself and execute code as the worker outside the jail. Now the image env is rendered as proto-escaped 'envar:' directives (child-only) and nsjail's process env carries only windmill-trusted keys (reserved vars + proxy). Also: warn instead of silently bypassing the size guard on inspect failure; reset the eviction guard via a Drop guard (no stuck flag on panic/early-return). +render_envars test. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sandbox): P0 symlink-write escape via rootfs script; P1 redact registry-auth logging CI review: - P0 (Codex): the body was written into the image-controlled rootfs as .windmill_docker_main.sh via write_file (follows symlinks) — a hostile image could plant that path as a symlink to a host file and capture the worker's write before nsjail starts. Now the body is passed straight to 'sh -c sh '; no file is written into the rootfs at all. - P1 (Codex): sandbox_registry_auth flowed through the generic setting loader which logs the value (raw auth.json credentials). Replaced with a secret-aware reload that loads directly and logs only a redacted 'configured=' message. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sandbox): redact sandbox_registry_auth in instance-settings write log too The settings API also logs 'Set global setting to ' via format_setting_value; add sandbox_registry_auth to SENSITIVE_SETTINGS so the credential is redacted there as well as on reload. * fix(sandbox): don't silently disable cache eviction on podman images parse error Re-review (cubic/Claude P2): serde_json::from_slice(...).unwrap_or_default() meant any parse hiccup (e.g. podman omitting Size/Created via omitempty for a zero value, or schema drift) silently degraded to an empty Vec and disabled eviction with no log. Now Size/Created are #[serde(default)] (a missing omitempty key -> 0, not a whole-array parse failure) and a real parse error warns + breaks instead of being swallowed. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/src/main.rs | 33 +- backend/src/monitor.rs | 75 +- .../windmill-common/src/global_settings.rs | 5 + .../windmill-common/src/instance_config.rs | 1 + backend/windmill-common/src/worker.rs | 59 ++ .../nsjail/run.docker.config.proto | 103 +++ backend/windmill-worker/src/bash_executor.rs | 36 +- backend/windmill-worker/src/common.rs | 10 + backend/windmill-worker/src/docker_v2.rs | 683 ++++++++++++++++++ backend/windmill-worker/src/lib.rs | 1 + backend/windmill-worker/src/worker.rs | 21 + docker-compose.yml | 35 +- docs/docker-v2-runtime.md | 106 +++ .../src/lib/components/ScriptBuilder.svelte | 15 - .../flows/content/FlowInputs.svelte | 19 - .../flows/content/FlowInputsQuick.svelte | 19 - .../src/lib/components/instanceSettings.ts | 54 ++ frontend/src/lib/script_helpers.ts | 21 +- 18 files changed, 1181 insertions(+), 115 deletions(-) create mode 100644 backend/windmill-worker/nsjail/run.docker.config.proto create mode 100644 backend/windmill-worker/src/docker_v2.rs create mode 100644 docs/docker-v2-runtime.md diff --git a/backend/src/main.rs b/backend/src/main.rs index e5daf3201b..b4d3cef4f9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -57,11 +57,14 @@ use windmill_common::{ PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, - SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, - TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, - UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, - WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, - WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_REGISTRIES_SETTING, + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, SANDBOX_IMAGE_PULL_POLICY_SETTING, + SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, + STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, + WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -134,8 +137,11 @@ use crate::monitor::{ reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting, reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting, - reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, - reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, + reload_pip_index_url_setting, reload_retention_period_setting, + reload_sandbox_image_cache_max_setting, reload_sandbox_image_default_registry_setting, + reload_sandbox_image_max_size_setting, reload_sandbox_image_pull_policy_setting, + reload_sandbox_registry_auth_setting, reload_scim_token_setting, reload_smtp_config, + reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting, reload_worker_config, MonitorIteration, }; @@ -1827,6 +1833,19 @@ async fn process_notify_event( JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await, NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await, NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING => { + reload_sandbox_image_max_size_setting(conn).await + } + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING => { + reload_sandbox_image_cache_max_setting(conn).await + } + SANDBOX_IMAGE_PULL_POLICY_SETTING => { + reload_sandbox_image_pull_policy_setting(conn).await + } + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING => { + reload_sandbox_image_default_registry_setting(conn).await + } + SANDBOX_REGISTRY_AUTH_SETTING => reload_sandbox_registry_auth_setting(conn).await, #[cfg(feature = "parquet")] OBJECT_STORE_CONFIG_SETTING => { if !disable_s3_store { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 13f52037e7..789706e7f8 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -66,7 +66,9 @@ use windmill_common::{ OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, - RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, + RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, + SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, @@ -112,8 +114,10 @@ use windmill_worker::{ JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB, NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, - PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, - UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB, + SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, + SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, + UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -407,6 +411,11 @@ pub async fn initial_load( reload_job_isolation_setting(&conn).await; reload_nsjail_tmpfs_size_setting(&conn).await; reload_nsjail_tmp_backing_setting(&conn).await; + reload_sandbox_image_max_size_setting(&conn).await; + reload_sandbox_image_cache_max_setting(&conn).await; + reload_sandbox_image_pull_policy_setting(&conn).await; + reload_sandbox_image_default_registry_setting(&conn).await; + reload_sandbox_registry_auth_setting(&conn).await; reload_extra_pip_index_url_setting(&conn).await; reload_pip_index_url_setting(&conn).await; reload_uv_index_strategy_setting(&conn).await; @@ -2045,6 +2054,66 @@ pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) { .await; } +pub async fn reload_sandbox_image_max_size_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, + "SANDBOX_IMAGE_MAX_SIZE_MB", + SANDBOX_IMAGE_MAX_SIZE_MB.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_cache_max_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, + "SANDBOX_IMAGE_CACHE_MAX_MB", + SANDBOX_IMAGE_CACHE_MAX_MB.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_pull_policy_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_PULL_POLICY_SETTING, + "SANDBOX_IMAGE_PULL_POLICY", + SANDBOX_IMAGE_PULL_POLICY.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_default_registry_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, + "SANDBOX_IMAGE_DEFAULT_REGISTRY", + SANDBOX_IMAGE_DEFAULT_REGISTRY.clone(), + ) + .await; +} + +pub async fn reload_sandbox_registry_auth_setting(conn: &Connection) { + // Secret-aware: the value is a raw docker/podman auth.json with credentials, so + // it must never be logged. Load directly (the generic reload_option_setting path + // logs the value via load_option_setting_value) and only log a redacted message. + let q = + match load_value_from_global_settings_with_conn(conn, SANDBOX_REGISTRY_AUTH_SETTING, true) + .await + { + Ok(q) => q, + Err(e) => { + tracing::error!("Error reloading setting SANDBOX_REGISTRY_AUTH: {e:?}"); + return; + } + }; + let value = q.and_then(|q| serde_json::from_value::(q).ok()); + let configured = value.as_ref().is_some_and(|v| !v.trim().is_empty()); + *SANDBOX_REGISTRY_AUTH.write().await = value; + tracing::info!("Loaded setting SANDBOX_REGISTRY_AUTH (redacted), configured={configured}"); +} + pub async fn reload_job_isolation_setting(conn: &Connection) { let value = match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await { diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 75f3fdf06b..8192f186d0 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,6 +58,11 @@ pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb"; pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing"; pub const NSJAIL_TMP_BACKING_DISK: &str = "disk"; pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs"; +pub const SANDBOX_IMAGE_MAX_SIZE_MB_SETTING: &str = "sandbox_image_max_size_mb"; +pub const SANDBOX_IMAGE_CACHE_MAX_MB_SETTING: &str = "sandbox_image_cache_max_mb"; +pub const SANDBOX_IMAGE_PULL_POLICY_SETTING: &str = "sandbox_image_pull_policy"; +pub const SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING: &str = "sandbox_image_default_registry"; +pub const SANDBOX_REGISTRY_AUTH_SETTING: &str = "sandbox_registry_auth"; pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 2239868982..bb56d5d0cc 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -976,6 +976,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ "ruby_repos", "powershell_repo_pat", "workspace_registries", + "sandbox_registry_auth", ]; /// Object-valued settings that contain sensitive sub-fields. diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 92ebf08477..c7816d7a70 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -859,6 +859,37 @@ pub struct BashAnnotations { pub sandbox: bool, } +impl BashAnnotations { + /// If the script declares `# sandbox ` (an image ref after the sandbox + /// annotation), returns that image ref. This selects the daemonless, sandboxed + /// container runtime: extract the image's rootfs and run it inside the job's + /// nsjail sandbox. + /// + /// A bare `# sandbox` (no image argument) returns `None` and keeps the plain + /// nsjail-sandboxed-bash behavior (the `sandbox` boolean modifier). `# docker` + /// is unaffected and keeps the legacy v1 (dind/daemon) path. + pub fn sandbox_image(code: &str) -> Option { + for line in code.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Mirror the annotation parser: stop at the first non-comment line. + if !line.starts_with('#') { + break; + } + let mut tokens = line[1..].split_whitespace(); + if tokens.next() == Some("sandbox") { + // `# sandbox ` -> container; bare `# sandbox` -> nsjail bash. + if let Some(image) = tokens.next() { + return Some(image.to_string()); + } + } + } + None + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum SqlResultCollectionStrategy { LastStatementAllRows, @@ -2224,6 +2255,34 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn test_bash_sandbox_image_annotation() { + // `# sandbox ` selects the container runtime and returns the image. + assert_eq!( + BashAnnotations::sandbox_image("# sandbox alpine:latest\necho hi"), + Some("alpine:latest".to_string()) + ); + // Extra whitespace and a leading non-spaced `#` still work. + assert_eq!( + BashAnnotations::sandbox_image("#sandbox python:3.12-slim\n"), + Some("python:3.12-slim".to_string()) + ); + // A bare `# sandbox` (no image) keeps the nsjail-bash modifier -> None. + assert_eq!(BashAnnotations::sandbox_image("# sandbox\necho hi"), None); + // `sandbox` must be its own token, not a prefix. + assert_eq!(BashAnnotations::sandbox_image("# sandboxed foo"), None); + // Stops at the first non-comment line (image declared too late is ignored). + assert_eq!( + BashAnnotations::sandbox_image("echo hi\n# sandbox alpine"), + None + ); + // `# docker` is a different annotation -> not a sandbox image. + assert_eq!( + BashAnnotations::sandbox_image("# docker alpine\necho hi"), + None + ); + } + #[test] fn test_mixed_tags() { let input = vec![ diff --git a/backend/windmill-worker/nsjail/run.docker.config.proto b/backend/windmill-worker/nsjail/run.docker.config.proto new file mode 100644 index 0000000000..a2da459fbe --- /dev/null +++ b/backend/windmill-worker/nsjail/run.docker.config.proto @@ -0,0 +1,103 @@ +name: "docker v2 run" + +mode: ONCE +hostname: "container" +log_level: ERROR +time_limit: {TIMEOUT} + +disable_rl: true + +cwd: {WORKDIR} + +clone_newnet: false +clone_newuser: {CLONE_NEWUSER} + +skip_setsid: true +keep_caps: false +# keep_env forwards nsjail's OWN process env (only windmill-trusted keys: reserved +# vars + proxy) to the child. The image's attacker-controlled Env is delivered via +# the envar directives below — NEVER nsjail's process env, so a hostile image cannot +# set LD_PRELOAD/LD_LIBRARY_PATH/LD_AUDIT on the nsjail binary itself. +keep_env: true +mount_proc: true + +# Image Env (+ PATH/HOME fallbacks), proto-escaped. Applied to the child only. +{ENVARS} + +# Map uid/gid 0 inside the jail to the (single) worker user outside. The image's +# rootfs is extracted as the worker user, so a root process inside the container +# owns the rootfs and runs like a normal "root in container" — without any subuid +# range. Multi-uid images are a later enhancement (newuidmap range). +uidmap { + inside_id: "0" + outside_id: "" + count: 1 +} +gidmap { + inside_id: "0" + outside_id: "" + count: 1 +} + +# The image's root filesystem, bound one top-level entry at a time. Binding the +# whole rootfs at "/" trips nsjail's read-only remount of its base root in a +# rootless userns ("mount(... MS_REMOUNT|MS_BIND|MS_RDONLY): Operation not +# permitted"); per-entry binds sit as rw submounts under nsjail's own tmpfs root +# and avoid it. Generated from the extracted rootfs. +{ROOTFS_MOUNTS} + +# Pseudo-filesystems the image expects. /tmp honors the same instance settings as +# every other nsjail job (nsjail_tmp_backing tmpfs/disk, nsjail_tmpfs_size_mb); +# /dev gets the standard nodes; /proc comes from mount_proc (the jail's own pid ns). +{TMP_MOUNT_BLOCK} + +mount { + src: "/dev/null" + dst: "/dev/null" + is_bind: true + rw: true +} + +mount { + src: "/dev/zero" + dst: "/dev/zero" + is_bind: true + rw: true +} + +mount { + src: "/dev/random" + dst: "/dev/random" + is_bind: true +} + +mount { + src: "/dev/urandom" + dst: "/dev/urandom" + is_bind: true +} + +# Host DNS config layered over the image's /etc so name resolution works on the +# job's network (mandatory:false: some minimal images have no /etc files to shadow). +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 +} + +# `# volume` mounts (and the same-worker /tmp/shared folder). Placed after the +# rootfs binds and the tmpfs /tmp so a volume target overrides any colliding image +# path and isn't shadowed by the tmpfs. Empty when there are no volumes. +{SHARED_MOUNT} + +iface_no_lo: true + +#{DEV} diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 516c75fdba..4c470b8dfc 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -40,9 +40,9 @@ use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ common::{ - build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + build_args_map, build_command_with_isolation, get_reserved_variables, raw_to_string, + read_file, read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -57,14 +57,6 @@ lazy_static::lazy_static! { pub static ref ANSI_ESCAPE_RE: Regex = Regex::new(r"\x1b\[[0-9;]*m").unwrap(); } -fn raw_to_string(x: &str) -> String { - match serde_json::from_str::(x) { - Ok(serde_json::Value::String(x)) => x, - Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()), - _ => String::new(), - } -} - #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_bash_job( mem_peak: &mut i32, @@ -84,6 +76,28 @@ pub async fn handle_bash_job( ) -> Result, Error> { let annotation = windmill_common::worker::BashAnnotations::parse(&content); + // `# sandbox ` selects the daemonless, nsjail-sandboxed container runtime + // (extract the image's rootfs + run it inside the job's sandbox). A bare + // `# sandbox` keeps the plain nsjail-bash modifier; `# docker` keeps v1 (dind). + if let Some(image) = windmill_common::worker::BashAnnotations::sandbox_image(content) { + return crate::docker_v2::handle_docker_v2_job( + &image, + mem_peak, + canceled_by, + job, + conn, + client, + parent_runnable_path, + content, + job_dir, + shared_mount, + base_internal_url, + worker_name, + occupancy_metrics, + ) + .await; + } + // Check if sandbox annotation is used but nsjail is not available if annotation.sandbox && NSJAIL_AVAILABLE.is_none() { return Err(Error::ExecutionErr( diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 9864093cd0..1bef9e4c6d 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -68,6 +68,16 @@ mount { #[cfg(not(debug_assertions))] pub const DEV_CONF_NSJAIL: &str = ""; +/// Turn a JSON value into the string a shell/CLI arg should receive: a JSON string +/// becomes its inner value, anything else is re-serialized compactly. +pub(crate) fn raw_to_string(x: &str) -> String { + match serde_json::from_str::(x) { + Ok(serde_json::Value::String(x)) => x, + Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()), + _ => String::new(), + } +} + pub async fn build_args_map<'a>( job: &'a MiniPulledJob, client: &AuthedClient, diff --git a/backend/windmill-worker/src/docker_v2.rs b/backend/windmill-worker/src/docker_v2.rs new file mode 100644 index 0000000000..6a99d252f3 --- /dev/null +++ b/backend/windmill-worker/src/docker_v2.rs @@ -0,0 +1,683 @@ +//! Sandboxed container runtime: run a container as a sandboxed subprogram of the job. +//! +//! Unlike the legacy `# docker` (dind/daemon) path, this has no daemon and no Docker +//! API. It splits *pull* from *run*: +//! +//! 1. **pull/extract** (podman, rootless): materialize the image's root filesystem +//! into `{job_dir}/rootfs` and read its OCI config (Env/Cmd/Entrypoint/WorkingDir). +//! 2. **run** (the job's own nsjail sandbox): execute the image command with the +//! extracted rootfs bound in as the new root, so the container inherits exactly +//! the job's confinement (filesystem mask, pid namespace, network, uid) and can't +//! escape past what the job itself can reach. +//! +//! Selected by `# sandbox ` (a bare `# sandbox` keeps plain nsjail-bash; +//! `# docker` keeps the v1 daemon path). The script body runs inside the image via +//! `/bin/sh`; an empty body runs the image's ENTRYPOINT/CMD. + +use std::process::Stdio; + +use serde::Deserialize; +use serde_json::{json, value::RawValue}; +use sqlx::types::Json; +use tokio::process::Command; + +use windmill_common::{client::AuthedClient, scripts::ScriptLang}; +use windmill_common::{ + error::Error, + worker::{to_raw_value, write_file, Connection}, +}; + +use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; + +use crate::{ + common::{ + build_args_map, get_reserved_variables, raw_to_string, resolve_nsjail_timeout, + resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + }, + get_proxy_envs_for_lang, + handle_child::handle_child, + DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB, + SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, + SANDBOX_REGISTRY_AUTH, +}; + +const NSJAIL_CONFIG_RUN_DOCKER_CONTENT: &str = include_str!("../nsjail/run.docker.config.proto"); + +const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +lazy_static::lazy_static! { + pub static ref PODMAN_PATH: String = + std::env::var("PODMAN_PATH").unwrap_or_else(|_| "podman".to_string()); +} + +/// Guards against overlapping cache-eviction passes across concurrent jobs. +static EVICTION_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// podman pull policy from the `sandbox_image_pull_policy` instance setting. `newer` +/// (the default when unset/invalid) re-pulls only when the registry digest changed — +/// one cheap manifest check per job, no transfer if unchanged — so moving tags like +/// `:latest` don't go stale. `missing` is fastest (tags can go stale); `always` +/// re-checks every job. +async fn pull_policy() -> String { + let p = SANDBOX_IMAGE_PULL_POLICY.read().await.clone(); + match p.as_deref() { + Some(p @ ("missing" | "newer" | "always" | "never")) => p.to_string(), + _ => "newer".to_string(), + } +} + +/// `sandbox_image_max_size_mb` instance setting; 0 (or unset/non-positive) = no limit. +async fn max_image_size_mb() -> u64 { + SANDBOX_IMAGE_MAX_SIZE_MB.read().await.unwrap_or(0).max(0) as u64 +} + +/// `sandbox_image_cache_max_mb` instance setting; 0 (or unset/non-positive) = unbounded. +async fn image_cache_max_mb() -> u64 { + SANDBOX_IMAGE_CACHE_MAX_MB.read().await.unwrap_or(0).max(0) as u64 +} + +/// A ref is registry-qualified if the component before the first `/` looks like a +/// host (contains `.` or `:`, or is `localhost`). Bare repos (`alpine`, +/// `alpine:latest`, `myorg/img`) are unqualified and resolve against docker.io — +/// or the configured default registry. +fn registry_qualified(image: &str) -> bool { + match image.split_once('/') { + None => false, + Some((first, _)) => first.contains('.') || first.contains(':') || first == "localhost", + } +} + +/// Prepend the `sandbox_image_default_registry` instance setting to unqualified image +/// refs (fully-qualified refs are left untouched). +async fn resolve_image_ref(image: &str) -> String { + let registry = SANDBOX_IMAGE_DEFAULT_REGISTRY.read().await.clone(); + match registry { + Some(registry) if !registry.trim().is_empty() && !registry_qualified(image) => { + format!("{}/{}", registry.trim().trim_end_matches('/'), image) + } + _ => image.to_string(), + } +} + +/// If the `sandbox_registry_auth` instance setting holds a docker/podman `auth.json` +/// blob, write it to a per-job authfile (0600, removed with the job) and return its +/// path to pass to `podman --authfile`. Returns `None` when unset. +async fn write_auth_file(job_dir: &str) -> Result, Error> { + let auth = SANDBOX_REGISTRY_AUTH.read().await.clone(); + let Some(auth) = auth.filter(|a| !a.trim().is_empty()) else { + return Ok(None); + }; + let path = format!("{job_dir}/registry_auth.json"); + // Create 0600 from the start (registry credentials) — no world-readable window. + #[cfg(unix)] + { + use tokio::io::AsyncWriteExt; + let mut f = tokio::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path) + .await?; + f.write_all(auth.as_bytes()).await?; + } + #[cfg(not(unix))] + tokio::fs::write(&path, auth).await?; + Ok(Some(path)) +} + +/// The subset of an image's OCI config we apply to the run. +#[derive(Deserialize, Default, Debug)] +struct OciConfig { + #[serde(default, rename = "Env")] + env: Option>, + #[serde(default, rename = "Cmd")] + cmd: Option>, + #[serde(default, rename = "Entrypoint")] + entrypoint: Option>, + #[serde(default, rename = "WorkingDir")] + working_dir: Option, +} + +/// Quote a string as a protobuf-text-format string literal for safe inclusion in +/// the nsjail config. Image-controlled values (mount srcs/dsts, symlink targets, +/// WorkingDir) flow into the config, so they MUST be escaped — an unescaped `"` or +/// newline would otherwise let a hostile image config inject arbitrary nsjail +/// directives and break out of the sandbox. Every byte is emitted as a printable +/// ASCII char or a valid protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit +/// octal `\NNN` for control/non-ASCII bytes), so the result always parses. +fn proto_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for &b in s.as_bytes() { + match b { + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + 0x20..=0x7e => out.push(b as char), + _ => out.push_str(&format!("\\{b:03o}")), + } + } + out.push('"'); + out +} + +/// Render container env vars as nsjail `envar:` directives (one per line). Each +/// `KEY=VALUE` is proto-escaped, so image-controlled keys/values can neither break +/// the config nor reach nsjail's own process environment. +fn render_envars(env: &[(String, String)]) -> String { + env.iter() + .map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}")))) + .collect::>() + .join("\n") +} + +async fn podman(args: &[&str]) -> Result { + Command::new(PODMAN_PATH.as_str()) + .args(args) + .output() + .await + .map_err(|e| Error::ExecutionErr(format!("failed to run podman {}: {e}", args.join(" ")))) +} + +/// Pull (if needed) and unpack `image` into `{job_dir}/rootfs`, returning its OCI +/// config. Uses podman rootless: `create` (auto-pulls) + `export | tar -x`, with the +/// config read from the resulting container (== image config, no command override). +async fn extract_image(image: &str, job_dir: &str) -> Result { + let rootfs = format!("{job_dir}/rootfs"); + tokio::fs::create_dir_all(&rootfs).await?; + + // `podman create` (no command) pulls the image per the configured policy and + // records the image's own Cmd/Entrypoint, which we then read back from the + // container config. `--` guards against an `image` ref that starts with `-` being + // parsed as a flag (e.g. `--authfile=...`) — the ref is attacker-controlled in + // the untrusted case. + let pull = format!("--pull={}", pull_policy().await); + let mut create_args = vec!["create", &pull]; + let authfile = write_auth_file(job_dir).await?; + if let Some(authfile) = authfile.as_deref() { + create_args.push("--authfile"); + create_args.push(authfile); + } + create_args.push("--"); + create_args.push(image); + let created = podman(&create_args).await?; + if !created.status.success() { + return Err(Error::ExecutionErr(format!( + "failed to pull/create image {image}: {}", + String::from_utf8_lossy(&created.stderr) + ))); + } + let container_id = String::from_utf8_lossy(&created.stdout).trim().to_string(); + + // Always clean up the container, even on a later failure. + let result = extract_created(image, &container_id, &rootfs).await; + let _ = podman(&["rm", "-f", &container_id]).await; + result +} + +async fn extract_created( + image: &str, + container_id: &str, + rootfs: &str, +) -> Result { + // Reject oversized images before paying the (large) extraction cost. + enforce_image_size_limit(image).await?; + + let inspected = podman(&["inspect", container_id, "--format", "{{json .Config}}"]).await?; + if !inspected.status.success() { + return Err(Error::ExecutionErr(format!( + "failed to inspect image {image}: {}", + String::from_utf8_lossy(&inspected.stderr) + ))); + } + let config: OciConfig = serde_json::from_slice(&inspected.stdout) + .map_err(|e| Error::ExecutionErr(format!("failed to parse image {image} config: {e}")))?; + + // Flatten the image's layers into a rootfs directory. Go through a tar on disk + // (in the job dir, cleaned up with the job) rather than a shell pipe. Extracted + // as the worker user, so the rootfs is owned by the worker user — which the + // single-uid jail maps to uid 0 inside. + let tar_path = format!("{rootfs}.tar"); + let exported = podman(&["export", container_id, "--output", &tar_path]).await?; + if !exported.status.success() { + let _ = tokio::fs::remove_file(&tar_path).await; + return Err(Error::ExecutionErr(format!( + "failed to export image {image}: {}", + String::from_utf8_lossy(&exported.stderr) + ))); + } + let untar = Command::new("tar") + .args(["-xf", &tar_path, "-C", rootfs]) + .output() + .await + .map_err(|e| Error::ExecutionErr(format!("failed to run tar: {e}")))?; + let _ = tokio::fs::remove_file(&tar_path).await; + if !untar.status.success() { + return Err(Error::ExecutionErr(format!( + "failed to unpack image {image}: {}", + String::from_utf8_lossy(&untar.stderr) + ))); + } + + Ok(config) +} + +/// Reject the image if its on-disk (uncompressed) size exceeds +/// `SANDBOX_IMAGE_MAX_SIZE_MB`. No-op when the limit is 0 (unset). +async fn enforce_image_size_limit(image: &str) -> Result<(), Error> { + let max = max_image_size_mb().await; + if max == 0 { + return Ok(()); + } + let out = podman(&["image", "inspect", image, "--format", "{{.Size}}"]).await?; + if !out.status.success() { + // Don't silently bypass the guard — surface it so an operator can see the + // size limit isn't being enforced for this image. + tracing::warn!( + "sandbox image size guard: `podman image inspect {image}` failed, not \ + enforcing SANDBOX_IMAGE_MAX_SIZE_MB: {}", + String::from_utf8_lossy(&out.stderr) + ); + return Ok(()); + } + let bytes: u64 = String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .unwrap_or(0); + let mb = bytes / 1_000_000; + if mb > max { + return Err(Error::ExecutionErr(format!( + "image {image} is {mb} MB, over the SANDBOX_IMAGE_MAX_SIZE_MB limit of {max} MB" + ))); + } + Ok(()) +} + +#[derive(Deserialize)] +struct PodmanImage { + #[serde(rename = "Id")] + id: String, + // `default`: podman tags Size/Created `omitempty`, so a degenerate image with a + // zero value drops the key — without this the whole array would fail to parse. + #[serde(default, rename = "Size")] + size: u64, + #[serde(default, rename = "Created")] + created: i64, +} + +/// Best-effort eviction: while the summed size of podman's images exceeds +/// `SANDBOX_IMAGE_CACHE_MAX_MB`, remove the oldest (by created time, an LRU proxy). +/// No-op when the limit is 0 (unset). Skipped if another pass is already running. +/// Images currently backing a container (e.g. a concurrent job mid-extract) fail +/// `rmi` and stop the pass, so in-use images are never removed. +async fn enforce_image_cache_limit() { + use std::sync::atomic::Ordering; + let max_mb = image_cache_max_mb().await; + if max_mb == 0 { + return; + } + if EVICTION_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + // Reset the guard on every exit path (incl. an early `break` or a panic), so a + // stuck flag can never permanently disable eviction until a worker restart. + struct ResetOnDrop; + impl Drop for ResetOnDrop { + fn drop(&mut self) { + EVICTION_RUNNING.store(false, std::sync::atomic::Ordering::SeqCst); + } + } + let _reset = ResetOnDrop; + let max_bytes = max_mb.saturating_mul(1_000_000); + loop { + let Ok(out) = podman(&["images", "--format", "json"]).await else { + break; + }; + if !out.status.success() { + break; + } + let mut imgs: Vec = match serde_json::from_slice(&out.stdout) { + Ok(v) => v, + Err(e) => { + // Don't silently disable eviction on a schema hiccup — surface it. + tracing::warn!( + "sandbox image cache eviction: cannot parse `podman images` json: {e}" + ); + break; + } + }; + let total: u64 = imgs.iter().map(|i| i.size).sum(); + if total <= max_bytes || imgs.is_empty() { + break; + } + imgs.sort_by_key(|i| i.created); + let victim = imgs[0].id.clone(); + match podman(&["rmi", &victim]).await { + Ok(rm) if rm.status.success() => { + tracing::info!("sandbox image cache eviction: removed {victim}"); + } + Ok(rm) => { + tracing::warn!( + "sandbox image cache eviction: cannot remove {victim} (in use?): {}", + String::from_utf8_lossy(&rm.stderr) + ); + break; + } + Err(_) => break, + } + } + // `_reset` drops here and clears EVICTION_RUNNING. +} + +/// Build the nsjail mount block that binds each top-level entry of the rootfs in +/// place. Binding the whole rootfs at `/` trips nsjail's read-only remount of its +/// base root in a rootless userns; per-entry binds avoid it. `proc`, `dev`, `tmp` +/// and `sys` are skipped — the profile provides them. +async fn generate_rootfs_mounts(rootfs: &str) -> Result { + let mut block = String::new(); + let mut entries = tokio::fs::read_dir(rootfs).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if matches!(name.as_ref(), "proc" | "dev" | "tmp" | "sys") { + continue; + } + let src = proto_str(&format!("{rootfs}/{name}")); + let dst = proto_str(&format!("/{name}")); + let file_type = entry.file_type().await?; + if file_type.is_symlink() { + // Recreate top-level symlinks (e.g. usr-merged /bin -> usr/bin) as + // symlinks in the jail. The target is image-controlled but only ever + // *resolved inside the jail* (against the bound rootfs dirs / jail + // pseudo-fs) — there is no host `/` in the jail for it to point at — and + // it is escaped via proto_str, so it can neither escape nor inject config. + let target = tokio::fs::read_link(entry.path()) + .await + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + block.push_str(&format!( + "mount {{\n src: {}\n dst: {dst}\n is_symlink: true\n mandatory: false\n}}\n", + proto_str(&target), + )); + } else { + block.push_str(&format!( + "mount {{\n src: {src}\n dst: {dst}\n is_bind: true\n rw: true\n mandatory: false\n}}\n", + )); + } + } + Ok(block) +} + +#[tracing::instrument(level = "trace", skip_all)] +pub async fn handle_docker_v2_job( + image: &str, + mem_peak: &mut i32, + canceled_by: &mut Option, + job: &MiniPulledJob, + conn: &Connection, + client: &AuthedClient, + parent_runnable_path: Option, + content: &str, + job_dir: &str, + shared_mount: &str, + base_internal_url: &str, + worker_name: &str, + occupancy_metrics: &mut OccupancyMetrics, +) -> Result, Error> { + // The sandboxed container runtime *is* nsjail, so it requires nsjail. (`# docker` + // keeps the v1 dind path for non-sandboxed workers.) + if NSJAIL_AVAILABLE.is_none() { + return Err(Error::ExecutionErr(format!( + "`# sandbox {image}` runs the image inside nsjail, which is not available on \ + this worker. Install nsjail, or use a bare `# docker` (dind) instead." + ))); + } + + // Apply the default-registry instance setting to unqualified refs. + let resolved_image = resolve_image_ref(image).await; + let image = resolved_image.as_str(); + + append_logs( + &job.id, + &job.workspace_id, + format!("\n\n--- SANDBOXED CONTAINER (nsjail) ---\nextracting image {image}...\n"), + conn, + ) + .await; + + let config = extract_image(image, job_dir).await?; + let rootfs = format!("{job_dir}/rootfs"); + + // Best-effort: keep podman's image store under its size cap (overlaps the run). + tokio::spawn(enforce_image_cache_limit()); + + // Resolve the script args from the bash signature, like the bash executor. + let args = build_args_map(job, client, conn).await?.map(Json); + let job_args = if args.is_some() { + args.as_ref() + } else { + job.args.as_ref() + }; + let args_owned = windmill_parser_bash::parse_bash_sig(content)? + .args + .iter() + .map(|arg| { + job_args + .and_then(|x| x.get(&arg.name).map(|x| raw_to_string(x.get()))) + .unwrap_or_else(String::new) + }) + .collect::>(); + + // The body is everything that isn't a leading `#` annotation/comment line. With + // a body we run it via the image's `/bin/sh`; without one we run the image's + // ENTRYPOINT + CMD. + let has_body = content + .lines() + .any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#')); + + let cmd_args: Vec = if has_body { + // Pass the body straight to `sh -c` rather than writing a script file into + // the image-controlled rootfs: a malicious image could plant that path as a + // symlink to a host file and capture the worker's write before nsjail starts + // (sandbox-boundary bypass). `sh -c sh ` binds args as $1.. . + let mut v = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!("set -e\n{content}"), + "sh".to_string(), + ]; + v.extend(args_owned.iter().cloned()); + v + } else { + let mut v = config.entrypoint.clone().unwrap_or_default(); + v.extend(config.cmd.clone().unwrap_or_default()); + if v.is_empty() { + return Err(Error::ExecutionErr(format!( + "image {image} has no ENTRYPOINT/CMD and the script body is empty — \ + nothing to run" + ))); + } + v.extend(args_owned.iter().cloned()); + v + }; + + let working_dir = config + .working_dir + .as_deref() + .filter(|w| !w.is_empty()) + .unwrap_or("/"); + + // The image's OCI Env is attacker-controlled (BOTH keys and values), so it must + // NOT enter the nsjail launcher's own process env: a hostile image could set + // LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT and have the dynamic loader run code in + // the nsjail binary as the worker — outside the jail — before it sandboxes. + // Deliver it to the *child only* via proto-escaped `envar:` directives. + let mut container_env: Vec<(String, String)> = Vec::new(); + for kv in config.env.unwrap_or_default() { + if let Some((k, v)) = kv.split_once('=') { + container_env.push((k.to_string(), v.to_string())); + } + } + if !container_env.iter().any(|(k, _)| k == "PATH") { + container_env.push(("PATH".to_string(), DEFAULT_PATH.to_string())); + } + if !container_env.iter().any(|(k, _)| k == "HOME") { + container_env.push(("HOME".to_string(), "/root".to_string())); + } + let envars = render_envars(&container_env); + + // Render the nsjail profile: dynamic per-entry rootfs binds + image WorkingDir. + let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; + let rootfs_mounts = generate_rootfs_mounts(&rootfs).await?; + write_file( + job_dir, + "run.docker.config.proto", + &NSJAIL_CONFIG_RUN_DOCKER_CONTENT + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + // proto_str-quoted: WorkingDir is image-controlled, must not break out + // of the `cwd:` string and inject nsjail directives. + .replace("{WORKDIR}", &proto_str(working_dir)) + .replace("{ROOTFS_MOUNTS}", &rootfs_mounts) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + // `# volume` mounts + same-worker shared folder (empty if none). + .replace("{SHARED_MOUNT}", shared_mount) + // Image env as `envar:` directives (child-only), so it never touches + // nsjail's process env. + .replace("{ENVARS}", &envars) + .replace("#{DEV}", DEV_CONF_NSJAIL), + )?; + + // nsjail's OWN process env: only windmill-trusted keys (reserved vars so + // `wmill`/API calls work, + proxy). `keep_env: true` forwards these to the + // child. The image env is NOT here — see container_env above. + let mut reserved_variables = + get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; + reserved_variables.insert("RUST_LOG".to_string(), "info".to_string()); + reserved_variables.insert( + "BASE_INTERNAL_URL".to_string(), + base_internal_url.to_string(), + ); + + let proxy_envs = get_proxy_envs_for_lang( + &ScriptLang::Bash, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?; + + let mut nsjail_run_args = vec!["--config", "run.docker.config.proto", "--"]; + nsjail_run_args.extend(cmd_args.iter().map(|s| s.as_str())); + + let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); + nsjail_cmd + .current_dir(job_dir) + .env_clear() + .envs(reserved_variables) + .envs(proxy_envs) + .args(nsjail_run_args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?; + + handle_child( + &job.id, + conn, + mem_peak, + canceled_by, + child, + true, + worker_name, + &job.workspace_id, + "sandboxed container run", + job.timeout, + true, + &mut Some(occupancy_metrics), + None, + None, + ) + .await?; + + Ok(to_raw_value(&json!(format!( + "sandboxed container ({image}) completed successfully" + )))) +} + +#[cfg(test)] +mod tests { + use super::{proto_str, registry_qualified, render_envars}; + + #[test] + fn render_envars_emits_proto_directives() { + // Image-controlled env (incl. loader vars) is rendered as `envar:` directives + // — i.e. delivered to the child via the config, NOT nsjail's process env, so + // it can never set LD_PRELOAD/etc. on the nsjail binary itself. + let env = vec![ + ("PATH".to_string(), "/usr/bin".to_string()), + ("LD_PRELOAD".to_string(), "rootfs/evil.so".to_string()), + ]; + let out = render_envars(&env); + assert_eq!( + out, + "envar: \"PATH=/usr/bin\"\nenvar: \"LD_PRELOAD=rootfs/evil.so\"" + ); + // A value trying to inject extra directives is escaped, not interpreted. + let evil = vec![("X".to_string(), "v\"\nclone_newuser: false".to_string())]; + let line = render_envars(&evil); + assert!(line.starts_with("envar: \"")); + assert!(!line.contains("\nclone_newuser")); + assert!(line.contains("\\n")); + } + + #[test] + fn proto_str_escapes_injection() { + // Normal paths are just wrapped in quotes. + assert_eq!(proto_str("/app"), "\"/app\""); + // A `"` is escaped so it cannot close the surrounding string and inject + // subsequent nsjail directives — this is what the WorkingDir / mount-src + // sandboxing fixes depend on. + let malicious = "/x\"\nmount { src: \"/\" dst: \"/host\" is_bind: true }\n#"; + let escaped = proto_str(malicious); + assert!(escaped.starts_with('"') && escaped.ends_with('"')); + // No raw quote or newline survives inside the rendered literal. + let inner = &escaped[1..escaped.len() - 1]; + assert!(!inner.contains('\n')); + assert!(!inner.contains("\"") || inner.contains("\\\"")); + assert!(escaped.contains("\\\"")); // the inner quote is backslash-escaped + assert!(escaped.contains("\\n")); // the newline is escaped + // Control and non-ASCII bytes render as valid 3-digit octal escapes (never + // a raw byte or an invalid `\u{..}` that nsjail's parser would reject). + assert_eq!(proto_str("a\u{1b}b"), "\"a\\033b\""); // ESC (0x1b) + assert_eq!(proto_str("é"), "\"\\303\\251\""); // UTF-8 bytes 0xc3 0xa9 + } + + #[test] + fn registry_qualified_classifies_refs() { + // Unqualified: bare repos (with/without tag) and docker.io org/repo. + for img in ["alpine", "alpine:latest", "myorg/img", "myorg/img:1.2"] { + assert!(!registry_qualified(img), "{img} should be unqualified"); + } + // Qualified: the first path component is a host (has `.`/`:`) or localhost. + for img in [ + "ghcr.io/org/img", + "registry.example.com/img:tag", + "localhost:5000/img", + "localhost/img", + "host:5000/a/b", + ] { + assert!(registry_qualified(img), "{img} should be qualified"); + } + } +} diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 08f9381205..7727982cf8 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -31,6 +31,7 @@ mod csharp_executor; mod dedicated_worker_ee; mod dedicated_worker_oss; mod deno_executor; +mod docker_v2; #[cfg(feature = "duckdb")] mod duckdb_executor; mod global_cache; diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 7fb0dee9f5..b7ae7c2eed 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -694,6 +694,27 @@ lazy_static::lazy_static! { /// RAM-backed tmpfs sized by `nsjail_tmpfs_size_mb`. pub static ref NSJAIL_TMP_BACKING: Arc>> = Arc::new(RwLock::new(None)); + /// Reject a `# sandbox ` whose on-disk size exceeds this many MB, before + /// extraction. `None`/non-positive = no limit. (`sandbox_image_max_size_mb`.) + pub static ref SANDBOX_IMAGE_MAX_SIZE_MB: Arc>> = Arc::new(RwLock::new(None)); + + /// Best-effort cap (MB) on podman's sandbox-image store; oldest images evicted + /// after a run when exceeded. `None`/non-positive = unbounded. (`sandbox_image_cache_max_mb`.) + pub static ref SANDBOX_IMAGE_CACHE_MAX_MB: Arc>> = Arc::new(RwLock::new(None)); + + /// podman pull policy for sandbox images (`missing`/`newer`/`always`/`never`). + /// `None`/unrecognized falls back to `newer`. (`sandbox_image_pull_policy`.) + pub static ref SANDBOX_IMAGE_PULL_POLICY: Arc>> = Arc::new(RwLock::new(None)); + + /// If set, unqualified sandbox image refs (e.g. `alpine`) are pulled from this + /// registry instead of docker.io. Fully-qualified refs are unaffected. + /// (`sandbox_image_default_registry`.) + pub static ref SANDBOX_IMAGE_DEFAULT_REGISTRY: Arc>> = Arc::new(RwLock::new(None)); + + /// Optional docker/podman `auth.json` blob for private registries, written to a + /// per-job authfile and passed to `podman --authfile`. (`sandbox_registry_auth`.) + pub static ref SANDBOX_REGISTRY_AUTH: Arc>> = Arc::new(RwLock::new(None)); + /// Optional mirror URL for `uv python install`. Wires to the `UV_PYTHON_INSTALL_MIRROR` /// env var when forwarded to uv. Can be set via the `UV_PYTHON_INSTALL_MIRROR` env var /// or the `uv_python_install_mirror` instance setting. diff --git a/docker-compose.yml b/docker-compose.yml index 8b636c7702..75252cb802 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,25 +49,6 @@ services: logging: *default-logging - # Docker-in-Docker sidecar: provides an isolated Docker daemon so user scripts - # can run containers without accessing the host Docker socket. - dind: - image: docker:dind - privileged: true - restart: unless-stopped - environment: - DOCKER_TLS_CERTDIR: "" - volumes: - - dind-data:/var/lib/docker - expose: - - 2375 - healthcheck: - test: ["CMD", "docker", "info"] - interval: 10s - timeout: 5s - retries: 5 - logging: *default-logging - windmill_worker: image: ${WM_IMAGE} pull_policy: always @@ -89,22 +70,19 @@ services: # If running with non-root/non-windmill UID (e.g., user: "1001:1001"), # add: - HOME=/tmp - FAVOR_UNSHARE_PID=true - # Connect to the dind sidecar instead of the host Docker socket - - DOCKER_HOST=tcp://dind:2375 depends_on: db: condition: service_healthy - dind: - condition: service_healthy # to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill volumes: - worker_dependency_cache:/tmp/windmill/cache - worker_logs:/tmp/windmill/logs - ## WARNING: mounting the host Docker socket grants user scripts full access to - ## the host Docker daemon, enabling host filesystem access and privilege escalation. - ## Only use this if you fully trust all users who can run scripts. - ## To use it, remove the DOCKER_HOST env var and dind depends_on above, - ## and uncomment the line below: + ## Sandboxed containers (`# sandbox `) run daemonless via podman + nsjail + ## inside the worker itself — no Docker socket or dind sidecar required. + ## For the legacy full-compat docker (a bare `# docker`, trusted users only), + ## mount the host Docker socket by uncommenting the line below. WARNING: this + ## grants user scripts full access to the host Docker daemon (host filesystem + ## access and privilege escalation) — only use it if you fully trust all users. # - /var/run/docker.sock:/var/run/docker.sock logging: *default-logging @@ -237,4 +215,3 @@ volumes: windmill_index: null lsp_cache: null caddy_data: null - dind-data: null diff --git a/docs/docker-v2-runtime.md b/docs/docker-v2-runtime.md new file mode 100644 index 0000000000..c981e89b69 --- /dev/null +++ b/docs/docker-v2-runtime.md @@ -0,0 +1,106 @@ +# Sandboxed container runtime (daemonless docker) + +Windmill bash scripts can run a container image. There are **two** runtimes: + +| | legacy `# docker` | sandboxed `# sandbox ` | +|---|---|---| +| selected by | bare `# docker` | `# sandbox ` | +| runtime | dind / Docker daemon (bollard, `dind` feature) | daemonless: extract rootfs + nsjail-run | +| boundary | separate (daemon outside the jail) | the job's own nsjail sandbox | +| nsjail | not provided (trusted-tenant) | **required** — this *is* the sandbox | +| safety | trusted-tenant | sandboxed (untrusted-capable) | +| compat | full `docker run`/`-d`/API | run-a-command subset | + +The three bash annotations are distinct and don't overload each other: + +- `# docker` → legacy daemon docker (unchanged). +- `# sandbox` → run the bash script under nsjail. +- `# sandbox ` → run that image's command under nsjail (this runtime). + +## Using it + +Put the image ref on a `# sandbox` annotation line; the rest of the script runs +**inside** that image: + +```bash +# sandbox python:3.12-slim +name="$1" # windmill args bind positionally, like any bash script +python3 -c "import sys; print('hello', sys.argv[1])" "$name" +``` + +- The body runs via the image's `/bin/sh -c` (so the image needs a shell). +- An **empty** body runs the image's `ENTRYPOINT` + `CMD`. +- Windmill args (declared `x="$1"`, …) are appended to the command. +- The image's `Env`, `WorkingDir` are applied; the windmill reserved variables + (`WM_TOKEN`, `BASE_INTERNAL_URL`, …) are injected so `wmill`/API calls work. + +## How it works + +1. **Pull/extract** (podman, rootless): `podman create --pull= ` + + `podman export | tar -x` materializes the image's flattened root filesystem + into `{job_dir}/rootfs`, and `podman inspect` reads its OCI config. podman's + image store dedups pulls across jobs. +2. **Run** (the job's nsjail sandbox): nsjail binds each top-level entry of the + rootfs in place (binding the whole rootfs at `/` trips nsjail's read-only + remount of its base root in a rootless userns), mounts the standard + pseudo-filesystems (`/proc` from the jail's pid namespace, a tmpfs `/tmp`, + `/dev` nodes), maps uid/gid 0 inside → the worker user outside, and runs the + command. The container *is* the jail. + +``` +# sandbox ─▶ podman create+export ─▶ {job_dir}/rootfs ─▶ nsjail (chroot rootfs) + podman inspect (OCI config) ──────────────────▶ Env / Cmd / WorkingDir +``` + +Because the run is just the job's own nsjail with the image's filesystem as root, +the container inherits exactly the job's confinement: + +- **Filesystem**: only the rootfs + the job's mounts are visible — no host `/`, + no other job dirs, no dep cache. There is nothing to bind-mount escape to. +- **/proc**: the jail's own pid namespace — the worker and other jobs aren't + visible. +- **uid**: a single-uid jail — an escape lands as the unprivileged worker user. +- **network**: the job's network (same as any bash job). + +## Image storage, freshness & limits + +- **Where pulls live:** podman's rootless graph root (default + `$HOME/.local/share/containers/storage`) — persistent, dedups pulls across jobs. + The per-job extracted rootfs lives in `{job_dir}/rootfs` and is removed with the + job; the transient `rootfs.tar` is removed right after extraction. +- **Freshness (`SANDBOX_IMAGE_PULL_POLICY`, default `newer`):** `newer` re-pulls + only when the registry digest changed (one cheap manifest check per job, no data + transfer if unchanged) — so moving tags like `:latest` don't go stale. `missing` + is fastest but tags can go stale; `always` re-checks every job. Pinning a digest + (`img@sha256:…`) is immutable and never stale. +- **Per-image size cap (`SANDBOX_IMAGE_MAX_SIZE_MB`, default 0 = off):** images + whose on-disk size exceeds the cap are rejected before extraction. +- **Cache size cap (`SANDBOX_IMAGE_CACHE_MAX_MB`, default 0 = off):** best-effort + LRU eviction — after a run, the oldest images are removed until podman's image + store is back under the cap. In-use images are never removed. + +## Requirements + +- `podman` (rootless) and `tar` on the worker for image pull/extract. +- `nsjail` on the worker — **required**. If nsjail is absent, a `# sandbox ` + job errors clearly (use a bare `# docker` + a daemon instead). + +## Limitations (by design — daemonless, run-to-completion) + +- No `docker run -d` + later `exec`/`attach`/`logs -f`, no `docker build`, + `compose`, swarm, healthchecks. +- No arbitrary `-v` host bind mounts, `--privileged`, `--cap-add`, `--device`, + host namespace sharing. +- Images that drop to a non-root uid or chown to arbitrary uids inside need a + subuid **range** in the jail (single-uid only today — follow-up: `newuidmap` + range mapping). +- The script result is a completion message; capture output via stdout/logs. + +## Follow-ups + +- Content-addressed rootfs cache keyed by image digest (today each job re-exports; + podman's image store still dedups the network pull). +- Pre-pull size guard via `skopeo` manifest inspection (reject before download). +- Subuid-range nsjail variant for multi-uid images. +- Per-container isolated networking (slirp/pasta). +- Support under the non-nsjail `unshare` isolation mode. diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 8f4e75ff3d..325f2753e5 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -1007,21 +1007,6 @@ function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) { if (lang == 'docker') { - if (isCloudHosted()) { - sendUserToast( - 'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.', - true, - [ - { - label: 'Learn more', - callback: () => { - window.open('https://www.windmill.dev/docs/advanced/docker', '_blank') - } - } - ] - ) - return - } template = 'docker' } else if (lang == 'bunnative') { template = 'bunnative' diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index 353dbb3a2f..335a37b0c7 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -7,8 +7,6 @@ import FlowScriptPicker from '../pickers/FlowScriptPicker.svelte' import PickHubScript from '../pickers/PickHubScript.svelte' import WorkspaceScriptPicker from '../pickers/WorkspaceScriptPicker.svelte' - import { isCloudHosted } from '$lib/cloud' - import { sendUserToast } from '$lib/toast' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import { Check, Code, Zap } from 'lucide-svelte' @@ -259,23 +257,6 @@ {label} lang={lang == 'docker' ? 'bash' : lang} on:click={() => { - if (lang == 'docker') { - if (isCloudHosted()) { - sendUserToast( - 'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.', - true, - [ - { - label: 'Learn more', - callback: () => { - window.open('https://www.windmill.dev/docs/advanced/docker', '_blank') - } - } - ] - ) - return - } - } dispatch('new', { language: lang == 'docker' ? 'bash' : lang, kind, diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index db199c745a..3f070dd74e 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -4,7 +4,6 @@