diff --git a/docs/schema.md b/docs/schema.md index a735f3c9c..105cfc302 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -54,7 +54,8 @@ production configs and the dev schema when working on experimental features: "enforcementMode": "firewall", // "capabilities", "firewall", or "both" "proxy": { "localhost": 8080 } // Loopback proxy port (processcontainer; bubblewrap) // (use { "builtinTestServer": true } for the bundled - // testing-only proxy; requires --allow-testing-features) + // testing-only proxy; requires --allow-testing-features. + // lxc can't reach a loopback proxy — use { "url": "..." }) }, "processContainer": { // Process-based container-specific diff --git a/src/backends/lxc/common/src/lxc_bindings.rs b/src/backends/lxc/common/src/lxc_bindings.rs index efbd4ad6e..f1f8edaf4 100644 --- a/src/backends/lxc/common/src/lxc_bindings.rs +++ b/src/backends/lxc/common/src/lxc_bindings.rs @@ -68,6 +68,16 @@ pub fn resolve_default_lxcpath() -> String { resolve_lxcpath_with_env(|k| std::env::var(k).ok(), current_euid) } +/// Test-only convenience wrapper over [`build_attach_args_with_env_control`] +/// that hardcodes `force_clear_env = false` (the legacy behavior). Kept +/// `#[cfg(test)]`-only because production code always calls the +/// `_with_env_control` variant directly, so compiling this wrapper outside +/// tests would trip the dead-code lint. +#[cfg(test)] +fn build_attach_args(env: &[String], working_directory: &str, command: &str) -> Vec { + build_attach_args_with_env_control(env, working_directory, command, false) +} + /// Build the post-binary argv for `lxc-attach` (the args that follow the /// `-n NAME -P lxcpath` flags already appended by `lxc_command`). /// @@ -75,11 +85,20 @@ pub fn resolve_default_lxcpath() -> String { /// actually spawning `lxc-attach`. See [`LxcContainer::attach_run`] for /// the full contract. /// +/// `force_clear_env` forces `--clear-env` even when `env` is empty, so a +/// fully-scrubbed proxy env can't silently fall back to inheriting the +/// host's variables. +/// /// Gated to Linux + test builds because `attach_run` is a Windows stub /// that never calls this helper, and the workspace clippy lane on /// `windows-latest` would otherwise flag it as dead code. #[cfg(any(target_os = "linux", test))] -fn build_attach_args(env: &[String], working_directory: &str, command: &str) -> Vec { +fn build_attach_args_with_env_control( + env: &[String], + working_directory: &str, + command: &str, + force_clear_env: bool, +) -> Vec { // Loose upper bound; realloc-avoidance hint only. let mut args: Vec = Vec::with_capacity(env.len() + 8); @@ -87,7 +106,7 @@ fn build_attach_args(env: &[String], working_directory: &str, command: &str) -> // slate, even if every entry is malformed. Matches Seatbelt exactly // and is the posture lxc-attach(1) recommends for sandbox callers. // See `attach_run` doc for the full contract. - if !env.is_empty() { + if force_clear_env || !env.is_empty() { args.push("--clear-env".to_string()); for kv in env { // Well-formed = "KEY=VAL" with a non-empty KEY. `"=foo"` and @@ -294,7 +313,11 @@ impl LxcContainer { /// and are outside this function's control. /// /// When `env` is empty, the legacy keep-env behavior is preserved so - /// existing call sites without explicit env are undisturbed. + /// existing call sites without explicit env are undisturbed unless + /// `force_clear_env` is true. The LXC runner uses `force_clear_env` + /// after proxy-env scrubbing removes every caller-supplied proxy entry; + /// that still must clear inherited proxy variables instead of falling + /// back to keep-env mode. /// /// We pass `unblock_signals = [SIGHUP, SIGTERM, SIGINT]` because /// [`crate::signal_cleanup::install`] blocks them in this process so @@ -314,6 +337,7 @@ impl LxcContainer { command: &str, working_directory: &str, env: &[String], + force_clear_env: bool, timeout: Option, ) -> Result<(i32, String, String), String> { use mxc_pty::{run_with_pty, PtyOptions, PtyOutcome, Signal}; @@ -321,7 +345,12 @@ impl LxcContainer { const UNBLOCK: &[Signal] = &[Signal::SIGHUP, Signal::SIGTERM, Signal::SIGINT]; let mut cmd = self.lxc_command("lxc-attach"); - cmd.args(build_attach_args(env, working_directory, command)); + cmd.args(build_attach_args_with_env_control( + env, + working_directory, + command, + force_clear_env, + )); let options = PtyOptions { unblock_signals: UNBLOCK, @@ -348,6 +377,7 @@ impl LxcContainer { _command: &str, _working_directory: &str, _env: &[String], + _force_clear_env: bool, _timeout: Option, ) -> Result<(i32, String, String), String> { Err("LxcContainer::attach_run is only supported on Linux".to_string()) @@ -746,6 +776,12 @@ mod tests { ); } + #[test] + fn build_attach_args_can_force_clear_env_when_env_empty() { + let args = build_attach_args_with_env_control(&[], "", "cmd", true); + assert_eq!(args, vec!["--clear-env", "--", "/bin/sh", "-c", "cmd"]); + } + #[test] fn build_attach_args_clears_env_even_when_all_entries_malformed() { // Caller opted into env control by populating the field. Even if diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index 002ce5445..8d6ea6d9d 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -192,13 +192,14 @@ impl LxcScriptRunner { let _ = writeln!(logger, "Container already running."); } - // Wait for network only when the config uses network features (firewall rules - // or allowed/blocked hosts). + // Wait for network only when the config uses network features + // (firewall rules, allowed/blocked hosts, or proxy enforcement). let needs_network = matches!( request.policy.network_enforcement_mode, NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both ) || !request.policy.allowed_hosts.is_empty() - || !request.policy.blocked_hosts.is_empty(); + || !request.policy.blocked_hosts.is_empty() + || request.policy.network_proxy.is_enabled(); if needs_network { Self::wait_for_network(&container_name, Duration::from_secs(10), logger); @@ -242,10 +243,14 @@ impl LxcScriptRunner { Some(Duration::from_millis(u64::from(request.script_timeout))) }; let _ = writeln!(logger, "Executing script inside container..."); + let mut exec_env = request.env.clone(); + let force_clear_env = + wxc_common::proxy_env::apply_proxy_env(&mut exec_env, &request.policy.network_proxy); let result = container.attach_run( &request.script_code, &request.working_directory, - &request.env, + &exec_env, + force_clear_env, timeout, ); diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 83374c1e8..37a749dc5 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -12,6 +12,12 @@ use std::process::Command; use wxc_common::logger::Logger; use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode, NetworkPolicy}; +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyEndpoint { + ip: String, + port: u16, +} + /// Manages iptables rules for an LXC container's network policy. pub struct NetworkIptablesManager { /// Chain name unique to this container (e.g., "MXC-"). @@ -129,6 +135,150 @@ impl NetworkIptablesManager { Ok(true) } + fn run_iptables_args(args: &[String], logger: &mut Logger) -> Result { + let refs = args.iter().map(String::as_str).collect::>(); + Self::run_iptables(&refs, logger) + } + + fn build_ordered_egress_rules( + chain_name: &str, + blocked_ips: &[String], + allowed_ips: &[String], + default_policy: NetworkPolicy, + proxy_endpoints: &[ProxyEndpoint], + ) -> Vec> { + let mut rules = Vec::new(); + + if !proxy_endpoints.is_empty() { + for endpoint in proxy_endpoints { + rules.push(vec![ + "-A".to_string(), + chain_name.to_string(), + "-p".to_string(), + "tcp".to_string(), + "-d".to_string(), + endpoint.ip.clone(), + "--dport".to_string(), + endpoint.port.to_string(), + "-j".to_string(), + "ACCEPT".to_string(), + ]); + } + rules.push(vec![ + "-A".to_string(), + chain_name.to_string(), + "-j".to_string(), + "DROP".to_string(), + ]); + return rules; + } + + for ip in blocked_ips { + rules.push(vec![ + "-A".to_string(), + chain_name.to_string(), + "-d".to_string(), + ip.clone(), + "-j".to_string(), + "DROP".to_string(), + ]); + } + + for ip in allowed_ips { + rules.push(vec![ + "-A".to_string(), + chain_name.to_string(), + "-d".to_string(), + ip.clone(), + "-j".to_string(), + "ACCEPT".to_string(), + ]); + } + + let default_action = match default_policy { + NetworkPolicy::Block => "DROP", + NetworkPolicy::Allow => "ACCEPT", + }; + rules.push(vec![ + "-A".to_string(), + chain_name.to_string(), + "-j".to_string(), + default_action.to_string(), + ]); + + rules + } + + fn resolve_policy_hosts(hosts: &[String], action: &str, logger: &mut Logger) -> Vec { + let mut resolved = Vec::new(); + + for host in hosts { + let ips = Self::resolve_host(host); + if ips.is_empty() { + logger.log_line(&format!("Warning: could not resolve host '{}'", host)); + continue; + } + for ip in ips { + logger.log_line(&format!("{} host: {} ({})", action, host, ip)); + resolved.push(ip); + } + } + + resolved + } + + /// Whether `host` is already an IP literal (v4 or v6) and therefore needs + /// no DNS resolution to reach. Accepts bracketed IPv6 literals (e.g. + /// `[::1]`) as stored by the proxy URL parser. + fn host_is_ip_literal(host: &str) -> bool { + let candidate = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + candidate.parse::().is_ok() + } + + fn resolve_proxy_endpoints( + policy: &ContainerPolicy, + logger: &mut Logger, + ) -> Result, String> { + if !policy.network_proxy.is_enabled() { + return Ok(Vec::new()); + } + + let address = policy.network_proxy.address.as_ref().ok_or_else(|| { + "Network proxy is enabled but no proxy address is configured".to_string() + })?; + + if address.port() == 0 { + return Err("Network proxy port must be between 1 and 65535".to_string()); + } + + let ips = Self::resolve_host(address.host()); + if ips.is_empty() { + return Err(format!( + "Could not resolve network proxy host '{}'", + address.host() + )); + } + + Ok(ips + .into_iter() + .map(|ip| { + logger.log_line(&format!( + "Allowing network proxy egress: {}:{} ({})", + address.host(), + address.port(), + ip + )); + ProxyEndpoint { + ip, + port: address.port(), + } + }) + .collect()) + } + /// Apply network firewall rules based on the container policy. pub fn apply_firewall_rules( &mut self, @@ -139,12 +289,19 @@ impl NetworkIptablesManager { let use_firewall = matches!( policy.network_enforcement_mode, NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both - ); + ) || policy.network_proxy.is_enabled(); if !use_firewall { logger.log_line("Network enforcement mode does not use firewall, skipping iptables."); return Ok(true); } + let Some(ref iface) = self.veth_interface else { + return Err( + "No veth interface set for container; cannot scope iptables FORWARD hook" + .to_string(), + ); + }; + logger.log_line(&format!("Creating iptables chain: {}", self.chain_name)); // Create custom chain @@ -169,83 +326,90 @@ impl NetworkIptablesManager { logger, )?; - // Allow DNS (needed for hostname resolution) - Self::run_iptables( - &[ - "-A", - &self.chain_name, - "-p", - "udp", - "--dport", - "53", - "-j", - "ACCEPT", - ], - logger, - )?; - Self::run_iptables( - &[ - "-A", - &self.chain_name, - "-p", - "tcp", - "--dport", - "53", - "-j", - "ACCEPT", - ], - logger, - )?; - - // Add allowed host rules - for host in &policy.allowed_hosts { - let ips = Self::resolve_host(host); - if ips.is_empty() { - logger.log_line(&format!("Warning: could not resolve host '{}'", host)); - continue; - } - for ip in &ips { - logger.log_line(&format!("Allowing host: {} ({})", host, ip)); - Self::run_iptables(&["-A", &self.chain_name, "-d", ip, "-j", "ACCEPT"], logger)?; - } - } - - // Add blocked host rules - for host in &policy.blocked_hosts { - let ips = Self::resolve_host(host); - if ips.is_empty() { - logger.log_line(&format!("Warning: could not resolve host '{}'", host)); - continue; - } - for ip in &ips { - logger.log_line(&format!("Blocking host: {} ({})", host, ip)); - Self::run_iptables(&["-A", &self.chain_name, "-d", ip, "-j", "DROP"], logger)?; - } - } - - // Append default policy at end of chain - let default_action = match policy.default_network_policy { - NetworkPolicy::Block => "DROP", - NetworkPolicy::Allow => "ACCEPT", + let proxy_endpoints = Self::resolve_proxy_endpoints(policy, logger)?; + let proxy_enabled = !proxy_endpoints.is_empty(); + + // Decide whether outbound DNS (port 53) may be opened. + // + // In proxy mode the posture is "deny-all-except-proxy", so DNS is only + // opened when the proxy is addressed by hostname (the container has to + // resolve it). When the proxy is an IP literal no resolution is needed, + // so DNS stays closed and nothing but the proxy endpoint is reachable. + // Outside proxy mode DNS is required for the hostname-based allow/block + // lists, so it is always opened. + let allow_dns = if proxy_enabled { + policy + .network_proxy + .address + .as_ref() + .is_some_and(|addr| !Self::host_is_ip_literal(addr.host())) + } else { + true }; - logger.log_line(&format!("Default network policy: {}", default_action)); - Self::run_iptables(&["-A", &self.chain_name, "-j", default_action], logger)?; - // Hook the chain into FORWARD for the container's traffic - if let Some(ref iface) = self.veth_interface { + if allow_dns { + // Allow DNS (needed for hostname resolution) Self::run_iptables( - &["-I", "FORWARD", "-o", iface, "-j", &self.chain_name], + &[ + "-A", + &self.chain_name, + "-p", + "udp", + "--dport", + "53", + "-j", + "ACCEPT", + ], logger, )?; + Self::run_iptables( + &[ + "-A", + &self.chain_name, + "-p", + "tcp", + "--dport", + "53", + "-j", + "ACCEPT", + ], + logger, + )?; + } + + let (blocked_ips, allowed_ips) = if !proxy_enabled { + ( + Self::resolve_policy_hosts(&policy.blocked_hosts, "Blocking", logger), + Self::resolve_policy_hosts(&policy.allowed_hosts, "Allowing", logger), + ) + } else if allow_dns { + logger.log_line( + "Network proxy enabled: allowing proxy egress plus DNS (for proxy hostname resolution) and dropping all other outbound traffic.", + ); + (Vec::new(), Vec::new()) } else { - // Without a veth interface, we cannot safely scope rules to the container. - // Refuse to apply host-wide rules to avoid affecting all host traffic. logger.log_line( - "Warning: No veth interface set for container. \ - Cannot scope iptables rules. Skipping FORWARD hook.", + "Network proxy enabled: allowing proxy egress only and dropping all other outbound traffic (including DNS).", ); + (Vec::new(), Vec::new()) + }; + + for args in Self::build_ordered_egress_rules( + &self.chain_name, + &blocked_ips, + &allowed_ips, + policy.default_network_policy.clone(), + &proxy_endpoints, + ) { + Self::run_iptables_args(&args, logger)?; } + // Hook the chain into FORWARD for the container's traffic + Self::run_iptables( + &["-I", "FORWARD", "-o", iface, "-j", &self.chain_name], + logger, + )?; + self.rules_applied = true; Ok(true) } @@ -358,4 +522,93 @@ mod tests { let ips = NetworkIptablesManager::resolve_host("10.0.0.1"); assert_eq!(ips, vec!["10.0.0.1"]); } + + #[test] + fn host_is_ip_literal_detects_ips_and_hostnames() { + assert!(NetworkIptablesManager::host_is_ip_literal("10.0.0.5")); + assert!(NetworkIptablesManager::host_is_ip_literal("127.0.0.1")); + // Bracketed and bare IPv6 literals both count as "no DNS needed". + assert!(NetworkIptablesManager::host_is_ip_literal("[::1]")); + assert!(NetworkIptablesManager::host_is_ip_literal("::1")); + // Hostnames require resolution, so they are not IP literals. + assert!(!NetworkIptablesManager::host_is_ip_literal( + "proxy.example.com" + )); + assert!(!NetworkIptablesManager::host_is_ip_literal("localhost")); + } + + #[test] + fn firewall_mode_without_veth_fails_fast() { + let mut mgr = NetworkIptablesManager::new("no-veth"); + let policy = ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + ..Default::default() + }; + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let err = mgr.apply_firewall_rules(&policy, &mut logger).unwrap_err(); + + assert!(err.contains("No veth interface set")); + assert!(!mgr.rules_applied()); + } + + #[test] + fn ordered_egress_rules_put_deny_before_allow() { + let blocked = vec!["10.0.0.5".to_string()]; + let allowed = vec!["10.0.0.0".to_string()]; + + let rules = NetworkIptablesManager::build_ordered_egress_rules( + "MXC-test", + &blocked, + &allowed, + NetworkPolicy::Block, + &[], + ); + + assert_eq!( + rules, + vec![ + vec!["-A", "MXC-test", "-d", "10.0.0.5", "-j", "DROP"], + vec!["-A", "MXC-test", "-d", "10.0.0.0", "-j", "ACCEPT"], + vec!["-A", "MXC-test", "-j", "DROP"], + ] + ); + } + + #[test] + fn proxy_egress_rules_allow_only_proxy_then_drop() { + let blocked = vec!["10.0.0.5".to_string()]; + let allowed = vec!["10.0.0.0".to_string()]; + let proxy = vec![ProxyEndpoint { + ip: "127.0.0.1".to_string(), + port: 8080, + }]; + + let rules = NetworkIptablesManager::build_ordered_egress_rules( + "MXC-test", + &blocked, + &allowed, + NetworkPolicy::Allow, + &proxy, + ); + + assert_eq!( + rules, + vec![ + vec![ + "-A", + "MXC-test", + "-p", + "tcp", + "-d", + "127.0.0.1", + "--dport", + "8080", + "-j", + "ACCEPT", + ], + vec!["-A", "MXC-test", "-j", "DROP"], + ] + ); + } } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index a35c682e8..7a5240617 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -762,13 +762,36 @@ fn convert_wire_config( // Network section if let Some(net) = cfg.network { if let Some(proxy) = net.proxy { + // Capture which shorthand was used before the wire proxy is + // consumed — LXC can't reach a localhost/loopback proxy. + let proxy_used_localhost = proxy.localhost.is_some(); let proxy_config = convert_wire_proxy(proxy)?; if proxy_config.is_enabled() && containment != ContainmentBackend::ProcessContainer && containment != ContainmentBackend::Bubblewrap + && containment != ContainmentBackend::Lxc { - let msg = "Network proxy is only supported with the 'processcontainer' \ - or 'bubblewrap' containment backends"; + let msg = "Network proxy is only supported with the 'processcontainer', \ + 'bubblewrap', or 'lxc' containment backends"; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + if containment == ContainmentBackend::Lxc && proxy_config.builtin_test_server { + let msg = "LXC: network.proxy.builtinTestServer is not supported; \ + use network.proxy.url"; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + // `network.proxy.localhost` maps to 127.0.0.1, which inside an LXC + // network namespace is the container's own loopback rather than the + // host. The injected HTTP(S)_PROXY would be unreachable and the + // iptables proxy-allow rule would never match, so require a routable + // host via `network.proxy.url` instead. + if containment == ContainmentBackend::Lxc && proxy_used_localhost { + let msg = "LXC: network.proxy.localhost is not reachable from the \ + container network namespace (127.0.0.1 is the container \ + loopback); use network.proxy.url with a host routable from \ + inside the container"; logger.log_line(msg); return Err(WxcError::ConfigParse(msg.to_string())); } @@ -2084,7 +2107,24 @@ mod tests { } #[test] - fn proxy_rejected_with_non_processcontainer() { + fn proxy_accepted_with_lxc() { + // LXC requires a routable proxy host: localhost/127.0.0.1 is the + // container loopback and unreachable, so use network.proxy.url. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.policy.network_proxy.is_enabled()); + let addr = req.policy.network_proxy.address.as_ref().unwrap(); + assert_eq!(addr.host(), "proxy.example.com"); + assert_eq!(addr.port(), 8080); + } + + #[test] + fn proxy_localhost_rejected_with_lxc() { + // network.proxy.localhost maps to 127.0.0.1, unreachable from inside + // the LXC network namespace — it must be rejected at parse time. let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"localhost":8080}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2163,14 +2203,15 @@ mod tests { } #[test] - fn proxy_builtin_test_server_rejected_with_non_processcontainer() { - // lxc is not allowed -- proxy is gated to processcontainer + bubblewrap. + fn proxy_builtin_test_server_rejected_with_lxc() { + // LXC enforces a configured proxy address with iptables; it does not + // launch the builtin testing proxy. let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"builtinTestServer":true}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); - let result = load_request(&encoded, &mut logger, true); - assert!(result.is_err()); + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!(format!("{}", err).contains("builtinTestServer is not supported")); } #[test] diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index 80c44e578..b00b72431 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -17,6 +17,7 @@ pub mod logger; pub mod microvm_staging; pub mod models; pub mod mxc_error; +pub mod proxy_env; pub mod sandbox_process; pub mod script_runner; pub mod state_aware_backend; diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs new file mode 100644 index 000000000..7fcf73769 --- /dev/null +++ b/src/core/wxc_common/src/proxy_env.rs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Helpers for applying network proxy policy to process environment vectors. + +use crate::models::ProxyConfig; + +const PROXY_ENV_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "ftp_proxy", + "no_proxy", +]; + +/// Scrub proxy-related variables from `env`, then set the configured HTTP(S) +/// proxy variables when `proxy` has an address. +/// +/// `env` uses the `ExecutionRequest::env` representation: `KEY=VALUE` strings. +/// Malformed entries without `=` are preserved because they are ignored by the +/// backend-specific command builders anyway. +/// +/// Returns `true` when the caller should force a clean environment even if the +/// resulting vector is empty (for example, because every entry was scrubbed). +pub fn apply_proxy_env(env: &mut Vec, proxy: &ProxyConfig) -> bool { + let original_len = env.len(); + env.retain(|entry| { + entry + .split_once('=') + .is_none_or(|(key, _)| !PROXY_ENV_KEYS.contains(&key)) + }); + + let scrubbed_any = env.len() != original_len; + + if let Some(address) = &proxy.address { + let url = address.to_url(); + for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { + env.push(format!("{key}={url}")); + } + true + } else { + scrubbed_any + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ProxyAddress, ProxyConfig}; + + #[test] + fn apply_proxy_env_removes_all_managed_proxy_vars() { + let mut env = vec![ + "HTTP_PROXY=old".to_string(), + "HTTPS_PROXY=old".to_string(), + "ALL_PROXY=old".to_string(), + "FTP_PROXY=old".to_string(), + "NO_PROXY=old".to_string(), + "http_proxy=old".to_string(), + "https_proxy=old".to_string(), + "all_proxy=old".to_string(), + "ftp_proxy=old".to_string(), + "no_proxy=old".to_string(), + "PATH=/usr/bin".to_string(), + "MALFORMED".to_string(), + ]; + + let force_clear = apply_proxy_env(&mut env, &ProxyConfig::default()); + + assert!(force_clear); + assert_eq!(env, vec!["PATH=/usr/bin", "MALFORMED"]); + } + + #[test] + fn apply_proxy_env_sets_configured_proxy_when_enabled() { + let mut env = vec![ + "HTTP_PROXY=http://old.example:1".to_string(), + "FOO=bar".to_string(), + ]; + let proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + + let force_clear = apply_proxy_env(&mut env, &proxy); + + assert!(force_clear); + assert_eq!(env[0], "FOO=bar"); + for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { + assert!( + env.iter() + .any(|entry| entry == &format!("{key}=http://127.0.0.1:8080")), + "missing {key} in {env:?}" + ); + } + assert!(!env.iter().any(|entry| entry.contains("old.example"))); + } + + #[test] + fn apply_proxy_env_disabled_clears_proxy_vars_without_setting_any() { + let mut env = vec![ + "HTTP_PROXY=http://old.example:1".to_string(), + "https_proxy=http://old.example:2".to_string(), + ]; + + let force_clear = apply_proxy_env(&mut env, &ProxyConfig::default()); + + assert!(force_clear); + assert!(env.is_empty()); + } + + #[test] + fn apply_proxy_env_disabled_no_proxy_vars_does_not_force_clear() { + let mut env = vec!["PATH=/usr/bin".to_string()]; + + let force_clear = apply_proxy_env(&mut env, &ProxyConfig::default()); + + assert!(!force_clear); + assert_eq!(env, vec!["PATH=/usr/bin"]); + } +} diff --git a/tests/configs/lxc_network_proxy.json b/tests/configs/lxc_network_proxy.json new file mode 100644 index 000000000..0d95650ac --- /dev/null +++ b/tests/configs/lxc_network_proxy.json @@ -0,0 +1,21 @@ +{ + "version": "0.6.0-alpha", + "containerId": "CLI-LXC-Network-Proxy", + "containment": "lxc", + "process": { + "commandLine": "wget -qO- https://api.github.com/zen" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "proxy": { "url": "http://proxy.example.com:8080" }, + "allowedHosts": ["api.github.com"] + } +}