diff --git a/docs/schema.md b/docs/schema.md index e1782309a..6667e1098 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -55,6 +55,9 @@ production configs and the dev schema when working on experimental features: "proxy": { "localhost": 8080 } // Loopback proxy port (processcontainer; bubblewrap; seatbelt) // (use { "builtinTestServer": true } for the bundled // testing-only proxy; requires --allow-testing-features) + // WSLC supports the cooperative proxy too, but only via + // { "url": "http://proxy.example:8080" } (own-netns: + // localhost/builtinTestServer are unreachable, rejected) }, "processContainer": { // Process-based container-specific diff --git a/docs/wsl/wsl-container-getting-started.md b/docs/wsl/wsl-container-getting-started.md index 56a5d7925..731f40948 100644 --- a/docs/wsl/wsl-container-getting-started.md +++ b/docs/wsl/wsl-container-getting-started.md @@ -227,6 +227,63 @@ no separate `--setup-wslc` step is required. | `"allowOutbound": true` | Bridged networking (full access) | | `"allowOutbound": false` | No networking (isolated) | +### Network proxy (cooperative, unprivileged) + +WSLC supports a **cooperative HTTP/HTTPS proxy**: setting `network.proxy` +routes a container's egress through a proxy you provide. WSLC's kernel has +**no in-kernel `iptables`**, so — exactly like the Bubblewrap backend — there +is no netfilter drop-floor; enforcement is *cooperative*, applied by handing +the workload proxy environment variables that well-behaved clients honor. + +**How it works** + +1. When `network.proxy` is set, the runner translates it into the + `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment + variables inside the container (via `WslcSetProcessSettingsEnvVariables`). + Any caller-supplied values for these keys — including `NO_PROXY` / + `no_proxy` — are **stripped** first, so a workload cannot override or + disable the configured proxy. The runner deliberately does **not** set + `NO_PROXY`. +2. Cooperative tools (curl, wget, Python `requests`, Node `https`, etc.) honor + the env vars and their traffic flows through the proxy. + +**Only the `url` form is supported.** A WSLC container runs in its own network +namespace (a separate WSL system VM), so a host- or distro-loopback proxy is +**not reachable** from inside the container. The proxy must be a routable +address the container can reach: + +```json +{ + "version": "0.6.0-alpha", + "containment": "wslc", + "process": { "commandLine": "curl -fsSL https://example.com && echo OK" }, + "network": { + "defaultPolicy": "allow", + "proxy": { "url": "http://proxy.example:8080" } + }, + "experimental": { "wslc": { "image": "alpine:latest" } } +} +``` + +The `localhost` and `builtinTestServer` proxy forms are **rejected at +config-parse time** for WSLC (they imply a host-loopback / MXC-run proxy that +the container cannot reach). The proxy also requires `defaultPolicy: "allow"` +and no `allowedHosts` / `blockedHosts`: the container must have outbound +networking to reach the proxy, and host lists are not forwarded to it — configs +that combine the proxy with a `block` default or host lists are **rejected**. + +**Caveats** + +- **Cooperative model, not enforcement.** Only clients that honor the proxy + env vars are routed through the proxy. Tools that bypass them (raw sockets, + custom HTTP clients, statically-linked binaries that ignore the env) are + **not** contained. WSLC cannot provide a hard network floor because its + kernel lacks `iptables`. For strict network isolation, use + `"allowOutbound": false` (no networking) instead. +- **Consumer-provided proxy.** MXC does not start a proxy for WSLC; you supply + a reachable one via `url`. Any host filtering is the proxy's responsibility — + the runner does not forward `allowedHosts` / `blockedHosts` to it. + ### Filesystem mounts Paths in `filesystem.readwritePaths` and `filesystem.readonlyPaths` are mounted @@ -249,6 +306,7 @@ the container. - [`tests/examples/wslc_hello_world.json`](../../tests/examples/wslc_hello_world.json) — Hello world with Alpine - [`tests/configs/wslc_network_isolated.json`](../../tests/configs/wslc_network_isolated.json) — Network isolation +- [`tests/configs/wslc_network_proxy.json`](../../tests/configs/wslc_network_proxy.json) — Cooperative HTTP proxy (`network.proxy.url`) - [`tests/configs/wslc_custom_registry_ghcr.json`](../../tests/configs/wslc_custom_registry_ghcr.json) — Pull from GitHub Container Registry - [`tests/configs/wslc_custom_registry_quay.json`](../../tests/configs/wslc_custom_registry_quay.json) — Pull from Quay.io - [`tests/configs/wslc_tar_import_rootfs.json`](../../tests/configs/wslc_tar_import_rootfs.json) — Import rootfs tar diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index e262e5278..17e177bf2 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -201,11 +201,14 @@ export interface NetworkConfig { /** Hostnames or IP addresses to block (firewall mode only) */ blockedHosts?: string[]; /** Proxy configuration (supported on Windows ProcessContainer, Linux Bubblewrap, - * and macOS Seatbelt). On Bubblewrap/Seatbelt it is a cooperative env-var proxy - * (HTTP_PROXY/HTTPS_PROXY): well-behaved HTTP clients honor it, raw-socket clients - * can bypass it. `builtinTestServer` activates a bundled, testing-only proxy; the - * SDK rejects it unless `allowTestingFeatures: true` is set in SandboxSpawnOptions - * (which maps to the native `--allow-testing-features` flag). */ + * macOS Seatbelt, and WSLC). On Bubblewrap/Seatbelt/WSLC it is a cooperative + * env-var proxy (HTTP_PROXY/HTTPS_PROXY): well-behaved HTTP clients honor it, + * raw-socket clients can bypass it. `builtinTestServer` activates a bundled, + * testing-only proxy; the SDK rejects it unless `allowTestingFeatures: true` is + * set in SandboxSpawnOptions (which maps to the native `--allow-testing-features` + * flag). WSLC accepts only the `{ url }` form (its containers run in their own + * network namespace, so the `localhost` / `builtinTestServer` loopback forms are + * unreachable and rejected); enforcement is cooperative (no in-kernel iptables). */ proxy?: { builtinTestServer: true } | { localhost: number } | { url: string }; /** Automatically remove firewall rules after execution (default: true). Deprecated: use lifecycle.preservePolicy. */ removeRulesOnExit?: boolean; diff --git a/src/backends/bubblewrap/common/src/bwrap_command.rs b/src/backends/bubblewrap/common/src/bwrap_command.rs index cfafb5fef..ba4aa7e20 100644 --- a/src/backends/bubblewrap/common/src/bwrap_command.rs +++ b/src/backends/bubblewrap/common/src/bwrap_command.rs @@ -11,21 +11,7 @@ use std::collections::HashSet; use wxc_common::filesystem_resolve::FsIntent; use wxc_common::models::{ExecutionRequest, NetworkPolicy, ProxyAddress}; - -/// Env var keys that the proxy block manages. Listed here so we can strip -/// any conflicting entries the caller supplied via `request.env` (callers -/// must not be able to defeat the cooperative proxy by injecting their own -/// proxy env vars). -const PROXY_ENV_KEYS: &[&str] = &[ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - "NO_PROXY", - "no_proxy", -]; +use wxc_common::proxy_env::{is_managed_proxy_key, PROXY_SET_KEYS}; /// Read-only host paths bind-mounted into every Bubblewrap sandbox as the /// deny-by-default baseline. Mirrors the seatbelt backend's @@ -231,7 +217,7 @@ pub fn build_args_classified( if let Some((key, value)) = env_str.split_once('=') { // When the proxy is active, drop any caller-supplied proxy env // entries so they cannot override the values we set below. - if proxy_address.is_some() && PROXY_ENV_KEYS.contains(&key) { + if proxy_address.is_some() && is_managed_proxy_key(key) { continue; } args.extend(["--setenv".into(), key.into(), value.into()]); @@ -254,15 +240,8 @@ pub fn build_args_classified( // destinations. if let Some(addr) = proxy_address { let url = addr.to_url(); - for key in [ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - ] { - args.extend(["--setenv".into(), key.into(), url.clone()]); + for key in PROXY_SET_KEYS { + args.extend(["--setenv".into(), (*key).into(), url.clone()]); } } diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index 98918bf0d..4aa587751 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -975,17 +975,57 @@ impl WSLContainerRunner { return sdk_error("WslcSetProcessSettingsCmdLine failed", hr, ""); } - if !request.env.is_empty() { - let env_cstrings: Vec> = request - .env + // Route egress through the cooperative proxy: WSLc has no in-kernel + // iptables, so per-host policy is enforced at the proxy layer by + // injecting HTTP(S)_PROXY (and scrubbing caller-supplied proxy vars). + // See wxc_common::proxy_env. + let effective_env: Vec = if request.policy.network_proxy.is_enabled() { + // url-only (also enforced at parse time). Fail fast rather than + // inject an empty HTTP_PROXY= for the localhost/builtinTestServer + // forms, which carry no routable URL. + let proxy_url = match request + .policy + .network_proxy + .address + .as_ref() + .and_then(|addr| addr.original_url.clone()) + { + Some(url) => url, + None => { + return ScriptResponse::error( + "WSLC: network.proxy requires the 'url' form (a routable proxy URL); \ + the localhost and builtinTestServer forms are not supported because a \ + WSLc container runs in its own network namespace.", + ); + } + }; + let _ = writeln!( + logger, + "[WSLC] Cooperative network proxy configured: {}", + wxc_common::proxy_env::redact_proxy_url(&proxy_url) + ); + wxc_common::proxy_env::apply_cooperative_proxy_env(&request.env, &proxy_url) + } else { + request.env.clone() + }; + + // Env buffers must outlive WslcCreateContainer: the SDK stores the + // pointers into process_settings (it does not copy), and reads them at + // container-create time. Hoisting to function scope keeps them alive — + // mirrors the cmdline/_cwd_cstr handling. Scoping them inside the `if` + // below frees them early and causes a use-after-free (0xC0000005). + let _env_cstrings: Vec>; + let _env_ptrs: Vec; + if !effective_env.is_empty() { + _env_cstrings = effective_env .iter() .map(|e| format!("{}\0", e).into_bytes()) .collect(); - let env_ptrs: Vec = env_cstrings.iter().map(|e| e.as_ptr() as PCSTR).collect(); + _env_ptrs = _env_cstrings.iter().map(|e| e.as_ptr() as PCSTR).collect(); let hr = sdk.WslcSetProcessSettingsEnvVariables( &mut process_settings, - env_ptrs.as_ptr(), - env_ptrs.len(), + _env_ptrs.as_ptr(), + _env_ptrs.len(), ); if hr != S_OK { return sdk_error("WslcSetProcessSettingsEnvVariables failed", hr, ""); diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 6f53b9440..b36dc5421 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -420,6 +420,16 @@ fn convert_wire_proxy(proxy: wire::Proxy) -> Result { let parsed = url::Url::parse(&url_str) .map_err(|e| WxcError::ConfigParse(format!("network.proxy.url is invalid: {e}")))?; + // Only http/https are meaningful for the HTTP(S)_PROXY env vars we + // inject. A non-HTTP scheme (ftp://, socks5://, …) is silently ignored + // by many clients, which fails open under WSLc's defaultPolicy=allow. + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return Err(WxcError::ConfigParse(format!( + "network.proxy.url must use the 'http' or 'https' scheme (got '{scheme}'): {url_str}" + ))); + } + let host = parsed .host_str() .ok_or_else(|| { @@ -865,12 +875,34 @@ fn convert_wire_config( && containment != ContainmentBackend::ProcessContainer && containment != ContainmentBackend::Bubblewrap && containment != ContainmentBackend::Seatbelt + && containment != ContainmentBackend::Wslc { let msg = "Network proxy is only supported with the 'processcontainer', \ - 'bubblewrap', or 'seatbelt' containment backends"; + 'bubblewrap', 'seatbelt', or 'wslc' containment backends"; logger.log_line(msg); return Err(WxcError::ConfigParse(msg.to_string())); } + + // WSLc containers run in their own network namespace, so an + // MXC-run host-loopback proxy is unreachable. Accept only the + // caller-supplied `url` form (which carries `original_url`); reject + // the `localhost` / `builtinTestServer` forms. + if containment == ContainmentBackend::Wslc && proxy_config.is_enabled() { + let is_url_form = proxy_config + .address + .as_ref() + .is_some_and(|addr| addr.original_url.is_some()); + if !is_url_form { + let msg = "WSLc: network.proxy must use the 'url' form pointing at a \ + routable proxy (e.g. \"url\": \"http://proxy.example:8080\"). \ + The 'localhost' and 'builtinTestServer' forms are not supported \ + because a WSLc container runs in its own network namespace and \ + cannot reach a host-loopback proxy."; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + } + policy.network_proxy = proxy_config; } @@ -893,6 +925,24 @@ fn convert_wire_config( policy.blocked_hosts = v; } + // WSLc routes egress through the cooperative proxy but does not forward + // host lists to it, and a 'block' default (the WSLc default) yields no + // outbound networking / a drop-floor that can't even reach the proxy. + // Require an 'allow' default with no host lists so the proxy is reachable. + if containment == ContainmentBackend::Wslc + && policy.network_proxy.is_enabled() + && (policy.default_network_policy == NetworkPolicy::Block + || !policy.allowed_hosts.is_empty() + || !policy.blocked_hosts.is_empty()) + { + let msg = "WSLc: network.proxy requires network.defaultPolicy='allow' and no \ + allowedHosts/blockedHosts. A WSLc container reaches the proxy only \ + with outbound networking enabled, and host lists are enforced by the \ + proxy, not forwarded to it."; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + // Bubblewrap is unprivileged by design; iptables-based enforcement // (firewall / both) requires CAP_NET_ADMIN, which defeats the backend's // privilege story. Reject the combination explicitly. @@ -2985,6 +3035,134 @@ mod tests { // succeeds. } + #[test] + fn proxy_accepted_with_wslc_url_form() { + // WSLc supports the cooperative env-var proxy via a routable `url`. + let json = r#"{ + "version": "0.6.0-alpha", + "containment": "wslc", + "process": {"commandLine": "echo hi"}, + "network": { + "proxy": {"url": "http://proxy.example:8080"}, + "defaultPolicy": "allow" + } + }"#; + 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()); + assert!(!req.policy.network_proxy.builtin_test_server); + let addr = req.policy.network_proxy.address.as_ref().unwrap(); + assert_eq!(addr.to_url(), "http://proxy.example:8080"); + } + + #[test] + fn proxy_rejects_wslc_localhost_form() { + // The localhost form implies a host-loopback proxy, which a WSLc + // container (own network namespace) cannot reach. Must be rejected. + let json = r#"{ + "version": "0.6.0-alpha", + "containment": "wslc", + "process": {"commandLine": "echo hi"}, + "network": {"proxy": {"localhost": 8080}} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{err}").contains("WSLc: network.proxy must use the 'url' form"), + "unexpected error: {err}" + ); + } + + #[test] + fn proxy_rejects_wslc_builtin_test_server() { + // builtinTestServer spins up an MXC-run in-host proxy, unreachable + // from a WSLc container. Must be rejected with the url-form message. + let json = r#"{ + "version": "0.6.0-alpha", + "containment": "wslc", + "process": {"commandLine": "echo hi"}, + "network": {"proxy": {"builtinTestServer": true}} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{err}").contains("WSLc: network.proxy must use the 'url' form"), + "unexpected error: {err}" + ); + } + + #[test] + fn proxy_rejects_non_http_scheme() { + // Non-HTTP schemes are silently ignored by many clients when injected + // as HTTP(S)_PROXY, which fails open. Reject at parse time. + for url in ["socks5://proxy.example:1080", "ftp://proxy.example:21"] { + let json = format!( + r#"{{ + "process": {{"commandLine": "echo hi"}}, + "containment": "processcontainer", + "network": {{"proxy": {{"url": "{url}"}}}} + }}"# + ); + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{err}").contains("must use the 'http' or 'https' scheme"), + "expected scheme rejection for {url}, got: {err}" + ); + } + } + + #[test] + fn proxy_rejects_wslc_url_with_block_default() { + // A WSLc url proxy needs outbound networking; the default 'block' + // policy (defaultPolicy omitted) leaves the proxy unreachable. + let json = r#"{ + "version": "0.6.0-alpha", + "containment": "wslc", + "process": {"commandLine": "echo hi"}, + "network": {"proxy": {"url": "http://proxy.example:8080"}} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{err}").contains("requires network.defaultPolicy='allow'"), + "unexpected error: {err}" + ); + } + + #[test] + fn proxy_rejects_wslc_url_with_host_lists() { + // Host lists are not forwarded to the proxy; reject to avoid silently + // weaker enforcement. + let json = r#"{ + "version": "0.6.0-alpha", + "containment": "wslc", + "process": {"commandLine": "echo hi"}, + "network": { + "proxy": {"url": "http://proxy.example:8080"}, + "defaultPolicy": "allow", + "allowedHosts": ["example.com"] + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{err}").contains("allowedHosts/blockedHosts"), + "unexpected error: {err}" + ); + } + #[test] fn new_toplevel_fields_parsed() { let json = r#"{"version": "0.6.0-alpha", "containerId": "abc-123", "containment": "lxc", "process": {"commandLine": "echo hi"}}"#; diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index b7a35a263..aa10b2193 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -19,6 +19,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..af1a25e23 --- /dev/null +++ b/src/core/wxc_common/src/proxy_env.rs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Cooperative HTTP/HTTPS proxy env-var handling shared by the Linux +//! (Bubblewrap) and WSLc backends. +//! +//! When a backend cannot install a netfilter drop-floor (WSLc has no +//! iptables in its kernel; Bubblewrap deliberately skips iptables while a +//! proxy is active), per-host network policy is enforced *cooperatively*: +//! the sandboxed process is handed `HTTP_PROXY` / `HTTPS_PROXY` env vars and +//! cooperating clients (curl, requests, apt, …) route through the proxy. +//! +//! Two hygiene rules make this robust: +//! 1. **Scrub** every caller-supplied proxy env var ([`PROXY_ENV_KEYS`]) so a +//! workload cannot defeat the cooperative proxy by injecting its own +//! `HTTP_PROXY` (or clearing it via `NO_PROXY`). +//! 2. **Set** the HTTP/HTTPS/ALL proxy keys ([`PROXY_SET_KEYS`]) to the +//! configured proxy URL. +//! +//! `NO_PROXY` is intentionally *not* set: exempting loopback/other hosts +//! would silently bypass the proxy's host filtering. +//! +//! The functions here operate purely on `"KEY=VALUE"` strings so they are +//! platform-agnostic and unit-testable on every host. + +/// Proxy-related env var keys that are *scrubbed* from caller-supplied env so +/// a sandboxed process cannot override or disable the cooperative proxy. +pub const PROXY_ENV_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "NO_PROXY", + "no_proxy", +]; + +/// Proxy env var keys that are actively *set* to the configured proxy URL. +/// +/// The HTTP/HTTPS/ALL keys (upper- and lower-case) are set. `NO_PROXY` is +/// deliberately omitted (see module docs). +pub const PROXY_SET_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", +]; + +/// Returns the key portion of a `"KEY=VALUE"` env entry (the whole string if +/// there is no `=`). +fn env_key(entry: &str) -> &str { + entry.split_once('=').map(|(k, _)| k).unwrap_or(entry) +} + +/// Returns `true` if `key` is one of the proxy env vars this module manages +/// (and therefore must be stripped from caller-supplied env when a +/// cooperative proxy is active). +/// +/// Matched case-insensitively: clients (Python `urllib`, curl, …) lower-case +/// these names, so `No_Proxy` must be scrubbed just like `NO_PROXY`. +pub fn is_managed_proxy_key(key: &str) -> bool { + PROXY_ENV_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) +} + +/// Redact any `user:pass@` userinfo from a proxy URL so it is safe to log. +pub fn redact_proxy_url(url: &str) -> String { + let Some((scheme, rest)) = url.split_once("://") else { + return url.to_string(); + }; + let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (authority, tail) = rest.split_at(auth_end); + match authority.rsplit_once('@') { + Some((_userinfo, host)) => format!("{scheme}://***@{host}{tail}"), + None => url.to_string(), + } +} + +/// Build the effective environment for a sandbox whose egress is routed +/// through a cooperative proxy at `proxy_url`. +/// +/// Every managed proxy key ([`PROXY_ENV_KEYS`]) is removed from `caller_env`, +/// then each key in [`PROXY_SET_KEYS`] is appended pointing at `proxy_url`. +/// All non-proxy entries are preserved in their original order. +/// +/// `caller_env` entries are `"KEY=VALUE"` strings; the returned vector uses +/// the same encoding. +pub fn apply_cooperative_proxy_env(caller_env: &[String], proxy_url: &str) -> Vec { + let mut effective: Vec = caller_env + .iter() + .filter(|entry| !is_managed_proxy_key(env_key(entry))) + .cloned() + .collect(); + + for key in PROXY_SET_KEYS { + effective.push(format!("{key}={proxy_url}")); + } + + effective +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_all_http_https_proxy_keys_to_url() { + let env = apply_cooperative_proxy_env(&[], "http://127.0.0.1:8080"); + for key in PROXY_SET_KEYS { + assert!( + env.contains(&format!("{key}=http://127.0.0.1:8080")), + "missing {key}: {env:?}" + ); + } + } + + #[test] + fn does_not_set_no_proxy() { + let env = apply_cooperative_proxy_env(&[], "http://127.0.0.1:8080"); + assert!( + !env.iter() + .any(|e| env_key(e) == "NO_PROXY" || env_key(e) == "no_proxy"), + "NO_PROXY must not be set: {env:?}" + ); + } + + #[test] + fn strips_caller_supplied_proxy_env() { + let caller = vec![ + "FOO=bar".to_string(), + "HTTP_PROXY=http://attacker.example:9999".to_string(), + "https_proxy=http://attacker.example:9999".to_string(), + "NO_PROXY=example.com".to_string(), + "PATH=/usr/bin".to_string(), + ]; + let env = apply_cooperative_proxy_env(&caller, "http://127.0.0.1:9000"); + + // Non-proxy entries preserved. + assert!(env.contains(&"FOO=bar".to_string())); + assert!(env.contains(&"PATH=/usr/bin".to_string())); + + // Proxy points at the configured URL, not the attacker's. + assert!(env.contains(&"HTTP_PROXY=http://127.0.0.1:9000".to_string())); + // The attacker's values are gone. + assert!(!env.iter().any(|e| e.contains("attacker.example"))); + // Caller NO_PROXY was scrubbed and not re-added. + assert!(!env.iter().any(|e| env_key(e) == "NO_PROXY")); + } + + #[test] + fn preserves_order_of_non_proxy_entries() { + let caller = vec!["A=1".to_string(), "B=2".to_string(), "C=3".to_string()]; + let env = apply_cooperative_proxy_env(&caller, "http://127.0.0.1:1"); + assert_eq!(env[0], "A=1"); + assert_eq!(env[1], "B=2"); + assert_eq!(env[2], "C=3"); + } + + #[test] + fn entry_without_equals_is_treated_as_key() { + // A bare "HTTP_PROXY" (no value) is still a managed key and stripped. + let caller = vec!["HTTP_PROXY".to_string(), "KEEP=1".to_string()]; + let env = apply_cooperative_proxy_env(&caller, "http://127.0.0.1:2"); + assert!(env.contains(&"KEEP=1".to_string())); + assert_eq!( + env.iter().filter(|e| *e == "HTTP_PROXY").count(), + 0, + "bare managed key must be stripped: {env:?}" + ); + } + + #[test] + fn strips_mixed_case_proxy_keys() { + // Clients lower-case these names, so a mixed-case spelling must not + // survive the scrub and defeat the cooperative proxy. + let caller = vec![ + "No_Proxy=*".to_string(), + "HtTp_PrOxY=http://attacker.example:9999".to_string(), + "KEEP=1".to_string(), + ]; + let env = apply_cooperative_proxy_env(&caller, "http://127.0.0.1:9000"); + assert!(env.contains(&"KEEP=1".to_string())); + assert!(!env.iter().any(|e| e.contains("attacker.example"))); + assert!(!env + .iter() + .any(|e| env_key(e).eq_ignore_ascii_case("no_proxy"))); + } + + #[test] + fn redacts_userinfo_in_proxy_url() { + assert_eq!( + redact_proxy_url("http://user:pass@proxy.example:8080"), + "http://***@proxy.example:8080" + ); + // No userinfo -> unchanged. + assert_eq!( + redact_proxy_url("http://proxy.example:8080"), + "http://proxy.example:8080" + ); + // '@' only in the path must not trigger redaction. + assert_eq!( + redact_proxy_url("http://proxy.example:8080/a@b"), + "http://proxy.example:8080/a@b" + ); + } +} diff --git a/tests/configs/wslc_network_proxy.json b/tests/configs/wslc_network_proxy.json new file mode 100644 index 000000000..9eedcd3f5 --- /dev/null +++ b/tests/configs/wslc_network_proxy.json @@ -0,0 +1,18 @@ +{ + "version": "0.6.0-alpha", + "containerId": "wslc-network-proxy", + "containment": "wslc", + "process": { + "commandLine": "set -e; ( while true; do printf 'HTTP/1.0 200 OK\\r\\nContent-Length: 9\\r\\nConnection: close\\r\\n\\r\\nPROXY_HIT' | nc -l -p 8888 -w 2 >/dev/null 2>&1; done ) & sleep 1; echo \"HTTP_PROXY=$HTTP_PROXY http_proxy=$http_proxy\"; BODY=$(wget -q -O - http://example.com/ 2>/dev/null || true); echo \"CLIENT_GOT=[$BODY]\"; case \"$BODY\" in *PROXY_HIT*) echo WSLC_PROXY_FUNCTIONAL_OK ;; *) echo WSLC_PROXY_FAIL ;; esac", + "env": ["HTTP_PROXY=http://attacker.invalid:1", "http_proxy=http://attacker.invalid:1"] + }, + "network": { + "defaultPolicy": "allow", + "proxy": { "url": "http://127.0.0.1:8888" } + }, + "experimental": { + "wslc": { + "image": "alpine:latest" + } + } +} diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index 8203f76de..6faf07beb 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -281,6 +281,7 @@ $null = $results.Add(@{ Write-Host "`n--- Network Tests ---" -ForegroundColor Cyan $null = $results.Add((Run-WslcTest "wslc_network_isolated.json")) +$null = $results.Add((Run-WslcTest "wslc_network_proxy.json" -OutputContains "WSLC_PROXY_FUNCTIONAL_OK")) $null = $results.Add((Run-WslcTest "wslc_port_mapping_tcp.json" -OutputContains "PORT_MAPPING_TCP_OK")) $null = $results.Add((Run-WslcTest "wslc_port_mapping_multiple.json" -OutputContains "PORT_MAPPING_MULTI_OK")) diff --git a/tests/scripts/run_wslc_proxy_test.ps1 b/tests/scripts/run_wslc_proxy_test.ps1 new file mode 100644 index 000000000..c0da7cc52 --- /dev/null +++ b/tests/scripts/run_wslc_proxy_test.ps1 @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# WSLC cooperative HTTP/HTTPS proxy functional test. +# +# WSLC has no in-kernel iptables, so per-host network policy is enforced +# *cooperatively*: the runner translates `network.proxy` into HTTP(S)_PROXY +# env vars and cooperating clients (curl, wget, ...) route through the proxy. +# This script proves that path end-to-end: +# +# 1. Parser accepts the `wslc` backend with a `url`-form proxy. +# 2. The runner injects HTTP_PROXY/HTTPS_PROXY from network.proxy.url AND +# scrubs the attacker-supplied proxy env vars in the config's process.env +# (so a workload cannot defeat the cooperative proxy). +# 3. A cooperating client (busybox wget) routes through the proxy. +# +# The proxy is an in-container marker server on 127.0.0.1 (a WSLC container +# runs in its own network namespace / separate VM, so loopback is the only +# address reachable by both the client and a self-hosted proxy -- a +# host/distro-loopback proxy is NOT reachable). The marker answers every +# request with the body `PROXY_HIT`; example.com is never actually contacted, +# so `PROXY_HIT` in the client output is an unambiguous "the proxy was used" +# signal. +# +# Usage: +# .\run_wslc_proxy_test.ps1 # auto-discovers wxc-exec.exe +# .\run_wslc_proxy_test.ps1 -WxcExecPath # explicit binary +# .\run_wslc_proxy_test.ps1 -Debug # debug build + --debug + +param( + [switch]$Debug, + [string]$WxcExecPath +) + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$ConfigPath = Join-Path $RepoRoot "tests\configs\wslc_network_proxy.json" + +# Resolve the binary: explicit path, then target-specific and default dirs. +$Target = "x86_64-pc-windows-msvc" +$Profile = if ($Debug) { "debug" } else { "release" } +if ($WxcExecPath) { + $WxcExec = $WxcExecPath +} else { + $Candidates = @( + (Join-Path $RepoRoot "src\target\$Target\$Profile\wxc-exec.exe"), + (Join-Path $RepoRoot "src\target\$Profile\wxc-exec.exe") + ) + $WxcExec = $Candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +} +if (-not $WxcExec -or -not (Test-Path $WxcExec)) { + Write-Host "ERROR: wxc-exec.exe not found. Build with: cd src; cargo build --features wslc --release --target $Target" -ForegroundColor Red + exit 1 +} + +Write-Host "Running WSLC cooperative proxy functional test..." +Write-Host "Binary: $WxcExec" -ForegroundColor Gray + +$wxcArgs = @("--experimental") +if ($Debug) { $wxcArgs += "--debug" } +$wxcArgs += $ConfigPath + +$prev = $ErrorActionPreference +$ErrorActionPreference = "Continue" +$output = & $WxcExec @wxcArgs 2>&1 | Out-String +$exitCode = $LASTEXITCODE +$ErrorActionPreference = $prev +Write-Host $output + +$pass = $true +$reason = "" + +if ($exitCode -ne 0) { + $pass = $false + $reason = "wxc-exec returned non-zero exit $exitCode" +} + +# The runner must have injected the configured proxy URL, replacing the +# attacker.invalid values supplied in the config's process.env. +if ($pass -and ($output -notmatch "HTTP_PROXY=http://127\.0\.0\.1:8888")) { + $pass = $false + $reason = "runner did not inject/scrub HTTP_PROXY (expected http://127.0.0.1:8888)" +} +if ($pass -and ($output -match "attacker\.invalid")) { + $pass = $false + $reason = "caller-supplied proxy env var leaked (attacker.invalid not scrubbed)" +} + +# The cooperating client must have routed through the marker proxy. +if ($pass -and ($output -notmatch "WSLC_PROXY_FUNCTIONAL_OK")) { + $pass = $false + $reason = "client did not route through the proxy (marker 'PROXY_HIT' not observed)" +} + +if ($pass) { + Write-Host "PASS: WSLC cooperative proxy is functional (env injected, caller vars scrubbed, client routed)." -ForegroundColor Green + exit 0 +} else { + Write-Host "FAIL: $reason" -ForegroundColor Red + exit 1 +}