mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 00:01:34 +00:00
feat: add allowed_domains annotation for sandbox domain filtering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
ee5e70863188ac0d4964a72361c0f9e8f1f9433a
|
||||
1e2f43629b580a6037c9e1222796a07f07e12c63
|
||||
@@ -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\
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user