Skip to content

Commit 3b910d9

Browse files
[WSLC] Add cooperative HTTP/HTTPS proxy support (#652)
* Add cooperative HTTP/HTTPS proxy support for the WSLc backend * Addressed PR comments * Harden cooperative proxy support * Addressed PR comments * Addressed PR comments --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent a9257eb commit 3b910d9

11 files changed

Lines changed: 729 additions & 37 deletions

File tree

docs/schema.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ production configs and the dev schema when working on experimental features:
5555
"proxy": { "localhost": 8080 } // Loopback proxy port (processcontainer; bubblewrap; seatbelt)
5656
// (use { "builtinTestServer": true } for the bundled
5757
// testing-only proxy; requires --allow-testing-features)
58+
// WSLC supports the cooperative proxy too, but only via
59+
// { "url": "http://proxy.example:8080" } (own-netns:
60+
// localhost/builtinTestServer are unreachable, rejected)
5861
},
5962

6063
"processContainer": { // Process-based container-specific

docs/wsl/wsl-container-getting-started.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,66 @@ no separate `--setup-wslc` step is required.
227227
| `"allowOutbound": true` | Bridged networking (full access) |
228228
| `"allowOutbound": false` | No networking (isolated) |
229229

230+
### Network proxy (cooperative, unprivileged)
231+
232+
WSLC supports a **cooperative HTTP/HTTPS proxy**: setting `network.proxy`
233+
routes a container's egress through a proxy you provide. WSLC's kernel has
234+
**no in-kernel `iptables`**, so — exactly like the Bubblewrap backend — there
235+
is no netfilter drop-floor; enforcement is *cooperative*, applied by handing
236+
the workload proxy environment variables that well-behaved clients honor.
237+
238+
**How it works**
239+
240+
1. When `network.proxy` is set, the runner translates it into the
241+
`HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment
242+
variables inside the container (via `WslcSetProcessSettingsEnvVariables`).
243+
Any caller-supplied values for these keys — including `NO_PROXY` /
244+
`no_proxy` — are **stripped** from the *initial* process environment first.
245+
Because WSLC merges the process environment onto the image's baked-in
246+
`ENV`, the runner also sets `NO_PROXY` / `no_proxy` to the **empty string**,
247+
so an image-baked exemption (e.g. `ENV NO_PROXY=*`) cannot silently disable
248+
the proxy. This sanitizes the process's *starting* environment only; see the
249+
cooperative-model caveat below.
250+
2. Cooperative tools (curl, wget, Python `requests`, Node `https`, etc.) honor
251+
the env vars and their traffic flows through the proxy.
252+
253+
**Only the `url` form is supported.** A WSLC container runs in its own network
254+
namespace (a separate WSL system VM), so a host- or distro-loopback proxy is
255+
**not reachable** from inside the container. The proxy must be a routable
256+
address the container can reach:
257+
258+
```json
259+
{
260+
"version": "0.6.0-alpha",
261+
"containment": "wslc",
262+
"process": { "commandLine": "curl -fsSL https://example.com && echo OK" },
263+
"network": {
264+
"defaultPolicy": "allow",
265+
"proxy": { "url": "http://proxy.example:8080" }
266+
},
267+
"experimental": { "wslc": { "image": "alpine:latest" } }
268+
}
269+
```
270+
271+
The `localhost` and `builtinTestServer` proxy forms are **rejected at
272+
config-parse time** for WSLC (they imply a host-loopback / MXC-run proxy that
273+
the container cannot reach). The proxy also requires `defaultPolicy: "allow"`
274+
and no `allowedHosts` / `blockedHosts`: the container must have outbound
275+
networking to reach the proxy, and host lists are not forwarded to it — configs
276+
that combine the proxy with a `block` default or host lists are **rejected**.
277+
278+
**Caveats**
279+
280+
- **Cooperative model, not enforcement.** Only clients that honor the proxy
281+
env vars are routed through the proxy. Tools that bypass them (raw sockets,
282+
custom HTTP clients, statically-linked binaries that ignore the env) are
283+
**not** contained. WSLC cannot provide a hard network floor because its
284+
kernel lacks `iptables`. For strict network isolation, use
285+
`"allowOutbound": false` (no networking) instead.
286+
- **Consumer-provided proxy.** MXC does not start a proxy for WSLC; you supply
287+
a reachable one via `url`. Any host filtering is the proxy's responsibility —
288+
the runner does not forward `allowedHosts` / `blockedHosts` to it.
289+
230290
### Filesystem mounts
231291

232292
Paths in `filesystem.readwritePaths` and `filesystem.readonlyPaths` are mounted
@@ -249,6 +309,7 @@ the container.
249309

250310
- [`tests/examples/wslc_hello_world.json`](../../tests/examples/wslc_hello_world.json) — Hello world with Alpine
251311
- [`tests/configs/wslc_network_isolated.json`](../../tests/configs/wslc_network_isolated.json) — Network isolation
312+
- [`tests/configs/wslc_network_proxy.json`](../../tests/configs/wslc_network_proxy.json) — Cooperative HTTP proxy (`network.proxy.url`)
252313
- [`tests/configs/wslc_custom_registry_ghcr.json`](../../tests/configs/wslc_custom_registry_ghcr.json) — Pull from GitHub Container Registry
253314
- [`tests/configs/wslc_custom_registry_quay.json`](../../tests/configs/wslc_custom_registry_quay.json) — Pull from Quay.io
254315
- [`tests/configs/wslc_tar_import_rootfs.json`](../../tests/configs/wslc_tar_import_rootfs.json) — Import rootfs tar

sdk/node/src/types.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,24 @@ export interface NetworkConfig {
201201
/** Hostnames or IP addresses to block (firewall mode only) */
202202
blockedHosts?: string[];
203203
/** Proxy configuration (supported on Windows ProcessContainer, Linux Bubblewrap,
204-
* and macOS Seatbelt). On Bubblewrap/Seatbelt it is a cooperative env-var proxy
205-
* (HTTP_PROXY/HTTPS_PROXY): well-behaved HTTP clients honor it, raw-socket clients
206-
* can bypass it. `builtinTestServer` activates a bundled, testing-only proxy; the
207-
* SDK rejects it unless `allowTestingFeatures: true` is set in SandboxSpawnOptions
208-
* (which maps to the native `--allow-testing-features` flag). */
204+
* macOS Seatbelt, and WSLC). On Bubblewrap/Seatbelt/WSLC it is a cooperative
205+
* env-var proxy (HTTP_PROXY/HTTPS_PROXY): well-behaved HTTP clients honor it,
206+
* raw-socket clients can bypass it. `builtinTestServer` activates a bundled,
207+
* testing-only proxy; the SDK rejects it unless `allowTestingFeatures: true` is
208+
* set in SandboxSpawnOptions (which maps to the native `--allow-testing-features`
209+
* flag).
210+
*
211+
* WSLC imposes additional parse-time constraints (a violating config is
212+
* rejected before it runs):
213+
* - Only the `{ url }` form is accepted — its containers run in their own
214+
* network namespace, so the `localhost` / `builtinTestServer` loopback
215+
* forms are unreachable and rejected.
216+
* - The `url` scheme must be `http` or `https`.
217+
* - `defaultPolicy` must be `"allow"` and both `allowedHosts` and
218+
* `blockedHosts` must be empty/unset — WSLC has no in-kernel iptables, so
219+
* it cannot enforce host lists, and the container needs outbound
220+
* networking to reach the proxy at all.
221+
* Enforcement is cooperative (no in-kernel iptables). */
209222
proxy?: { builtinTestServer: true } | { localhost: number } | { url: string };
210223
/** Automatically remove firewall rules after execution (default: true). Deprecated: use lifecycle.preservePolicy. */
211224
removeRulesOnExit?: boolean;

src/backends/bubblewrap/common/src/bwrap_command.rs

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,7 @@ use std::collections::HashSet;
1111

1212
use wxc_common::filesystem_resolve::FsIntent;
1313
use wxc_common::models::{ExecutionRequest, NetworkPolicy, ProxyAddress};
14-
15-
/// Env var keys that the proxy block manages. Listed here so we can strip
16-
/// any conflicting entries the caller supplied via `request.env` (callers
17-
/// must not be able to defeat the cooperative proxy by injecting their own
18-
/// proxy env vars).
19-
const PROXY_ENV_KEYS: &[&str] = &[
20-
"HTTP_PROXY",
21-
"HTTPS_PROXY",
22-
"ALL_PROXY",
23-
"http_proxy",
24-
"https_proxy",
25-
"all_proxy",
26-
"NO_PROXY",
27-
"no_proxy",
28-
];
14+
use wxc_common::proxy_env::{is_managed_proxy_key, PROXY_SET_KEYS};
2915

3016
/// Read-only host paths bind-mounted into every Bubblewrap sandbox as the
3117
/// deny-by-default baseline. Mirrors the seatbelt backend's
@@ -231,7 +217,7 @@ pub fn build_args_classified(
231217
if let Some((key, value)) = env_str.split_once('=') {
232218
// When the proxy is active, drop any caller-supplied proxy env
233219
// entries so they cannot override the values we set below.
234-
if proxy_address.is_some() && PROXY_ENV_KEYS.contains(&key) {
220+
if proxy_address.is_some() && is_managed_proxy_key(key) {
235221
continue;
236222
}
237223
args.extend(["--setenv".into(), key.into(), value.into()]);
@@ -254,15 +240,8 @@ pub fn build_args_classified(
254240
// destinations.
255241
if let Some(addr) = proxy_address {
256242
let url = addr.to_url();
257-
for key in [
258-
"HTTP_PROXY",
259-
"HTTPS_PROXY",
260-
"ALL_PROXY",
261-
"http_proxy",
262-
"https_proxy",
263-
"all_proxy",
264-
] {
265-
args.extend(["--setenv".into(), key.into(), url.clone()]);
243+
for key in PROXY_SET_KEYS {
244+
args.extend(["--setenv".into(), (*key).into(), url.clone()]);
266245
}
267246
}
268247

src/backends/wslc/common/src/wsl_container_runner.rs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -975,17 +975,57 @@ impl WSLContainerRunner {
975975
return sdk_error("WslcSetProcessSettingsCmdLine failed", hr, "");
976976
}
977977

978-
if !request.env.is_empty() {
979-
let env_cstrings: Vec<Vec<u8>> = request
980-
.env
978+
// Route egress through the cooperative proxy: WSLc has no in-kernel
979+
// iptables, so per-host policy is enforced at the proxy layer by
980+
// injecting HTTP(S)_PROXY (and scrubbing caller-supplied proxy vars).
981+
// See wxc_common::proxy_env.
982+
let effective_env: Vec<String> = if request.policy.network_proxy.is_enabled() {
983+
// url-only (also enforced at parse time). Fail fast rather than
984+
// inject an empty HTTP_PROXY= for the localhost/builtinTestServer
985+
// forms, which carry no routable URL.
986+
let proxy_url = match request
987+
.policy
988+
.network_proxy
989+
.address
990+
.as_ref()
991+
.and_then(|addr| addr.original_url.clone())
992+
{
993+
Some(url) => url,
994+
None => {
995+
return ScriptResponse::error(
996+
"WSLC: network.proxy requires the 'url' form (a routable proxy URL); \
997+
the localhost and builtinTestServer forms are not supported because a \
998+
WSLc container runs in its own network namespace.",
999+
);
1000+
}
1001+
};
1002+
let _ = writeln!(
1003+
logger,
1004+
"[WSLC] Cooperative network proxy configured: {}",
1005+
wxc_common::proxy_env::redact_proxy_url(&proxy_url)
1006+
);
1007+
wxc_common::proxy_env::apply_cooperative_proxy_env(&request.env, &proxy_url)
1008+
} else {
1009+
request.env.clone()
1010+
};
1011+
1012+
// Env buffers must outlive WslcCreateContainer: the SDK stores the
1013+
// pointers into process_settings (it does not copy), and reads them at
1014+
// container-create time. Hoisting to function scope keeps them alive —
1015+
// mirrors the cmdline/_cwd_cstr handling. Scoping them inside the `if`
1016+
// below frees them early and causes a use-after-free (0xC0000005).
1017+
let _env_cstrings: Vec<Vec<u8>>;
1018+
let _env_ptrs: Vec<PCSTR>;
1019+
if !effective_env.is_empty() {
1020+
_env_cstrings = effective_env
9811021
.iter()
9821022
.map(|e| format!("{}\0", e).into_bytes())
9831023
.collect();
984-
let env_ptrs: Vec<PCSTR> = env_cstrings.iter().map(|e| e.as_ptr() as PCSTR).collect();
1024+
_env_ptrs = _env_cstrings.iter().map(|e| e.as_ptr() as PCSTR).collect();
9851025
let hr = sdk.WslcSetProcessSettingsEnvVariables(
9861026
&mut process_settings,
987-
env_ptrs.as_ptr(),
988-
env_ptrs.len(),
1027+
_env_ptrs.as_ptr(),
1028+
_env_ptrs.len(),
9891029
);
9901030
if hr != S_OK {
9911031
return sdk_error("WslcSetProcessSettingsEnvVariables failed", hr, "");

0 commit comments

Comments
 (0)