diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 490b8f36d1..ac87bb4e44 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ee5e70863188ac0d4964a72361c0f9e8f1f9433a \ No newline at end of file +1e2f43629b580a6037c9e1222796a07f07e12c63 \ No newline at end of file diff --git a/backend/windmill-sandbox/src/types.rs b/backend/windmill-sandbox/src/types.rs index 676319c09c..6f84627cf7 100644 --- a/backend/windmill-sandbox/src/types.rs +++ b/backend/windmill-sandbox/src/types.rs @@ -9,6 +9,7 @@ use uuid::Uuid; pub struct SandboxConfig { pub snapshot: Option, pub volumes: HashMap, + pub allowed_domains: Vec, } #[derive(Debug, Clone)] @@ -98,6 +99,12 @@ pub fn parse_sandbox_config(code: &str) -> SandboxConfig { if let Some((name, path)) = spec.split_once(':') { config.volumes.insert(name.to_string(), path.to_string()); } + } else if let Some(spec) = content.strip_prefix("allowed_domains:").map(|s| s.trim()) { + config.allowed_domains = spec + .split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); } } config @@ -247,6 +254,37 @@ mod tests { assert_eq!(config.volumes.len(), 3); } + #[test] + fn test_parse_allowed_domains() { + let code = "# sandbox: py-env:v1\n# allowed_domains: api.example.com, cdn.example.com\n"; + let config = parse_sandbox_config(code); + assert_eq!( + config.allowed_domains, + vec!["api.example.com", "cdn.example.com"] + ); + } + + #[test] + fn test_parse_allowed_domains_ts_style() { + let code = "// allowed_domains: ghcr.io, registry.npm.org\n// sandbox: node:v2\n"; + let config = parse_sandbox_config(code); + assert_eq!(config.allowed_domains, vec!["ghcr.io", "registry.npm.org"]); + } + + #[test] + fn test_parse_allowed_domains_empty() { + let code = "# allowed_domains: \ndef main(): pass\n"; + let config = parse_sandbox_config(code); + assert!(config.allowed_domains.is_empty()); + } + + #[test] + fn test_parse_allowed_domains_case_insensitive() { + let code = "# allowed_domains: API.Example.COM\n"; + let config = parse_sandbox_config(code); + assert_eq!(config.allowed_domains, vec!["api.example.com"]); + } + #[test] fn test_parse_ignores_unrelated_comments() { let code = "# This is a normal comment\n\ diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 6f2f8ce420..8cb922bfb2 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -727,35 +727,57 @@ pub async fn is_otel_tracing_proxy_enabled_for_lang(lang: &ScriptLang) -> bool { } /// Get proxy environment variables for job execution for a specific language. -/// When OTEL tracing proxy is enabled for this language, routes all traffic through the proxy. +/// When OTEL tracing proxy is enabled or domain filtering is active, routes traffic through the MITM proxy. /// Otherwise, uses the standard HTTP_PROXY/HTTPS_PROXY from environment. pub async fn get_proxy_envs_for_lang( lang: &ScriptLang, ) -> anyhow::Result> { #[cfg(all(feature = "private", feature = "enterprise"))] - if is_otel_tracing_proxy_enabled_for_lang(lang).await { - return get_otel_tracing_proxy_envs().await; + { + let otel_enabled = is_otel_tracing_proxy_enabled_for_lang(lang).await; + let has_allowed_domains = crate::otel_tracing_proxy_ee::CURRENT_JOB_ALLOWED_DOMAINS + .read() + .await + .is_some(); + + if otel_enabled || has_allowed_domains { + return get_mitm_proxy_envs(has_allowed_domains).await; + } } let _ = lang; Ok(PROXY_ENVS.clone()) } +/// Build proxy env vars that route traffic through the MITM proxy. +/// When `domain_filtering` is true, includes BASE_INTERNAL_URL in NO_PROXY +/// so jobs can always reach the Windmill API. #[cfg(all(feature = "private", feature = "enterprise"))] -async fn get_otel_tracing_proxy_envs() -> anyhow::Result> { +async fn get_mitm_proxy_envs( + domain_filtering: bool, +) -> anyhow::Result> { let port = crate::otel_tracing_proxy_ee::TRACING_PROXY_PORT .read() .await - .ok_or_else(|| anyhow::anyhow!("OTEL tracing proxy port not initialized"))?; + .ok_or_else(|| anyhow::anyhow!("MITM proxy port not initialized"))?; let proxy_url = format!("http://127.0.0.1:{}", port); + + // When domain filtering is active, bypass proxy for BASE_INTERNAL_URL + // so sandboxed jobs can always reach the Windmill API. + let no_proxy = if domain_filtering { + extract_no_proxy_from_base_url() + } else { + String::new() + }; + Ok(vec![ ("HTTP_PROXY", proxy_url.clone()), ("HTTPS_PROXY", proxy_url.clone()), // Lowercase variants for Ruby and other runtimes that check lowercase first ("http_proxy", proxy_url.clone()), ("https_proxy", proxy_url), - ("NO_PROXY", "".to_string()), - ("no_proxy", "".to_string()), - // CA cert for various runtimes to trust the tracing proxy + ("NO_PROXY", no_proxy.clone()), + ("no_proxy", no_proxy), + // CA cert for various runtimes to trust the MITM proxy ("SSL_CERT_FILE", TRACING_PROXY_CA_CERT_PATH.to_string()), ("REQUESTS_CA_BUNDLE", TRACING_PROXY_CA_CERT_PATH.to_string()), ( @@ -767,6 +789,18 @@ async fn get_otel_tracing_proxy_envs() -> anyhow::Result "localhost", "https://windmill.example.com" -> "windmill.example.com" +#[cfg(all(feature = "private", feature = "enterprise"))] +fn extract_no_proxy_from_base_url() -> String { + let base_url = + std::env::var("BASE_INTERNAL_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); + url::Url::parse(&base_url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_else(|| "localhost".to_string()) +} + #[cfg(windows)] lazy_static::lazy_static! { pub static ref SYSTEM_ROOT: String = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); @@ -4226,7 +4260,10 @@ mount {{ None }; - if sandbox_config.snapshot.is_some() || !sandbox_config.volumes.is_empty() { + if sandbox_config.snapshot.is_some() + || !sandbox_config.volumes.is_empty() + || !sandbox_config.allowed_domains.is_empty() + { let mut sandbox_logs = "\n--- SANDBOX ---\n".to_string(); if let Some(ref snap) = sandbox_config.snapshot { sandbox_logs.push_str(&format!("Snapshot: {}:{}\n", snap.name, snap.tag)); @@ -4234,9 +4271,26 @@ mount {{ for (vol_name, mount_path) in &sandbox_config.volumes { sandbox_logs.push_str(&format!("Volume: {} -> {}\n", vol_name, mount_path)); } + if !sandbox_config.allowed_domains.is_empty() { + sandbox_logs.push_str(&format!( + "Allowed domains: {}\n", + sandbox_config.allowed_domains.join(", ") + )); + } append_logs(&job.id, &job.workspace_id, sandbox_logs, conn).await; } + // Set domain filtering for the current job (cleared for jobs without the annotation) + #[cfg(all(feature = "private", feature = "enterprise"))] + { + let domains = if sandbox_config.allowed_domains.is_empty() { + None + } else { + Some(sandbox_config.allowed_domains.clone()) + }; + crate::otel_tracing_proxy_ee::set_current_job_allowed_domains(domains).await; + } + let envs = build_envs(envs.as_ref())?; let Some(language) = language else {