Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/bwrap-support/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,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: <port>` or `url: <url>`.
2. The sandbox is then started **without** `--unshare-net` so the sandbox
shares the host network namespace and can reach the loopback proxy.
Expand Down Expand Up @@ -230,10 +230,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).
Expand Down
8 changes: 7 additions & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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`.
4 changes: 2 additions & 2 deletions docs/sandbox-policy/v1/policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. Error if `allowOutbound` is not set. |
| `blockedHosts` | Hosts to block even when outbound is allowed. Error if `allowOutbound` is not set. |
| `proxy` | `{ builtinTestServer: true }` or `{ url: "..." }`. Routes all traffic through this proxy. Cannot be combined with other network flags. |
| `proxy` | `{ builtinTestServer: true }`, `{ localhost: <port> }`, 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.

Expand Down
2 changes: 2 additions & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion sdk/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
MGudgin marked this conversation as resolved.
}
return resolved;
}

/**
Expand Down
11 changes: 11 additions & 0 deletions sdk/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -316,7 +319,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 };
};
Expand Down
4 changes: 2 additions & 2 deletions sdk/tests/integration/linux-bubblewrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
Expand Down Expand Up @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion sdk/tests/integration/windows-process-container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
54 changes: 54 additions & 0 deletions sdk/tests/unit/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
});
1 change: 1 addition & 0 deletions src/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 10 additions & 20 deletions src/backends/bubblewrap/common/src/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,9 @@ impl ScriptRunner 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.

// Reject timeouts smaller than our polling interval.
if request.script_timeout > 0 && u64::from(request.script_timeout) < POLL_INTERVAL_MS {
Expand Down Expand Up @@ -331,22 +323,20 @@ mod tests {
}

#[test]
fn validate_rejects_builtin_test_server_without_experimental() {
fn validate_runner_does_not_locally_gate_builtin_test_server() {
// The builtinTestServer gate moved to `wxc_common::validator::validate_common`
// (enforced centrally for every backend via `ScriptRunner::run`). 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_runner(&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_runner(&req).is_ok());
}

#[test]
Expand Down
7 changes: 7 additions & 0 deletions src/core/lxc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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,
Expand Down Expand Up @@ -204,6 +210,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);
Expand Down
7 changes: 7 additions & 0 deletions src/core/mxc_darwin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/core/wxc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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,
Expand Down Expand Up @@ -695,6 +701,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
Expand Down
1 change: 1 addition & 0 deletions src/core/wxc_common/src/config_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,7 @@ fn convert_raw_config_inner(
lxc_config,
seatbelt,
experimental_enabled: false,
testing_features_enabled: false,
experimental,
dry_run: false,
})
Expand Down
6 changes: 6 additions & 0 deletions src/core/wxc_common/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,12 @@ pub struct ExecutionRequest {
pub seatbelt: Option<SeatbeltConfig>,
/// 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
Expand Down
Loading
Loading