diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 9c8db5c0d..b3ed48b76 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -208,7 +208,7 @@ Bubblewrap because it requires **no root and no `CAP_NET_ADMIN`**. 1. When `network.proxy` is set, the runner launches an unprivileged HTTP proxy on loopback (`127.0.0.1:N`). For tests, the bundled `linux-test-proxy` binary is used (`builtinTestServer: true`, - testing-only and gated behind `--experimental`); in production callers + testing-only and gated behind `--allow-testing-features`); in production callers supply their own proxy via `localhost: ` or `url: `. 2. The sandbox is then started **without** `--unshare-net` so the sandbox shares the host network namespace and can reach the loopback proxy. @@ -277,10 +277,11 @@ Bubblewrap because it requires **no root and no `CAP_NET_ADMIN`**. **not** forward `allowedHosts` / `blockedHosts` / `defaultPolicy: "block"` to it, and config combinations that would silently weaken enforcement are rejected at parse time. -- **`builtinTestServer` is testing-only**: gated behind `--experimental` +- **`builtinTestServer` is testing-only**: gated behind `--allow-testing-features` and never to be used as a real production proxy. It has no auth, no body-size limits, and minimal hop-by-hop header handling. Use a real - HTTP proxy for production deployments. + HTTP proxy for production deployments. (Selecting the Bubblewrap backend + itself still also requires `--experimental`.) - **HTTPS via CONNECT**: the proxy uses HTTP `CONNECT` tunnels for TLS, so certificate validation continues to work end-to-end (the proxy does not see plaintext). diff --git a/docs/examples.md b/docs/examples.md index bcdf7a37e..f349f0f2f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -82,4 +82,10 @@ an OS-assigned port (for integration testing only, not production): ``` When `builtinTestServer` is `true`, it must be the only key in the `proxy` -object. \ No newline at end of file +object. Because it activates a deliberately-permissive, testing-only proxy +(no auth, no body limits), it is **not** enabled by default: pass the +`--allow-testing-features` flag to `wxc-exec`/`lxc-exec`/`mxc-exec-mac`. This +is a separate axis from `--experimental` (which selects experimental backends +and features). The MXC SDK exposes the same gate as the `allowTestingFeatures` +spawn option, which must be set to `true` for a policy that uses +`builtinTestServer`. \ No newline at end of file diff --git a/docs/sandbox-policy/v1/policy.md b/docs/sandbox-policy/v1/policy.md index a4c1e8a5c..52520a6b3 100644 --- a/docs/sandbox-policy/v1/policy.md +++ b/docs/sandbox-policy/v1/policy.md @@ -187,7 +187,7 @@ type SandboxPolicy = { allowLocalNetwork?: boolean; allowedHosts?: string[]; blockedHosts?: string[]; - proxy?: { builtinTestServer: true } | { url: string }; + proxy?: { builtinTestServer: true } | { localhost: number } | { url: string }; }; ui?: { allowWindows?: boolean; @@ -219,7 +219,7 @@ All flags default to `false` (no network access). | `allowLocalNetwork`| Allow connections to local networks. | | `allowedHosts` | When set, ONLY these outbound hosts are reachable. Host-filtering backends (Linux, macOS) accept this without `allowOutbound`; Windows ProcessContainer requires `allowOutbound`. | | `blockedHosts` | Hosts to block even when outbound is allowed. Same `allowOutbound` requirement as `allowedHosts` (Windows ProcessContainer only). | -| `proxy` | `{ builtinTestServer: true }` or `{ url: "..." }`. Routes all traffic through this proxy. Cannot be combined with other network flags. | +| `proxy` | `{ builtinTestServer: true }`, `{ localhost: }`, or `{ url: "..." }`. Routes all traffic through this proxy. Cannot be combined with other network flags. `builtinTestServer` is testing-only and requires the `--allow-testing-features` flag (set `allowTestingFeatures: true` in the SDK spawn options). | Omitted = no network access. diff --git a/docs/schema.md b/docs/schema.md index 1de799a95..49ebd302c 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -53,6 +53,8 @@ production configs and the dev schema when working on experimental features: "defaultPolicy": "block", // "allow" or "block" "enforcementMode": "firewall", // "capabilities", "firewall", or "both" "proxy": { "localhost": 8080 } // Loopback proxy port (processcontainer; bubblewrap) + // (use { "builtinTestServer": true } for the bundled + // testing-only proxy; requires --allow-testing-features) }, "processContainer": { // Process-based container-specific diff --git a/sdk/src/helper.ts b/sdk/src/helper.ts index 08e0d0762..d0d2fbabe 100644 --- a/sdk/src/helper.ts +++ b/sdk/src/helper.ts @@ -267,7 +267,28 @@ export function resolveExecutableAndArgs( } } - return resolveBinaryAndCommonArgs(JSON.stringify(config), options); + // `network.proxy.builtinTestServer` is testing-only, deliberately-permissive + // scaffolding that the native binary gates behind `--allow-testing-features`. + // Mirror that fail-closed posture at the SDK boundary: the caller must opt in + // explicitly via `allowTestingFeatures` (a distinct axis from `experimental`). + // Forwarding the flag automatically whenever the policy used the feature would + // make the gate meaningless — requesting the dangerous feature would silently + // enable the gate that is supposed to guard it. + const proxy = config.network?.proxy as { builtinTestServer?: boolean } | undefined; + const usesBuiltinTestServer = proxy?.builtinTestServer === true; + if (usesBuiltinTestServer && !options.allowTestingFeatures) { + throw new Error( + "network.proxy.builtinTestServer is a testing-only feature. Set " + + "'allowTestingFeatures: true' in SandboxSpawnOptions to enable it. For " + + "production, point network.proxy at a real HTTP proxy via 'localhost' or 'url'.", + ); + } + + const resolved = resolveBinaryAndCommonArgs(JSON.stringify(config), options); + if (usesBuiltinTestServer) { + resolved.args.push('--allow-testing-features'); + } + return resolved; } /** diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index b53c6bf08..19e6b63a6 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -413,6 +413,17 @@ export interface SandboxSpawnOptions { */ experimental?: boolean; + /** + * Allow testing-only, deliberately-permissive features that must never run + * in production — currently `network.proxy.builtinTestServer` (a bundled + * test HTTP proxy with no auth, no body limits, minimal hop-by-hop header + * handling). This is a distinct axis from {@link experimental}: a policy + * that requests such a feature is rejected unless this is explicitly set, + * keeping the gate fail-closed at the SDK boundary (it maps to the native + * `--allow-testing-features` flag). + */ + allowTestingFeatures?: boolean; + /** * Explicit path to the wxc-exec (or lxc-exec) binary. * When set, the SDK uses this path directly instead of searching. diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 3370dc629..65a0d7c74 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -200,7 +200,10 @@ export interface NetworkConfig { allowedHosts?: string[]; /** Hostnames or IP addresses to block (firewall mode only) */ blockedHosts?: string[]; - /** Proxy configuration (supported on Windows ProcessContainer and Linux Bubblewrap) */ + /** Proxy configuration (supported on Windows ProcessContainer and Linux Bubblewrap). + * `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; @@ -329,7 +332,10 @@ export type SandboxPolicy = { blockedHosts?: string[]; /** * Proxy configuration. Routes all traffic through this proxy. - * Cannot be combined with other network flags. + * Cannot be combined with other network flags. `builtinTestServer` + * selects 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 }; }; diff --git a/sdk/tests/integration/linux-bubblewrap.test.ts b/sdk/tests/integration/linux-bubblewrap.test.ts index cec38e566..28ab3724c 100644 --- a/sdk/tests/integration/linux-bubblewrap.test.ts +++ b/sdk/tests/integration/linux-bubblewrap.test.ts @@ -138,7 +138,7 @@ describe('Linux Bubblewrap network proxy (schema 0.6.0-alpha)', { proxy: { builtinTestServer: true }, }; - const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true }); + const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true, allowTestingFeatures: true }); assert.strictEqual(result.exitCode, 0, `builtin-proxy run failed: ${result.stdout}`); assert.ok(result.stdout.includes('BUILTIN_OK'), `missing BUILTIN_OK in: ${result.stdout}`); }); @@ -167,7 +167,7 @@ describe('Linux Bubblewrap network proxy (schema 0.6.0-alpha)', { allowedHosts: ['api.github.com'], }; - const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true }); + const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true, allowTestingFeatures: true }); assert.strictEqual(result.exitCode, 0, `allowlist run failed: ${result.stdout}`); assert.ok(result.stdout.includes('SENTINEL_OK'), `missing SENTINEL_OK in: ${result.stdout}`); assert.ok(result.stdout.includes('BLOCKED_OK'), `disallowed host was not blocked: ${result.stdout}`); diff --git a/sdk/tests/integration/windows-process-container.test.ts b/sdk/tests/integration/windows-process-container.test.ts index 2f54f7707..b9afef799 100644 --- a/sdk/tests/integration/windows-process-container.test.ts +++ b/sdk/tests/integration/windows-process-container.test.ts @@ -159,7 +159,7 @@ describe(`Windows Process Container (schema ${schemaVersion})`, { `$h.Send(); ` + `Write-Output ('PROXY_RESPONSE: ' + $h.ResponseText)"`; const result = await sdk.spawnSandboxAsync( - script, policy, { debug: true }, undefined, `proxy-builtin-${schemaVersion}`, + script, policy, { debug: true, allowTestingFeatures: true }, undefined, `proxy-builtin-${schemaVersion}`, ); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); diff --git a/sdk/tests/unit/sandbox.test.ts b/sdk/tests/unit/sandbox.test.ts index 03a39986d..1fd5accd7 100644 --- a/sdk/tests/unit/sandbox.test.ts +++ b/sdk/tests/unit/sandbox.test.ts @@ -1140,4 +1140,58 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk assert.strictEqual(envelope.containment, 'appcontainer'); }); }); + + describe('builtinTestServer testing-features gate', () => { + it('forwards --allow-testing-features when the caller opts in via allowTestingFeatures', () => { + const config: ContainerConfig = { + version: '0.5.0-alpha', + containment: 'process', + process: { commandLine: 'echo hi' }, + network: { proxy: { builtinTestServer: true } }, + }; + const { args } = resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + allowTestingFeatures: true, + }); + assert.ok( + args.includes('--allow-testing-features'), + 'expected --allow-testing-features to be forwarded', + ); + }); + + it('throws when builtinTestServer is used without allowTestingFeatures', () => { + const config: ContainerConfig = { + version: '0.5.0-alpha', + containment: 'process', + process: { commandLine: 'echo hi' }, + network: { proxy: { builtinTestServer: true } }, + }; + assert.throws( + () => + resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + }), + { message: /allowTestingFeatures: true/ }, + ); + }); + + it('does not forward --allow-testing-features for a non-test proxy', () => { + const config: ContainerConfig = { + version: '0.5.0-alpha', + containment: 'process', + process: { commandLine: 'echo hi' }, + network: { proxy: { url: 'http://localhost:8080' } }, + }; + const { args } = resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + }); + assert.ok( + !args.includes('--allow-testing-features'), + 'did not expect --allow-testing-features for a url proxy', + ); + }); + }); }); diff --git a/src/Cargo.lock b/src/Cargo.lock index 26ff4e3d1..0c693c46d 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -2990,6 +2990,7 @@ version = "0.7.0" dependencies = [ "anyhow", "mxc_build_common", + "serde_json", ] [[package]] diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index 2c297872b..1bc0e3c94 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -77,17 +77,9 @@ impl SandboxBackend for BubblewrapScriptRunner { )); } - // The bundled `linux-test-proxy` is a testing-only HTTP proxy with - // a deliberately permissive feature set (no auth, no body limits, - // no hop-by-hop header handling). Gate it behind --experimental so - // it cannot be enabled from a stock production config. - if request.policy.network_proxy.builtin_test_server && !request.experimental_enabled { - return Err(ScriptResponse::error( - "network.proxy.builtinTestServer is a testing-only feature and requires \ - --experimental. For production, point network.proxy at a real HTTP \ - proxy via 'localhost' or 'url'.", - )); - } + // `network.proxy.builtinTestServer` is gated centrally in + // `validate_common` (ahead of every `ScriptRunner::run`), so no + // backend-local check is needed here. if !Self::is_bwrap_available() { return Err(ScriptResponse::error( @@ -479,22 +471,20 @@ mod tests { } #[test] - fn validate_rejects_builtin_test_server_without_experimental() { + fn validate_does_not_locally_gate_builtin_test_server() { + // The builtinTestServer gate moved to `wxc_common::validator::validate_common` + // (enforced centrally for every backend). The bwrap runner must therefore no + // longer reject it locally — otherwise the gate would be applied twice with + // diverging messages. let mut req = base_request(); req.policy.network_proxy = ProxyConfig { address: None, builtin_test_server: true, }; - req.experimental_enabled = false; + req.testing_features_enabled = false; let runner = BubblewrapScriptRunner::new(); - let err = runner.validate(&req).unwrap_err(); - assert!( - err.error_message.contains("builtinTestServer") - && err.error_message.contains("--experimental"), - "expected experimental-gate error, got: {}", - err.error_message - ); + assert!(runner.validate(&req).is_ok()); } #[test] diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 7c1bddf6f..cee7d9d46 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -53,6 +53,12 @@ struct Cli { #[arg(long)] experimental: bool, + /// Allow testing-only features that must never run in production, currently + /// `network.proxy.builtinTestServer` (a bundled, deliberately-permissive + /// test HTTP proxy). Distinct from --experimental. + #[arg(long = "allow-testing-features")] + allow_testing_features: bool, + /// Parse and validate config then exit without executing #[arg(long = "dry-run")] dry_run: bool, @@ -206,6 +212,7 @@ fn main() { }; request.experimental_enabled = cli.experimental; + request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; log_request(&request, &mut logger); diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index 048ca3aca..bc0fe8ef6 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -48,6 +48,12 @@ struct Cli { #[arg(long)] experimental: bool, + /// Allow testing-only features that must never run in production, currently + /// `network.proxy.builtinTestServer` (a bundled, deliberately-permissive + /// test HTTP proxy). Distinct from --experimental. + #[arg(long = "allow-testing-features")] + allow_testing_features: bool, + /// Parse and validate config then exit without executing #[arg(long = "dry-run")] dry_run: bool, @@ -109,6 +115,7 @@ fn main() { }; request.experimental_enabled = cli.experimental; + request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; log_request(&request, &mut logger); diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 1fa5228bb..9ae68a281 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -64,6 +64,12 @@ struct Cli { #[arg(long)] experimental: bool, + /// Allow testing-only features that must never run in production, currently + /// `network.proxy.builtinTestServer` (a bundled, deliberately-permissive + /// test HTTP proxy). Distinct from --experimental. + #[arg(long = "allow-testing-features")] + allow_testing_features: bool, + /// Parse and validate config then exit without executing #[arg(long = "dry-run")] dry_run: bool, @@ -697,6 +703,7 @@ fn main() { let mut request = request; request.experimental_enabled = cli.experimental; + request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; // Apply the CLI command-line override to one-shot requests. State-aware diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 61278bd77..de72c81d7 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -1374,6 +1374,7 @@ fn convert_raw_config_inner( lxc_config, seatbelt, experimental_enabled: false, + testing_features_enabled: false, experimental, dry_run: false, }) diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 29a41f21e..f1ff02280 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -574,6 +574,12 @@ pub struct ExecutionRequest { pub seatbelt: Option, /// Whether the --experimental flag was passed. pub experimental_enabled: bool, + /// Whether the --allow-testing-features flag was passed. Gates testing-only, + /// deliberately-permissive helpers (currently `network.proxy.builtinTestServer`) + /// that must never activate from a stock production config. This is a distinct + /// axis from `experimental_enabled`: "experimental" means unstable/new, whereas + /// this means "not-for-production testing scaffolding". + pub testing_features_enabled: bool, /// Experimental feature configs (only applied when experimental_enabled is true). pub experimental: ExperimentalConfig, /// Dry-run mode: validate config and runner setup then return success diff --git a/src/core/wxc_common/src/validator.rs b/src/core/wxc_common/src/validator.rs index f6e6d4225..d16c71dd9 100644 --- a/src/core/wxc_common/src/validator.rs +++ b/src/core/wxc_common/src/validator.rs @@ -9,6 +9,20 @@ pub fn validate_common(request: &ExecutionRequest) -> Result<(), ScriptResponse> if request.script_code.is_empty() { return Err(ScriptResponse::error("Script content must not be empty.")); } + + // Enforce the testing-only-features gate centrally so it applies uniformly + // to all backends — every backend runs `validate_common` before executing. + // Currently this gates `network.proxy.builtinTestServer` (a deliberately- + // permissive test proxy); see `ExecutionRequest::testing_features_enabled` + // for the rationale behind the dedicated `--allow-testing-features` axis. + if request.policy.network_proxy.builtin_test_server && !request.testing_features_enabled { + return Err(ScriptResponse::error( + "network.proxy.builtinTestServer is a testing-only feature and requires the \ + --allow-testing-features flag. For production, point network.proxy at a real \ + HTTP proxy via 'localhost' or 'url'.", + )); + } + Ok(()) } @@ -86,4 +100,34 @@ mod tests { }; assert!(validate_exec_common(&req).is_ok()); } + + #[test] + fn rejects_builtin_test_server_without_testing_features() { + let mut req = ExecutionRequest { + script_code: "echo hi".to_string(), + ..Default::default() + }; + req.policy.network_proxy.builtin_test_server = true; + req.testing_features_enabled = false; + + let err = validate_common(&req).unwrap_err(); + assert!( + err.error_message.contains("builtinTestServer") + && err.error_message.contains("--allow-testing-features"), + "expected testing-gate error, got: {}", + err.error_message + ); + } + + #[test] + fn accepts_builtin_test_server_with_testing_features() { + let mut req = ExecutionRequest { + script_code: "echo hi".to_string(), + ..Default::default() + }; + req.policy.network_proxy.builtin_test_server = true; + req.testing_features_enabled = true; + + assert!(validate_common(&req).is_ok()); + } } diff --git a/src/testing/wxc_test_driver/Cargo.toml b/src/testing/wxc_test_driver/Cargo.toml index 7a9bf08ef..f9556dd53 100644 --- a/src/testing/wxc_test_driver/Cargo.toml +++ b/src/testing/wxc_test_driver/Cargo.toml @@ -10,6 +10,7 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +serde_json.workspace = true [build-dependencies] mxc_build_common.workspace = true diff --git a/src/testing/wxc_test_driver/src/main.rs b/src/testing/wxc_test_driver/src/main.rs index 10efb0c74..53e4debad 100644 --- a/src/testing/wxc_test_driver/src/main.rs +++ b/src/testing/wxc_test_driver/src/main.rs @@ -52,12 +52,31 @@ fn run_configs(config_path: &std::path::Path, debug: bool) -> anyhow::Result<()> cmd.arg("--debug"); } - // Windows Sandbox is experimental — pass --experimental when the config uses it. - let content = fs::read_to_string(path).unwrap_or_default(); - if content.contains("\"containment\": \"windows_sandbox\"") - || content.contains("\"containment\":\"windows_sandbox\"") - { - cmd.arg("--experimental"); + // Derive the flags wxc-exec needs from the config's actual JSON fields + // rather than sniffing for substrings. Parsing leniently: a config that + // fails to parse just gets no extra flags (wxc-exec will report the real + // error), matching the prior best-effort behavior. + let config_json: Option = fs::read_to_string(path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()); + + if let Some(config) = &config_json { + // Windows Sandbox is experimental — pass --experimental when selected. + if config.get("containment").and_then(|c| c.as_str()) == Some("windows_sandbox") { + cmd.arg("--experimental"); + } + + // builtinTestServer is testing-only scaffolding gated behind + // --allow-testing-features — pass it when the config opts in. + let builtin_test_server = config + .get("network") + .and_then(|n| n.get("proxy")) + .and_then(|p| p.get("builtinTestServer")) + .and_then(|b| b.as_bool()) + .unwrap_or(false); + if builtin_test_server { + cmd.arg("--allow-testing-features"); + } } let output = cmd.output()?; diff --git a/tests/scripts/run_bwrap_network_proxy_test.sh b/tests/scripts/run_bwrap_network_proxy_test.sh index 478f17ad4..908b70ec7 100644 --- a/tests/scripts/run_bwrap_network_proxy_test.sh +++ b/tests/scripts/run_bwrap_network_proxy_test.sh @@ -24,7 +24,7 @@ run_one() { local sentinel="$3" echo "Running Bubblewrap network proxy test: $label..." local out - if ! out=$("$LXC_EXEC" --experimental "$REPO_DIR/tests/configs/$config" 2>&1); then + if ! out=$("$LXC_EXEC" --experimental --allow-testing-features "$REPO_DIR/tests/configs/$config" 2>&1); then echo "$out" echo "FAIL: $label (lxc-exec returned non-zero)" return 1