From 8b3bba08748f0eb42e1ea09cd2d689111bf9ffd7 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:08:31 -0800 Subject: [PATCH] [WSLC] apply the provision proxy to every exec The daemon stores runtimeConfig.networkProxy from a 1.1.0-alpha provision and injects it into every exec. An exec may omit the proxy or name the same URL; a different URL is rejected. The daemon now applies the proxy environment, so the client sends the raw exec environment and the protocol version is bumped to 10. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3f64c2c-9005-43eb-a803-a91fb7265c5b --- .../wslc/wsl-container-getting-started.md | 20 +- docs/backends/wslc/wslc-state-aware.md | 15 +- .../backends/wslc/common/daemon_protocol.rs | 18 +- .../src/backends/wslc/common/policy.rs | 116 ++++++++--- .../src/backends/wslc/common/state_aware.rs | 68 ++++--- .../src/bin/wslc_daemon/control_server.rs | 5 + .../src/bin/wslc_daemon/session_manager.rs | 192 ++++++++++++++++-- src/mxc-sdk/tests/wslc_daemon_ipc.rs | 5 + tests/scripts/run_wslc_state_aware_tests.ps1 | 92 +++++++++ 9 files changed, 445 insertions(+), 86 deletions(-) diff --git a/docs/backends/wslc/wsl-container-getting-started.md b/docs/backends/wslc/wsl-container-getting-started.md index 716a6a91de..4e2d29ee40 100644 --- a/docs/backends/wslc/wsl-container-getting-started.md +++ b/docs/backends/wslc/wsl-container-getting-started.md @@ -558,13 +558,13 @@ variables that well-behaved clients honor. **How it works** -1. When `runtimeConfig.networkProxy` is set, the runner translates it into the +1. When `runtimeConfig.networkProxy` is set, MXC 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**, + `ENV`, MXC 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. @@ -593,11 +593,19 @@ proxy must have an address routable from the container: } ``` +**Where to set it** + +- **Create-and-run execution:** on the request, with the unrestricted bridged + posture shown above. A proxy cannot make isolated networking reach an + external listener. + +- **Container lifecycle operations:** on a `1.1.0-alpha` `provision` request, + the proxy applies to every `exec` in the container. An `exec` request can + also set `runtimeConfig.networkProxy`, but it must name the same URL. An `exec` always + inherits the provisioned network posture. + The removed `network.proxy` object and its `localhost`/`builtinTestServer` -forms are rejected by schema `0.9.0-alpha`. Create-and-run proxy use requires the unrestricted -bridged posture shown above; a proxy cannot make isolated networking reach an -external listener. Execution in an existing container (`exec`) instead supplies only `runtimeConfig` and -inherits its provisioned network posture. +forms are rejected by schema `0.9.0-alpha`. ### Per-host filtering is not supported diff --git a/docs/backends/wslc/wslc-state-aware.md b/docs/backends/wslc/wslc-state-aware.md index d6dac707e7..b039b21180 100644 --- a/docs/backends/wslc/wslc-state-aware.md +++ b/docs/backends/wslc/wslc-state-aware.md @@ -652,7 +652,7 @@ containers reach Windows `127.0.0.1` services through `host.wslc.internal`; see | Directional `network` posture | deny/deny/deny → isolated; allow/allow/allow → bridged | rejected | rejected; inherit provision posture | | `network.egress.allow` / `deny` rules | rejected | rejected | rejected | | Legacy `network` fields | structurally rejected in v0.9 | structurally rejected | structurally rejected | -| `runtimeConfig.networkProxy` | structurally rejected in published contracts; rejected by WSLc in `1.1.0-alpha` | structurally rejected | honored as a routable URL, injected as `HTTP_PROXY` / `HTTPS_PROXY` env vars | +| `runtimeConfig.networkProxy` | structurally rejected in published contracts; in `1.1.0-alpha`, honored as a routable URL and injected into every exec | structurally rejected | honored as a routable URL, injected as `HTTP_PROXY` / `HTTPS_PROXY` env vars; must match the provision URL, if one was set | | `ui` | rejected | rejected | rejected | | `process.env` / `process.inheritDefaultEnv` | n/a | n/a | honored → see [Environment](wsl-container-getting-started.md#environment) | | `process.timeout` | n/a | n/a | honored → `ExecConfig.timeout_ms` | @@ -675,9 +675,11 @@ Filesystem policy is fixed at `provision` and immutable afterwards. The directional network posture is fixed at `provision`. Post-provision phases reject supplied posture by presence, even when values equal defaults. -Only exec can supply a cooperative proxy through top-level `runtimeConfig`; -its request omits `network` so proxy-only execution inherits the existing -posture. Proxy URLs must address a listener reachable from the guest; a host +Provision (`1.1.0-alpha`) and exec can supply a cooperative proxy through +top-level `runtimeConfig`. A provision proxy applies to every exec in that +container; an exec may omit it or repeat the same URL, and a different URL is +rejected. An exec request omits `network`, so proxy-only execution inherits the +existing posture. Proxy URLs must address a listener reachable from the guest; a host loopback listener is not made guest-reachable by spelling its host address `localhost`. @@ -730,8 +732,9 @@ success for a request it only partly honoured — a sandbox that is quietly not what was asked for. A refusal the caller can see is better. Upgrading an install that may have a live daemon therefore means stopping the -old one first. `PROTOCOL_VERSION` is **9** as of the addition of exec stdin -(the `stdin` flag on `exec` and the `exec_stdin` request). +old one first. `PROTOCOL_VERSION` is **10** as of the addition of +`network_proxy` to `provision` and `exec`, which moved applying the cooperative +proxy from the client to the daemon. ## Daemon idle-timeout env overrides diff --git a/src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs b/src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs index 0a2a051517..88351c974a 100644 --- a/src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs +++ b/src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs @@ -53,7 +53,7 @@ pub const MAX_EXEC_ID_BYTES: usize = 128; /// /// Changing this strands whatever a running daemon is holding, since the SDK /// offers no way to re-attach to a live session from another process. -pub const PROTOCOL_VERSION: u32 = 9; +pub const PROTOCOL_VERSION: u32 = 10; /// Largest payload one [`StreamFrame::Stdin`] may carry. pub const MAX_STDIN_CHUNK_BYTES: usize = 64 * 1024; @@ -171,6 +171,10 @@ pub struct ProvisionConfig { skip_serializing_if = "Option::is_none" )] pub session_ref: Option, + + /// Cooperative proxy URL applied to every exec in this container. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network_proxy: Option, } /// Inputs to `start`: the container was created at provision; start boots it. @@ -198,7 +202,8 @@ pub struct ExecConfig { /// Working directory inside the container (empty = container default). #[serde(default)] pub working_directory: String, - /// Environment variables applied to the process, as `(name, value)` pairs. + /// Environment variables applied to the process, as `(name, value)` pairs, + /// before the daemon applies the cooperative proxy. #[serde(default)] pub env: Vec<(String, String)>, @@ -214,6 +219,11 @@ pub struct ExecConfig { /// immediately. #[serde(default)] pub stdin: bool, + + /// This exec's cooperative proxy URL. It must match the one set at + /// provision, if any; absent uses that one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network_proxy: Option, } /// Inputs to `stop`: stop the running container, keeping it created for a later @@ -513,6 +523,7 @@ mod tests { container_port: 80, }], session_ref: Some("wslcsess:abc".to_string()), + network_proxy: None, })); } @@ -531,6 +542,7 @@ mod tests { env_scope: EnvScope::Replace, timeout_ms: 30_000, stdin: true, + network_proxy: None, })); roundtrip(DaemonRequest::CancelExec(CancelExecConfig { exec_id: "exec-1".to_string(), @@ -578,6 +590,7 @@ mod tests { env_scope: EnvScope::Replace, timeout_ms: 0, stdin: false, + network_proxy: None, }; let frame = encode_frame(&DaemonRequest::Exec(config.clone())).unwrap(); @@ -643,6 +656,7 @@ mod tests { network: NetworkMode::None, port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }) .expect("serialize"); diff --git a/src/mxc-sdk/src/backends/wslc/common/policy.rs b/src/mxc-sdk/src/backends/wslc/common/policy.rs index e9e1162333..181b9c196b 100644 --- a/src/mxc-sdk/src/backends/wslc/common/policy.rs +++ b/src/mxc-sdk/src/backends/wslc/common/policy.rs @@ -15,7 +15,7 @@ //! | `ui` | rejected (no UI primitive) | rejected | rejected | //! | `network.egress.default` | honoured (None / Bridged) | rejected (immutable) | rejected (immutable) | //! | `network.ingress.*` | must match egress (deny all / allow all) | rejected | rejected | -//! | `runtimeConfig.networkProxy` | rejected (applies at exec) | rejected | honoured (cooperative env) | +//! | `runtimeConfig.networkProxy` | honoured (applied to every exec) | rejected | honoured (must match provision's) | //! //! [^1]: a standalone `denied_path` is honoured by container isolation (unlisted //! host paths are simply never mounted); only a denied path nested under a @@ -41,10 +41,11 @@ const ERR_ENUMERATE_PATHS: &str = const ERR_NETWORK_IMMUTABLE: &str = "network mode is bound to the provision phase and cannot be changed by the WSLc backend after \ provisioning"; -const ERR_PROXY_AT_PROVISION: &str = - "runtimeConfig.networkProxy is applied per-exec by the WSLc backend; set it on the exec phase, not provision"; const ERR_PROXY_AT_PHASE: &str = - "runtimeConfig.networkProxy is only honoured on the exec phase by the WSLc backend"; + "runtimeConfig.networkProxy is only honoured on the provision and exec phases by the WSLc backend"; +const ERR_PROXY_MISMATCH: &str = + "WSLc: runtimeConfig.networkProxy on exec must match the proxy set at provision; omit it to \ + use the provisioned proxy"; const ERR_PROXY_URL_FORM: &str = "WSLc: runtimeConfig.networkProxy requires a proxy url reachable from inside the container; \ a host-loopback proxy is not reachable from the container's own network namespace"; @@ -137,9 +138,9 @@ pub(crate) fn validate_directional_network(request: &ExecutionRequest) -> Result } /// Validate the request for the provision phase. `rw` / `ro` paths become -/// volume mounts and `network.egress.default` selects the container network -/// mode; both are honoured here. Everything else in the module table is -/// rejected. +/// volume mounts, `network.egress.default` selects the container network +/// mode and a `url`-form proxy is stored for every exec; all are honoured +/// here. Everything else in the module table is rejected. pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> { if !request.policy.enumerate_paths.is_empty() { return Err(MxcError::policy_validation(ERR_ENUMERATE_PATHS)); @@ -151,16 +152,14 @@ pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<() ) .map_err(MxcError::policy_validation)?; reject_ui_policy(request)?; - if request.policy.network_proxy.is_enabled() { - return Err(MxcError::policy_validation(ERR_PROXY_AT_PROVISION)); - } + reject_non_url_proxy(request)?; validate_directional_network(request)?; Ok(()) } /// Validate the request for start / stop / deprovision. These phases carry no -/// applicable policy: filesystem and network mode are fixed at provision and -/// the proxy is an exec-time concern. A UI policy is never supported. +/// applicable policy: filesystem, network mode and the proxy are fixed at +/// provision. A UI policy is never supported. pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> { reject_filesystem_policy(request)?; reject_ui_policy(request)?; @@ -178,10 +177,16 @@ pub(crate) fn validate_exec_policy(request: &ExecutionRequest) -> Result<(), Mxc reject_filesystem_policy(request)?; reject_ui_policy(request)?; reject_post_provision_network_mode(request)?; + reject_non_url_proxy(request)?; + reject_proxy_credentials_in_argv(request)?; + Ok(()) +} + +/// The proxy must be a URL so a routable value reaches the container. +fn reject_non_url_proxy(request: &ExecutionRequest) -> Result<(), MxcError> { if request.policy.network_proxy.is_enabled() && exec_proxy_url(request).is_none() { return Err(MxcError::policy_validation(ERR_PROXY_URL_FORM)); } - reject_proxy_credentials_in_argv(request)?; Ok(()) } @@ -194,18 +199,36 @@ pub(crate) fn validate_exec_policy(request: &ExecutionRequest) -> Result<(), Mxc /// runs. The other scopes leave the entries with the SDK's environment setter, /// which argv never sees. pub(crate) fn reject_proxy_credentials_in_argv(request: &ExecutionRequest) -> Result<(), MxcError> { - if EnvScope::of(request) != EnvScope::Replace { - return Ok(()); - } - match exec_proxy_url(request) { - Some(url) if crate::mxc_common::proxy_env::proxy_url_has_credentials(url) => { + Some(url) if exposes_proxy_credentials(EnvScope::of(request), url) => { Err(MxcError::policy_validation(ERR_PROXY_CREDENTIALS_IN_ARGV)) } _ => Ok(()), } } +fn exposes_proxy_credentials(scope: EnvScope, url: &str) -> bool { + scope == EnvScope::Replace && crate::mxc_common::proxy_env::proxy_url_has_credentials(url) +} + +/// The proxy URL an exec runs under: its own, or the one set at provision when +/// it names none. The daemon calls this because only it holds the provisioned +/// URL; the error is a rejection message that never repeats either URL. +pub fn resolve_exec_proxy<'a>( + provisioned: Option<&'a str>, + requested: Option<&'a str>, + scope: EnvScope, +) -> Result, &'static str> { + if provisioned.is_some() && requested.is_some() && provisioned != requested { + return Err(ERR_PROXY_MISMATCH); + } + let url = requested.or(provisioned); + if url.is_some_and(|url| exposes_proxy_credentials(scope, url)) { + return Err(ERR_PROXY_CREDENTIALS_IN_ARGV); + } + Ok(url) +} + /// The routable proxy URL to inject at exec, or `None` when the proxy is /// disabled or specified without an original URL. /// Borrows from the request so presence validation does not allocate. @@ -473,17 +496,62 @@ mod tests { } #[test] - fn provision_rejects_proxy() { + fn provision_accepts_only_a_url_proxy() { + let req = parsed( + r#"{"version":"1.1.0-alpha","phase":"provision","containment":"wslc","network":{"egress":{"default":"allow"},"ingress":{"default":"allow","hostLoopback":"allow"}},"runtimeConfig":{"networkProxy":"http://proxy.example:8080"}}"#, + ); + validate_provision_policy(&req).unwrap(); + assert_eq!(exec_proxy_url(&req), Some("http://proxy.example:8080")); + let req = request_with_policy(ContainerPolicy { - network_proxy: url_proxy(), + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Allow, + }), + network_proxy: non_url_proxy(), ..Default::default() }); - assert_policy_validation(validate_provision_policy(&req).unwrap_err(), "exec phase"); + assert_policy_validation(validate_provision_policy(&req).unwrap_err(), "url"); + } - let req = parsed( - r#"{"version":"1.1.0-alpha","phase":"provision","containment":"wslc","runtimeConfig":{"networkProxy":"http://proxy.example:8080"}}"#, + #[test] + fn an_exec_runs_under_its_own_or_the_provisioned_proxy_but_not_a_different_one() { + let provisioned = Some("http://proxy.example:8080"); + for requested in [None, provisioned] { + assert_eq!( + resolve_exec_proxy(provisioned, requested, EnvScope::Merge), + Ok(provisioned) + ); + } + assert_eq!( + resolve_exec_proxy(None, provisioned, EnvScope::Merge), + Ok(provisioned) + ); + assert_eq!( + resolve_exec_proxy( + provisioned, + Some("http://other.example:8080"), + EnvScope::Merge + ), + Err(ERR_PROXY_MISMATCH) + ); + } + + #[test] + fn a_replaced_environment_refuses_a_credential_bearing_provisioned_proxy() { + let url = credential_proxy().address.unwrap().original_url.unwrap(); + assert_eq!( + resolve_exec_proxy(Some(&url), None, EnvScope::Replace), + Err(ERR_PROXY_CREDENTIALS_IN_ARGV) + ); + assert_eq!( + resolve_exec_proxy(Some(&url), None, EnvScope::Merge), + Ok(Some(url.as_str())) ); - assert_policy_validation(validate_provision_policy(&req).unwrap_err(), "exec phase"); } // ---- post-provision (start / stop / deprovision) ---- diff --git a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs index f30d0616ba..f20564f9b2 100644 --- a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs @@ -591,35 +591,25 @@ fn exec_piped( )) } -/// The daemon's inputs for one exec, with the cooperative proxy applied. +/// The daemon's inputs for one exec. The daemon applies the cooperative proxy, +/// because it also holds the one set at provision. fn exec_config( sandbox_id: &str, request: &ExecutionRequest, exec_id: String, run_token: String, ) -> ExecConfig { - // `exec_proxy_url` yields the routable URL only when the proxy is enabled - // *and* in the required `url` form — `validate_exec` has already rejected - // the non-`url` form before we get here, so a `None` here means the proxy - // is disabled, not malformed. - let env = match exec_proxy_url(request) { - Some(proxy_url) => split_env(&crate::mxc_common::proxy_env::apply_cooperative_proxy_env( - request.env_entries(), - proxy_url, - )), - None => split_env(request.env_entries()), - }; - ExecConfig { exec_id, run_token, sandbox_id: sandbox_id.to_string(), script_code: request.script_code.clone(), working_directory: request.working_directory.clone(), - env, + env: split_env(request.env_entries()), env_scope: EnvScope::of(request), timeout_ms: request.script_timeout, stdin: false, + network_proxy: exec_proxy_url(request).map(str::to_string), } } @@ -869,6 +859,7 @@ fn build_provision_config( network, port_mappings, session_ref: session_id, + network_proxy: exec_proxy_url(request).map(str::to_string), }) } @@ -954,6 +945,7 @@ mod stdin_relay_tests { env_scope: EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, } } @@ -1635,6 +1627,25 @@ mod tests { } } + #[test] + fn provision_forwards_the_proxy_url_to_the_daemon() { + let mut request = bridged_request(); + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::from_url( + "http://proxy.example:8080", + "proxy.example".to_string(), + 8080, + )), + }; + + let cfg = build_provision_config(&request, None).unwrap(); + + assert_eq!( + cfg.network_proxy.as_deref(), + Some("http://proxy.example:8080") + ); + } + #[test] fn isolated_networking_rejects_port_mappings_before_the_daemon_sees_them() { let phase = WslcProvisionConfig { @@ -1945,12 +1956,9 @@ mod tests { } #[test] - fn the_exec_config_keeps_the_cooperative_proxy_out_of_the_callers_reach() { + fn the_exec_config_leaves_the_cooperative_proxy_to_the_daemon() { let request = ExecutionRequest { - env: Some(vec![ - "FOO=bar".to_string(), - "HTTP_PROXY=http://attacker.invalid:1".to_string(), - ]), + env: Some(vec!["HTTP_PROXY=http://attacker.invalid:1".to_string()]), policy: ContainerPolicy { network_proxy: ProxyConfig { address: Some(ProxyAddress::from_url( @@ -1971,23 +1979,17 @@ mod tests { "run-1".to_string(), ); - // Replacement still applies, so the proxy variables must survive into - // the entries argv carries rather than being left to the SDK's setter. - assert_eq!(config.env_scope, EnvScope::Replace); - let value = |name: &str| { - config - .env - .iter() - .find(|(k, _)| k == name) - .map(|(_, v)| v.clone()) - }; - assert_eq!(value("FOO").as_deref(), Some("bar")); assert_eq!( - value("HTTP_PROXY").as_deref(), + config.network_proxy.as_deref(), Some("http://127.0.0.1:8888") ); - assert_eq!(value("NO_PROXY").as_deref(), Some("")); - assert!(!config.env.iter().any(|(_, v)| v.contains("attacker"))); + assert_eq!( + config.env, + vec![( + "HTTP_PROXY".to_string(), + "http://attacker.invalid:1".to_string() + )] + ); } #[test] diff --git a/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs b/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs index 81cc5cafd5..a1956ffa21 100644 --- a/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs +++ b/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs @@ -1091,6 +1091,7 @@ mod tests { env_scope: mxc_sdk::wslc_common::process_env::EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, }), ) .await @@ -1141,6 +1142,7 @@ mod tests { env_scope: mxc_sdk::wslc_common::process_env::EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, }, permit, ) @@ -1177,6 +1179,7 @@ mod tests { env_scope: mxc_sdk::wslc_common::process_env::EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, }, permit, )); @@ -1405,6 +1408,7 @@ mod tests { env_scope: mxc_sdk::wslc_common::process_env::EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, }), ) .await @@ -1757,6 +1761,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }), ) .await diff --git a/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs b/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs index 807f853387..990fbd11c4 100644 --- a/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs +++ b/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs @@ -50,6 +50,7 @@ use tokio::sync::{mpsc, oneshot}; use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::models::{FailurePhase, ScriptResponse}; +use mxc_sdk::mxc_common::proxy_env::apply_cooperative_proxy_env; use mxc_sdk::wslc_common::container_steps::{ self, OutStream, OutputSink, ProcessSettings, SessionSizing, StdinConsumer, }; @@ -58,6 +59,7 @@ use mxc_sdk::wslc_common::daemon_protocol::{ PortMapping, ProvisionConfig, ProvisionSessionConfig, StartConfig, StopConfig, }; use mxc_sdk::wslc_common::image; +use mxc_sdk::wslc_common::policy; use mxc_sdk::wslc_common::policy_mapping; use mxc_sdk::wslc_common::process_env::EnvScope; use mxc_sdk::wslc_common::store_slots::{StoreSlotLease, StoreSlotPool}; @@ -403,6 +405,22 @@ fn start_exec( spawned.map(|_| ()) } +/// `env` with the cooperative proxy variables pointed at `proxy_url`. +fn with_proxy_env(env: &[(String, String)], proxy_url: &str) -> Vec<(String, String)> { + let entries: Vec = env + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect(); + apply_cooperative_proxy_env(&entries, proxy_url) + .into_iter() + .filter_map(|entry| { + entry + .split_once('=') + .map(|(name, value)| (name.to_string(), value.to_string())) + }) + .collect() +} + /// The off-worker half of an exec: run it to completion and classify it. fn run_exec(job: ExecJob, logger: &mut Logger) -> ExecReport { let ExecJob { @@ -788,16 +806,18 @@ impl SessionHandle { &cancellation, stdin_relay, )?); - self.send(WorkerCommand::Container(ContainerWork::Exec(ExecRequest { - config, - sink, - cancellation, - stdin, - registration: Arc::clone(®istration), - admit, - done, - slot, - })))?; + self.send(WorkerCommand::Container(ContainerWork::Exec(Box::new( + ExecRequest { + config, + sink, + cancellation, + stdin, + registration: Arc::clone(®istration), + admit, + done, + slot, + }, + ))))?; admit_rx.await.map_err(worker_gone)??; Ok(ExecStream { done: done_rx, @@ -935,6 +955,9 @@ struct ContainerEntry { /// Keeps a quarantined sandbox counted against exec capacity, because a run /// whose termination was never confirmed may still hold a live process. exec_slot: Option, + + /// The cooperative proxy URL set at provision, applied to every exec. + network_proxy: Option, } /// A provision waiting on its image to arrive from a registry. @@ -963,7 +986,7 @@ pub(crate) enum ContainerWork { /// unknown, not-started or already-busy sandbox is a pre-admission typed /// error, never a post-admission stream `Error`); `done` carries the run's /// exit code once [`WorkerCommand::ExecFinished`] lands. - Exec(ExecRequest), + Exec(Box), Start { config: StartConfig, reply: oneshot::Sender>, @@ -2340,6 +2363,7 @@ impl Worker { container, session_ref: session_ref.to_string(), exec_slot: None, + network_proxy: config.network_proxy.clone(), }, ); Ok(sandbox_id) @@ -2401,7 +2425,7 @@ impl Worker { if let Some(in_flight) = self.exec_in_flight.get_mut(work.sandbox_id()) { match work { ContainerWork::Exec(request) => { - let ExecRequest { config, admit, .. } = request; + let ExecRequest { config, admit, .. } = *request; let _ = admit.send(Err(WorkerError::Busy(config.sandbox_id))); } lifecycle => in_flight.parked.push(lifecycle), @@ -2410,7 +2434,7 @@ impl Worker { } match work { - ContainerWork::Exec(request) => self.begin_exec(request, worker), + ContainerWork::Exec(request) => self.begin_exec(*request, worker), ContainerWork::Start { config, reply } => { let _ = reply.send(self.start(config)); } @@ -2448,6 +2472,21 @@ impl Worker { } }; + let mut config = config; + let provisioned = self.containers[&config.sandbox_id].network_proxy.as_deref(); + match policy::resolve_exec_proxy( + provisioned, + config.network_proxy.as_deref(), + config.env_scope, + ) { + Ok(Some(proxy_url)) => config.env = with_proxy_env(&config.env, proxy_url), + Ok(None) => {} + Err(message) => { + let _ = admit.send(Err(WorkerError::Rejected(anyhow::anyhow!(message)))); + return; + } + } + // Recorded here rather than looked up when the run reports, because // quarantine can delete the container entry while the run is still // going. @@ -3254,6 +3293,7 @@ mod tests { container: unsafe { WslcContainerGuard::from_raw(sentinel, release_noop) }, session_ref: TEST_SESSION.to_string(), exec_slot: None, + network_proxy: None, } } @@ -3293,6 +3333,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }, reply, session_ref: TEST_SESSION.to_string(), @@ -3322,6 +3363,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }, reply, session_ref: TEST_SESSION.to_string(), @@ -3474,6 +3516,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, } } @@ -3542,6 +3585,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }, reply, session_ref: TEST_SESSION.to_string(), @@ -3795,6 +3839,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }, parked, )); @@ -5203,6 +5248,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, }, None, ) @@ -5267,6 +5313,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, }, None, ) @@ -5495,6 +5542,7 @@ mod tests { container: unsafe { WslcContainerGuard::from_raw(sentinel, release) }, session_ref: TEST_SESSION.to_string(), exec_slot: None, + network_proxy: None, } } @@ -5549,7 +5597,7 @@ mod tests { Arc::new(register_exec(&active_execs, exec_id, "run", &cancellation, None).unwrap()); let sink: OutputSink = Box::new(|_, _| {}); TestExec { - work: ContainerWork::Exec(ExecRequest { + work: ContainerWork::Exec(Box::new(ExecRequest { config: ExecConfig { exec_id: exec_id.to_string(), run_token: "run".to_string(), @@ -5560,6 +5608,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, }, sink, cancellation: Arc::clone(&cancellation), @@ -5568,7 +5617,7 @@ mod tests { admit, done, slot, - }), + })), admit: admit_rx, done: done_rx, cancellation, @@ -5756,6 +5805,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 0, stdin: false, + network_proxy: None, }, Some(Arc::new(permit)), ) @@ -5953,6 +6003,102 @@ mod tests { ); } + /// Dispatch one exec on a container provisioned with `provisioned_proxy`, + /// returning its admission and the config the runner received, if any. + fn admit_proxied_exec( + provisioned_proxy: Option<&str>, + requested_proxy: Option<&str>, + env_scope: EnvScope, + ) -> (Result<(), WorkerError>, Option) { + thread_local! { + static STARTED: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + } + + fn record_config( + _: &Worker, + config: ExecConfig, + _: WslcContainer, + _: OutputSink, + _: Arc, + _: Option, + _: &mpsc::UnboundedSender, + ) -> Result<(), WorkerError> { + STARTED.with(|started| *started.borrow_mut() = Some(config)); + Ok(()) + } + + let (tx, _rx) = mpsc::unbounded_channel(); + let mut worker = worker_that_can_run(); + worker.start_run = record_config; + worker + .containers + .get_mut("wslc:ready") + .unwrap() + .network_proxy = provisioned_proxy.map(str::to_string); + let mut exec = exec_work("wslc:ready", "proxied"); + let ContainerWork::Exec(request) = &mut exec.work else { + unreachable!() + }; + request.config.env = vec![ + ("FOO".to_string(), "bar".to_string()), + ( + "HTTP_PROXY".to_string(), + "http://attacker.invalid:1".to_string(), + ), + ]; + request.config.env_scope = env_scope; + request.config.network_proxy = requested_proxy.map(str::to_string); + + STARTED.with(|started| started.borrow_mut().take()); + worker.dispatch(exec.work, &tx); + let admitted = exec.admit.try_recv().unwrap(); + ( + admitted, + STARTED.with(|started| started.borrow_mut().take()), + ) + } + + #[test] + fn an_exec_runs_under_the_proxy_set_at_provision() { + let provisioned = "http://proxy.example:8080"; + for requested in [None, Some(provisioned)] { + let (admitted, started) = + admit_proxied_exec(Some(provisioned), requested, EnvScope::Merge); + admitted.unwrap(); + let env = started.expect("the exec must reach the runner").env; + let value = |name: &str| env.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()); + assert_eq!(value("FOO"), Some("bar")); + assert_eq!(value("HTTP_PROXY"), Some(provisioned)); + assert_eq!(value("NO_PROXY"), Some("")); + assert!(!env.iter().any(|(_, v)| v.contains("attacker"))); + } + } + + #[test] + fn an_exec_naming_a_different_proxy_than_provision_is_rejected() { + let (admitted, started) = admit_proxied_exec( + Some("http://proxy.example:8080"), + Some("http://other.example:8080"), + EnvScope::Merge, + ); + assert!(matches!(admitted, Err(WorkerError::Rejected(_)))); + assert!(started.is_none()); + } + + #[test] + fn a_replaced_environment_refuses_a_credential_bearing_provisioned_proxy() { + let (admitted, started) = admit_proxied_exec( + Some("http://user:hunter2@proxy.example:8080"), + None, + EnvScope::Replace, + ); + let Err(WorkerError::Rejected(e)) = admitted else { + panic!("expected a rejection, got {admitted:?}"); + }; + assert!(!format!("{e:#}").contains("hunter2")); + assert!(started.is_none()); + } + #[test] fn an_unconfirmed_exec_keeps_its_slot_claimed() { let (tx, _rx) = mpsc::unbounded_channel(); @@ -6213,6 +6359,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }, reply, session_ref: TEST_SESSION.to_string(), @@ -6379,6 +6526,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }, None, ) @@ -6404,6 +6552,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: Some(session_ref.to_string()), + network_proxy: None, }) .await .expect("provision into the named session"); @@ -6613,6 +6762,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: Some(session_ref.to_string()), + network_proxy: None, }) .await { @@ -6641,6 +6791,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }, None, ) @@ -6861,6 +7012,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: Some(session_ref.clone()), + network_proxy: None, }) .await .expect("provision into the named session"); @@ -6897,6 +7049,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }) .await .unwrap(); @@ -6933,6 +7086,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, } } @@ -6987,6 +7141,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }) .await .unwrap(); @@ -7011,6 +7166,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }, None, ) @@ -7064,6 +7220,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }) .await }); @@ -7083,6 +7240,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }, None, ) @@ -7126,6 +7284,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }, None, ) @@ -7171,6 +7330,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }, None, ) @@ -7233,6 +7393,7 @@ mod tests { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }) .await .unwrap(); @@ -7255,6 +7416,7 @@ mod tests { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }, None, ) diff --git a/src/mxc-sdk/tests/wslc_daemon_ipc.rs b/src/mxc-sdk/tests/wslc_daemon_ipc.rs index 7f69cf8fe1..89feea4e3c 100644 --- a/src/mxc-sdk/tests/wslc_daemon_ipc.rs +++ b/src/mxc-sdk/tests/wslc_daemon_ipc.rs @@ -95,6 +95,7 @@ fn provisioned_and_started(client: &DaemonClient) -> String { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }) .expect("provision"); client @@ -117,6 +118,7 @@ fn stamped_exec(exec_id: &str, sandbox_id: &str, seconds: u32) -> ExecConfig { env_scope: EnvScope::Merge, timeout_ms: 60_000, stdin: false, + network_proxy: None, } } @@ -300,6 +302,7 @@ fn full_lifecycle_over_pipe() { network: Default::default(), port_mappings: Vec::new(), session_ref: None, + network_proxy: None, }) .expect("provision"); @@ -320,6 +323,7 @@ fn full_lifecycle_over_pipe() { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }) .expect("first exec"); assert_eq!(result.exit_code, 0, "echo hi should exit 0"); @@ -337,6 +341,7 @@ fn full_lifecycle_over_pipe() { env_scope: EnvScope::Merge, timeout_ms: 30_000, stdin: false, + network_proxy: None, }) .expect("second exec"); assert_eq!(result.exit_code, 7, "exit 7 should propagate its exit code"); diff --git a/tests/scripts/run_wslc_state_aware_tests.ps1 b/tests/scripts/run_wslc_state_aware_tests.ps1 index aef8dfed73..d960171d81 100644 --- a/tests/scripts/run_wslc_state_aware_tests.ps1 +++ b/tests/scripts/run_wslc_state_aware_tests.ps1 @@ -1251,6 +1251,98 @@ try { } } +# ---------------- Lifecycle CR: proxy set at provision ---------------- + +# A 1.1.0-alpha provision fixes runtimeConfig.networkProxy for the container; the +# daemon injects it into every exec, and an exec naming a different proxy is +# rejected. +$script:crSandboxId = $null +$script:crStarted = $false +$crDeprovisionedOk = $false +$crProxy = 'http://proxy.example:8080' +try { + $crProvisionedOk = Run-StateAwareTest "CR: provision (bridged network + proxy)" { + $r = Invoke-StateAware -Request @{ + version = '1.1.0-alpha' + phase = 'provision' + containment = 'wslc' + network = @{ + egress = @{ default = 'allow' } + ingress = @{ default = 'allow'; hostLoopback = 'allow' } + } + runtimeConfig = @{ networkProxy = $crProxy } + wslc = @{ provision = @{ image = 'alpine:latest' } } + } + $envObj = Assert-ResultEnvelope $r "proxied provision" + if ($envObj) { $script:crSandboxId = [string]$envObj.result.sandboxId } + } + + $crStartedOk = $false + if ($crProvisionedOk) { + $crStartedOk = Run-StateAwareTest "CR: start" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:crSandboxId + $null = Assert-ResultEnvelope $r "proxied start" + $script:crStarted = $true + } + } + + if ($crStartedOk) { + Run-StateAwareTest "CR: exec without runtimeConfig gets the provisioned proxy" { + $r = Invoke-StateAware -Request @{ + phase = 'exec' + sandboxId = $script:crSandboxId + process = @{ + commandLine = "sh -c 'echo HTTP_PROXY=[`$HTTP_PROXY] NO_PROXY=[`$NO_PROXY]'" + env = @('HTTP_PROXY=http://caller.invalid:1', 'NO_PROXY=*') + inheritDefaultEnv = $true + timeout = 30000 + } + } + Assert-True ($r.ExitCode -eq 0) "exit code = 0" + Assert-True ($r.Stdout.Contains("HTTP_PROXY=[$crProxy] NO_PROXY=[]")) ` + "provisioned proxy replaced the caller's proxy variables ($($r.Stdout.Trim()))" + } | Out-Null + + Run-StateAwareTest "CR: exec naming a different proxy is rejected" { + $r = Invoke-StateAware -Request @{ + phase = 'exec' + sandboxId = $script:crSandboxId + runtimeConfig = @{ networkProxy = 'http://other.example:8080' } + process = @{ commandLine = 'true'; timeout = 30000 } + } + Assert-True ($r.ExitCode -ne 0) "exit code is non-zero (mismatched exec proxy rejected)" + $envObj = Parse-StderrEnvelope -Stderr $r.Stderr + $code = if ($envObj) { $envObj.error.code } else { '' } + Assert-True ($code -eq 'policy_validation') "error.code is 'policy_validation' (got '$code')" + $msg = if ($envObj) { [string]$envObj.error.message } else { '' } + Assert-True ($msg -match 'must match the proxy set at provision') ` + "error.message names the provisioned proxy rule (got '$msg')" + } | Out-Null + } + + if ($crProvisionedOk) { + Run-StateAwareTest "CR: stop" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $script:crSandboxId + $null = Assert-ResultEnvelope $r "proxied stop" + $script:crStarted = $false + } | Out-Null + $crDeprovPassed = Run-StateAwareTest "CR: deprovision" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $script:crSandboxId + $null = Assert-ResultEnvelope $r "proxied deprovision" + } + if ($crDeprovPassed) { $crDeprovisionedOk = $true } + } +} finally { + if ($null -ne $script:crSandboxId -and -not $crDeprovisionedOk) { + Write-Host "" + Write-Host "[cleanup] best-effort deprovision of $script:crSandboxId" -ForegroundColor DarkGray + if ($script:crStarted) { + try { $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $script:crSandboxId } catch { } + } + try { $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $script:crSandboxId } catch { } + } +} + # ---------------- Lifecycle CP: provision-time port mappings ---------------- # `wslc.provision.portMappings` is container-scoped, so the daemon applies it to