feat: add allowed_domains annotation for sandbox domain filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-20 06:49:53 +00:00
parent fe0c517b03
commit 2c5dba0644
3 changed files with 102 additions and 10 deletions
+1 -1
View File
@@ -1 +1 @@
ee5e70863188ac0d4964a72361c0f9e8f1f9433a
1e2f43629b580a6037c9e1222796a07f07e12c63
+38
View File
@@ -9,6 +9,7 @@ use uuid::Uuid;
pub struct SandboxConfig {
pub snapshot: Option<SnapshotRef>,
pub volumes: HashMap<String, String>,
pub allowed_domains: Vec<String>,
}
#[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\
+63 -9
View File
@@ -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<Vec<(&'static str, String)>> {
#[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<Vec<(&'static str, String)>> {
async fn get_mitm_proxy_envs(
domain_filtering: bool,
) -> anyhow::Result<Vec<(&'static str, String)>> {
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<Vec<(&'static str, Stri
])
}
/// Extract the hostname from BASE_INTERNAL_URL for NO_PROXY.
/// e.g. "http://localhost:8000" -> "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 {