diff --git a/docs/schema.md b/docs/schema.md index 6667e1098..e1782309a 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -55,9 +55,6 @@ 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 5f871a52e..56a5d7925 100644 --- a/docs/wsl/wsl-container-getting-started.md +++ b/docs/wsl/wsl-container-getting-started.md @@ -227,66 +227,6 @@ 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** from the *initial* process environment first. - Because WSLC merges the process environment onto the image's baked-in - `ENV`, the runner also sets `NO_PROXY` / `no_proxy` to the **empty string**, - so an image-baked exemption (e.g. `ENV NO_PROXY=*`) cannot silently disable - the proxy. This sanitizes the process's *starting* environment only; see the - cooperative-model caveat below. -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 @@ -309,7 +249,6 @@ 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 de029318b..154bfcffe 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -210,24 +210,11 @@ export interface NetworkConfig { /** Hostnames or IP addresses to block (firewall mode only) */ blockedHosts?: string[]; /** Proxy configuration (supported on Windows ProcessContainer, Linux Bubblewrap, - * 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 imposes additional parse-time constraints (a violating config is - * rejected before it runs): - * - Only the `{ url }` form is accepted — its containers run in their own - * network namespace, so the `localhost` / `builtinTestServer` loopback - * forms are unreachable and rejected. - * - The `url` scheme must be `http` or `https`. - * - `defaultPolicy` must be `"allow"` and both `allowedHosts` and - * `blockedHosts` must be empty/unset — WSLC has no in-kernel iptables, so - * it cannot enforce host lists, and the container needs outbound - * networking to reach the proxy at all. - * Enforcement is cooperative (no in-kernel iptables). */ + * 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). */ 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 ba4aa7e20..cfafb5fef 100644 --- a/src/backends/bubblewrap/common/src/bwrap_command.rs +++ b/src/backends/bubblewrap/common/src/bwrap_command.rs @@ -11,7 +11,21 @@ use std::collections::HashSet; use wxc_common::filesystem_resolve::FsIntent; use wxc_common::models::{ExecutionRequest, NetworkPolicy, ProxyAddress}; -use wxc_common::proxy_env::{is_managed_proxy_key, PROXY_SET_KEYS}; + +/// 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", +]; /// Read-only host paths bind-mounted into every Bubblewrap sandbox as the /// deny-by-default baseline. Mirrors the seatbelt backend's @@ -217,7 +231,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() && is_managed_proxy_key(key) { + if proxy_address.is_some() && PROXY_ENV_KEYS.contains(&key) { continue; } args.extend(["--setenv".into(), key.into(), value.into()]); @@ -240,8 +254,15 @@ pub fn build_args_classified( // destinations. if let Some(addr) = proxy_address { let url = addr.to_url(); - for key in PROXY_SET_KEYS { - args.extend(["--setenv".into(), (*key).into(), url.clone()]); + for key in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ] { + 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 4aa587751..98918bf0d 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -975,57 +975,17 @@ impl WSLContainerRunner { return sdk_error("WslcSetProcessSettingsCmdLine failed", hr, ""); } - // 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 + if !request.env.is_empty() { + let env_cstrings: Vec> = request + .env .iter() .map(|e| format!("{}\0", e).into_bytes()) .collect(); - _env_ptrs = _env_cstrings.iter().map(|e| e.as_ptr() as PCSTR).collect(); + let env_ptrs: Vec = 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 c3237736b..aa96adcb7 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -420,16 +420,6 @@ 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(|| { @@ -874,34 +864,12 @@ enforced; access denials are recorded for diagnostics.\n", && containment != ContainmentBackend::ProcessContainer && containment != ContainmentBackend::Bubblewrap && containment != ContainmentBackend::Seatbelt - && containment != ContainmentBackend::Wslc { let msg = "Network proxy is only supported with the 'processcontainer', \ - 'bubblewrap', 'seatbelt', or 'wslc' containment backends"; + 'bubblewrap', or 'seatbelt' 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; } @@ -924,24 +892,6 @@ enforced; access denials are recorded for diagnostics.\n", 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. @@ -3065,134 +3015,6 @@ 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 aa10b2193..b7a35a263 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -19,7 +19,6 @@ 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 deleted file mode 100644 index 484a1a698..000000000 --- a/src/core/wxc_common/src/proxy_env.rs +++ /dev/null @@ -1,267 +0,0 @@ -// 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 pre-disable the proxy via its own `HTTP_PROXY` (or a -//! `NO_PROXY` exemption). This only sanitizes the *initial* env; the model -//! is cooperative, so a workload can still mutate its own env at runtime. -//! 2. **Set** the HTTP/HTTPS/ALL proxy keys ([`PROXY_SET_KEYS`]) to the -//! configured URL — never `NO_PROXY` (a host exemption list, not a target). -//! -//! `NO_PROXY` is kept out of [`PROXY_SET_KEYS`] and handled per-backend: -//! - Bubblewrap uses `--clearenv` + `PROXY_SET_KEYS`, so it never emits -//! `NO_PROXY` (a stray exemption would bypass the proxy's host filtering). -//! - WSLc ([`apply_cooperative_proxy_env`]) *merges* over the image's baked-in -//! `ENV`, so an image `NO_PROXY=*` could survive. To neutralize it, WSLc -//! sets `NO_PROXY`/`no_proxy` ([`PROXY_NEUTRALIZE_KEYS`]) to the *empty* -//! string rather than omitting them. -//! -//! Functions here operate 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 (it is a host-exemption list, not a proxy target; see -/// module docs and [`PROXY_NEUTRALIZE_KEYS`]). -pub const PROXY_SET_KEYS: &[&str] = &[ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", -]; - -/// Proxy env var keys that [`apply_cooperative_proxy_env`] sets to the *empty -/// string* to neutralize any inherited (e.g. image-baked) value. Forcing -/// `NO_PROXY` empty exempts nothing, so all cooperating traffic still routes -/// through the proxy. (Bubblewrap uses `--clearenv` and does not use these.) -pub const PROXY_NEUTRALIZE_KEYS: &[&str] = &["NO_PROXY", "no_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`, -/// and each key in [`PROXY_NEUTRALIZE_KEYS`] (`NO_PROXY`/`no_proxy`) is -/// appended set to the *empty* string — so an inherited or image-baked -/// exemption cannot disable the proxy. 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}")); - } - - // Neutralize any inherited NO_PROXY: WSLc merges over the image's ENV, so - // an image `NO_PROXY=*` would otherwise disable the proxy. Empty exempts - // nothing. - for key in PROXY_NEUTRALIZE_KEYS { - effective.push(format!("{key}=")); - } - - 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 sets_no_proxy_empty() { - // NO_PROXY / no_proxy are forced to the empty string (never a value), - // so an inherited or image-baked exemption cannot disable the proxy. - let env = apply_cooperative_proxy_env(&[], "http://127.0.0.1:8080"); - for key in PROXY_NEUTRALIZE_KEYS { - assert!( - env.contains(&format!("{key}=")), - "expected empty {key}: {env:?}" - ); - } - // ...and never carries a non-empty value. - assert!( - !env.iter().any(|e| { - let (k, v) = e.split_once('=').unwrap_or((e.as_str(), "")); - k.eq_ignore_ascii_case("no_proxy") && !v.is_empty() - }), - "NO_PROXY must be empty: {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; only the empty neutralizer remains. - assert!(env.contains(&"NO_PROXY=".to_string())); - assert!(!env.iter().any(|e| e == "NO_PROXY=example.com")); - } - - #[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"))); - // The mixed-case No_Proxy=* was scrubbed; only an empty neutralizer - // (never the `*` value) survives. - assert!( - !env.iter().any(|e| { - let (k, v) = e.split_once('=').unwrap_or((e.as_str(), "")); - k.eq_ignore_ascii_case("no_proxy") && !v.is_empty() - }), - "No_Proxy value must not survive: {env:?}" - ); - } - - #[test] - fn neutralizes_image_baked_no_proxy() { - // WSLc merges this env over the image's ENV (process env wins per key), - // so we must emit an explicit empty NO_PROXY/no_proxy to override an - // image `ENV NO_PROXY=*`. - let env = apply_cooperative_proxy_env(&[], "http://127.0.0.1:8888"); - assert!( - env.contains(&"NO_PROXY=".to_string()), - "empty NO_PROXY override missing: {env:?}" - ); - assert!( - env.contains(&"no_proxy=".to_string()), - "empty no_proxy override missing: {env:?}" - ); - // Neither override may carry a value (which would re-add an exemption). - assert!(!env.iter().any(|e| e == "NO_PROXY=*" || e == "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 deleted file mode 100644 index 8556b438e..000000000 --- a/tests/configs/wslc_network_proxy.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 \"PROXY_ENV HTTP_PROXY=[$HTTP_PROXY] http_proxy=[$http_proxy] NO_PROXY=[$NO_PROXY] no_proxy=[$no_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", "NO_PROXY=*", "no_proxy=attacker.invalid"] - }, - "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 4f5a11332..8203f76de 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -281,20 +281,6 @@ $null = $results.Add(@{ Write-Host "`n--- Network Tests ---" -ForegroundColor Cyan $null = $results.Add((Run-WslcTest "wslc_network_isolated.json")) -# Delegate the cooperative proxy fixture to its owning script, which asserts -# HTTP_PROXY injection/scrub, NO_PROXY neutralization, and attacker-value -# removal -- assertions the marker-only Run-WslcTest path cannot make. -$proxyScript = Join-Path $PSScriptRoot "run_wslc_proxy_test.ps1" -$proxyArgs = @{ WxcExecPath = $WxcExec } -if ($Debug) { $proxyArgs.Debug = $true } -& $proxyScript @proxyArgs -$proxyPass = ($LASTEXITCODE -eq 0) -$null = $results.Add(@{ - Name = "wslc_network_proxy.json" - Pass = $proxyPass - Skipped = $false - Reason = $(if ($proxyPass) { "" } else { "cooperative proxy test failed" }) -}) $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 deleted file mode 100644 index d8f6ff302..000000000 --- a/tests/scripts/run_wslc_proxy_test.ps1 +++ /dev/null @@ -1,118 +0,0 @@ -# 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, -# scrubs the attacker-supplied proxy env vars in the config's process.env, -# and neutralizes NO_PROXY/no_proxy to empty (so a caller- or image-baked -# exemption like NO_PROXY=* 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" -} - -# Scope env assertions to the child's own `PROXY_ENV ...` line: the raw stream -# also holds executor diagnostics (diagnostic-console logging echoes the input -# JSON, which carries attacker.invalid), so matching $output would false-flag. -$childLine = ($output -split "`r?`n" | Where-Object { $_ -match '^PROXY_ENV ' } | Select-Object -First 1) -if ($pass -and (-not $childLine)) { - $pass = $false - $reason = "child did not emit the PROXY_ENV diagnostic line" -} - -# The runner must have injected the configured proxy URL, replacing the -# attacker.invalid values supplied in the config's process.env. -if ($pass -and ($childLine -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 ($childLine -match 'attacker\.invalid')) { - $pass = $false - $reason = "caller-supplied proxy env var leaked to the child (attacker.invalid not scrubbed)" -} - -# NO_PROXY / no_proxy must be neutralized to empty so an image-baked or -# caller-supplied exemption (e.g. NO_PROXY=*) cannot disable the proxy. -if ($pass -and (($childLine -notmatch 'NO_PROXY=\[\]') -or ($childLine -notmatch 'no_proxy=\[\]'))) { - $pass = $false - $reason = "runner did not neutralize NO_PROXY/no_proxy to empty" -} - -# 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, NO_PROXY neutralized, client routed)." -ForegroundColor Green - exit 0 -} else { - Write-Host "FAIL: $reason" -ForegroundColor Red - exit 1 -}