Skip to content
Merged
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
46 changes: 35 additions & 11 deletions docs/bwrap-support/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,11 +668,16 @@ request fails if its private namespace cannot be configured.
proxy-mode execution on the host indefinitely. A successful probe is cached
for the life of the process; failures are not, so installing the missing
tool takes effect without a restart.
1. When `network.proxy` is set, the runner launches an unprivileged HTTP
proxy on loopback (`127.0.0.1:N`). For tests, the bundled
`unix-test-proxy` binary is used (`builtinTestServer: true`,
testing-only and gated behind `--allow-testing-features`); in production callers
supply their own proxy via `localhost: <port>` or `url: <url>`.
1. When a proxy is requested, the runner routes the sandbox to it; the caller
starts it. `runtimeConfig.networkProxy` names it from schema 0.8 onward and
is the only spelling on 0.9; the parser accepts only a loopback endpoint
there. The legacy `network.proxy` field names it on 0.6–0.8 and also accepts
a hostname or routable endpoint, which is resolved on the host and pinned
into the sandbox's `/etc/hosts`; there `builtinTestServer: true`
additionally makes the runner launch the bundled `unix-test-proxy` on
loopback (testing-only, gated behind `--allow-testing-features`). Both
spellings normalize to the same `policy.network_proxy`, so 0.8 accepts
either and enforces them identically.
2. The runner creates a same-UID user-namespace supervisor, starts Bubblewrap
with `--unshare-net`, and keeps the workload behind a startup barrier.
3. The supervisor attaches `slirp4netns` to Bubblewrap's private network
Expand Down Expand Up @@ -747,7 +752,25 @@ The monitor is disarmed *before* teardown stops the supervisor, so an ordinary
shutdown β€” which closes the same descriptor β€” is never reported as a loss. Only
an exit is detected; see [Limitations](#limitations).

### Example: builtin test proxy with allowlist
### Example: proxy on v0.9

```json
{
"version": "0.9.0-alpha",
"containment": "bubblewrap",
"process": { "commandLine": "curl -fsSL https://example.com" },
"network": {
"egress": { "default": "deny" },
"ingress": { "default": "deny", "hostLoopback": "deny" }
},
"runtimeConfig": { "networkProxy": "http://127.0.0.1:8080" }
}
```

A proxy request is the proxy-only posture, so `egress.default` must be `deny`
with no `allow` / `deny` rules; the chain opens the proxy endpoint alone.

### Example (legacy, ≀0.8): builtin test proxy with allowlist

```json
{
Expand All @@ -765,7 +788,7 @@ an exit is detected; see [Limitations](#limitations).
}
```

### Example: external proxy on loopback
### Example (legacy, ≀0.8): external proxy on loopback

```json
{
Expand All @@ -778,10 +801,11 @@ an exit is detected; see [Limitations](#limitations).
}
```

> Both examples declare `0.8.0-alpha` deliberately: the private-namespace and
> egress-enforcement behavior described above is selected by the schema version,
> so the same config on `0.6`/`0.7` runs the legacy shared-host-network proxy
> path instead.
> Both legacy examples declare `0.8.0-alpha` deliberately: the
> private-namespace and egress-enforcement behavior described above is selected
> by the schema version, so the same config on `0.6`/`0.7` runs the legacy
> shared-host-network proxy path instead. `network.proxy`, `allowedHosts`, and
> `blockedHosts` are not accepted on v0.9.

### Checking host support before you run

Expand Down
1 change: 1 addition & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ use:
|---------|----------------------------------------|
| Windows ProcessContainer (AppContainer / BaseContainer) | First `readwritePaths` entry that is an existing directory, else the first such `readonlyPaths` entry, else the system drive root (`%SystemDrive%\`). Never `NULL`. |
| Seatbelt (macOS) | Same precedence, with `~` expanded as the profile expands it; falls back to `/`. |
| Bubblewrap (Linux) | No substitution β€” a policy grant is never adopted. `--chdir` is emitted only for an explicit `process.cwd`, which from 0.9 is also normalized against the sandbox root and used as `HOME`. With no explicit `cwd` there is no `--chdir` and `HOME` is unset β€” see [`docs/bwrap-support/bubblewrap-backend.md`](bwrap-support/bubblewrap-backend.md). |
| LXC / WSL Container | The container root β€” see [`docs/lxc-support/lxc-backend.md`](lxc-support/lxc-backend.md). |
| MicroVM (NanVix) / Hyperlight | Not applicable β€” these backends reject a working directory outright. |

Expand Down
133 changes: 129 additions & 4 deletions sdk/node/tests/integration/linux-bubblewrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,14 @@ describe(`Linux Bubblewrap (schema ${schemaVersion})`, {
// Network proxy tests use the cooperative env-var proxy, which is
// unprivileged by design -- the entire reason the proxy path exists is to
// avoid the root requirement of iptables-based enforcement. Gate on
// "Linux + bwrap available" rather than "Linux + root". Pinned to schema
// 0.6.0-alpha because Bubblewrap proxy support is only available in 0.6+.
const PROXY_SCHEMA = '0.6.0-alpha';
describe('Linux Bubblewrap network proxy (schema 0.6.0-alpha)', {
// "Linux + bwrap available" rather than "Linux + root".
//
// Pinned to 0.7 to hold the *legacy* proxy shape: `network.proxy`,
// `defaultPolicy` and `allowedHosts` were removed in 0.9, and below 0.8 they
// run on the shared host network, so this block needs no slirp4netns. The 0.9
// spelling is covered separately below.
const PROXY_SCHEMA = '0.7.0-alpha';
describe(`Linux Bubblewrap network proxy, legacy shape (schema ${PROXY_SCHEMA})`, {
skip: !isLinuxBubblewrap
? 'Linux Bubblewrap proxy tests require Linux with bwrap installed'
: undefined,
Expand Down Expand Up @@ -178,6 +182,127 @@ describe('Linux Bubblewrap network proxy (schema 0.6.0-alpha)', {
});
});

// Schema 0.9 removed `network.proxy` along with `defaultPolicy` and the host
// lists, so the legacy block above has no 0.9 translation. A 0.9 proxy is a
// real endpoint named by `runtimeConfig.networkProxy`, which the parser accepts
// only on loopback, and the request resolves to the proxy-only posture: egress
// must be deny-by-default with no rules, and the backend opens the proxy
// endpoint alone.
//
// That posture is enforced from inside a private network namespace routed by
// rootless slirp4netns, which the legacy path does not need -- hence the extra
// prerequisite here.
//
// The SDK's own capability answers it: the native probe runs `slirp4netns
// --version`, checks that private namespaces can actually be unshared, and
// inspects the iptables backend, so it fails closed on any part of the
// dependency set. Testing for the binary alone would let a host with an
// unusable slirp, `unshare`, `nsenter`, `iptables`, or `ip6tables` past the
// gate and report an environmental failure as a test failure.
const PROXY_SCHEMA_09 = '0.9.0-alpha';
const hasProxyEnforcement =
isLinuxBubblewrap &&
sdk.getPlatformSupport().bubblewrapNetwork?.proxyEnforcement === 'supported';

describe(`Linux Bubblewrap network proxy (schema ${PROXY_SCHEMA_09})`, {
skip: !isLinuxBubblewrap
? 'Linux Bubblewrap proxy tests require Linux with bwrap installed'
: !hasProxyEnforcement
? 'this host cannot enforce proxy-only egress (see PlatformSupport.bubblewrapNetwork.warnings)'
: undefined,
}, () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-sdk-bwrap-proxy-09-'));
const proxies: ChildProcess[] = [];

// The helper announces its port through a fixed-name ready file, so two
// proxies sharing a directory would have the second read the first one's
// port. Each gets its own directory instead.
const startProxy = (): number => {
const { port, proxyProcess } = startUnixTestProxy(
fs.mkdtempSync(path.join(tmpDir, 'proxy-')),
);
proxies.push(proxyProcess);
return port;
};

after(() => {
for (const p of proxies) {
try { p.kill('SIGTERM'); } catch { /* ignore */ }
}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('should route traffic through the endpoint named by runtimeConfig.networkProxy', async () => {
const port = startProxy();

const config = sdk.createConfigFromPolicy(
{ version: PROXY_SCHEMA_09 },
'bubblewrap',
'bwrap-runtime-proxy-09',
);
config.process!.commandLine =
`curl -fsSL '${NETWORK_TEST_URL}' > /dev/null && echo PROXY_09_OK`;
config.network = {
egress: { default: 'deny' },
ingress: { default: 'deny', hostLoopback: 'deny' },
};
config.runtimeConfig = {
...(config.runtimeConfig ?? {}),
networkProxy: `http://127.0.0.1:${port}`,
};

// No allowTestingFeatures: that flag gates `builtinTestServer`, which has
// no 0.9 spelling. Needing it here would mean the gate had gone slack.
const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true });
assert.strictEqual(result.exitCode, 0, `0.9 proxy run failed: ${result.stdout}`);
assert.ok(result.stdout.includes('PROXY_09_OK'), `missing PROXY_09_OK in: ${result.stdout}`);
});

it('should confine egress to the proxy endpoint', async () => {
const port = startProxy();

const config = sdk.createConfigFromPolicy(
{ version: PROXY_SCHEMA_09 },
'bubblewrap',
'bwrap-runtime-proxy-09-egress',
);
// `--noproxy '*'` is the load-bearing part: it opts the request out of the
// proxy env vars, so a success would mean the sandbox reached the internet
// directly and the proxy-only posture was never enforced.
config.process!.commandLine =
'set -e; ' +
`if curl -fsS --noproxy '*' --max-time 10 '${NETWORK_TEST_URL}' > /dev/null 2>&1; then ` +
' echo DIRECT_09_LEAKED; exit 1; ' +
'else ' +
' echo DIRECT_09_BLOCKED_OK; ' +
'fi; ' +
`curl -fsSL '${NETWORK_TEST_URL}' > /dev/null && echo PROXY_09_STILL_OK`;
config.network = {
egress: { default: 'deny' },
ingress: { default: 'deny', hostLoopback: 'deny' },
};
config.runtimeConfig = {
...(config.runtimeConfig ?? {}),
networkProxy: `http://127.0.0.1:${port}`,
};

const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true });
assert.strictEqual(result.exitCode, 0, `0.9 egress run failed: ${result.stdout}`);
assert.ok(
result.stdout.includes('DIRECT_09_BLOCKED_OK'),
`direct egress was not blocked: ${result.stdout}`,
);
assert.ok(
result.stdout.includes('PROXY_09_STILL_OK'),
`the proxied request did not complete: ${result.stdout}`,
);
assert.ok(
!result.stdout.includes('DIRECT_09_LEAKED'),
`proxy-only egress leaked: ${result.stdout}`,
);
});
});

// The Rust serializer and the TypeScript parser are each unit-tested against
// fixtures, but a fixture cannot catch the two drifting apart. This pins the
// transport: the real `lxc-exec --available-backends` payload is fed to the
Expand Down
Loading
Loading