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
2 changes: 1 addition & 1 deletion docs/bwrap-support/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ request fails if its private namespace cannot be configured.
"commandLine": "curl -fsSL https://api.github.com/zen && echo OK"
},
"network": {
"defaultPolicy": "allow",
"defaultPolicy": "block",
"proxy": { "builtinTestServer": true },
"allowedHosts": ["api.github.com"]
}
Expand Down
22 changes: 10 additions & 12 deletions docs/nanvix-microvm/nanvix.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,18 +189,16 @@ The following describes the retained legacy runtime filter, not accepted v0.10
JSON vocabulary. The exact cutover does not silently translate directional
rules into this weaker contract.

`allowedHosts` and `blockedHosts` are supported and forwarded to the guest's
host-side socket proxy, which enforces egress at `connect()`. The guest filter
is **allow-XOR-block**, so the two lists are mutually exclusive (specifying both
is rejected at preflight). Presence of either list implies host networking, so
`defaultPolicy` is ignored when a list is set:

| `allowedHosts` | `blockedHosts` | Effect |
| -------------- | -------------- | ------ |
| _(empty)_ | _(empty)_ | follows `defaultPolicy` (`block` = no egress, `allow` = unrestricted) |
| `[A, ...]` | _(empty)_ | **allowlist** β€” only the listed destinations are reachable |
| _(empty)_ | `[B, ...]` | **blocklist** β€” everything except the listed destinations is reachable |
| `[A, ...]` | `[B, ...]` | rejected at preflight (mutually exclusive) |
Legacy `defaultPolicy` and host-list interactions follow the
[backend-agnostic network policy semantics](../schema.md#legacy-network-host-list-semantics).
Invalid legacy combinations are rejected by shared policy validation:
`blockedHosts` requires an `allowedHosts` exception set under a block default,
and `allowedHosts` cannot be used under an allow default.

NanVix forwards the validated host list to the guest's host-side socket proxy,
which enforces egress at `connect()`. The guest filter is **allow-XOR-block**,
so NanVix rejects requests that supply both lists instead of dropping either
one.

Entries may be IPv4 literals (`93.184.216.34`), IPv4 CIDR blocks
(`10.0.0.0/8`), or hostnames. Hostnames are resolved to their IPv4 (A-record)
Expand Down
6 changes: 3 additions & 3 deletions docs/sandbox-policy/0.7.0/policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,10 @@ All flags default to `false` (no network access).

| Field | Description |
|--------------------|-------------|
| `allowOutbound` | Allow outbound connections to the internet (HTTP, DNS, etc.). |
| `allowOutbound` | Allow outbound connections by default when no `allowedHosts` list narrows the policy. |
| `allowLocalNetwork`| Allow connections to local networks. |
| `allowedHosts` | When set, only these outbound hosts are reachable on host-filtering backends. Seatbelt accepts the field for compatibility but degrades to allow-all outbound because it cannot filter DNS names. |
| `blockedHosts` | Hosts to block even when outbound is allowed. Seatbelt rejects this field because hostname blocks cannot be enforced. |
| `allowedHosts` | Hosts or CIDRs allowed under a default-block policy. SDK builders select the block default whenever this list is non-empty; Windows ProcessContainer callers must also set `allowOutbound`. Seatbelt rejects the shared-valid combination unless `builtinTestServer` supplies the enforcement path. |
| `blockedHosts` | Hosts or CIDRs denied after allow rules are applied. Without `allowedHosts`, set `allowOutbound` to express allow-all-except-these. Seatbelt rejects this field because hostname blocks cannot be enforced. |
| `proxy` | `{ builtinTestServer: true }`, `{ localhost: <port> }`, or `{ url: "..." }`. Windows enforces the proxy through WinHTTP; Bubblewrap and Seatbelt inject cooperative proxy environment variables that raw-socket clients can bypass. Combination rules are backend-specific: Bubblewrap and Seatbelt both reject proxy plus `enforcementMode: "firewall"` / `"both"` (neither has a privileged packet-filter layer), and Bubblewrap external proxies cannot be combined with host lists or `defaultPolicy: "block"`. `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
21 changes: 21 additions & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,27 @@ schema 0.6 and 0.7. During the additive schema 0.8 transition, requests may
continue to use those legacy fields or use the directional fields above, but
cannot mix both formats in one request.

#### Legacy network host-list semantics

Legacy host lists refine `defaultPolicy`; they do not replace it. Shared
validation rejects a list that cannot refine the selected default before the
backend executes.

| `defaultPolicy` | `allowedHosts` | `blockedHosts` | Result |
| --- | --- | --- | --- |
| `block` | empty | empty | Valid: no egress |
| `block` | non-empty | empty | Valid: allow only listed destinations |
| `block` | empty | non-empty | Invalid: a blocklist cannot refine a block default without an allowlist |
| `block` | non-empty | non-empty | Valid shared policy: explicit blocks override allowed destinations; backends may reject if they cannot represent both lists |
| `allow` | empty | empty | Valid: unrestricted egress |
| `allow` | empty | non-empty | Valid: allow all except listed destinations |
| `allow` | non-empty | empty | Invalid: an allowlist cannot refine an allow default |
| `allow` | non-empty | non-empty | Invalid: `allowedHosts` cannot be used with an allow default |
Comment thread
huzaifa-d marked this conversation as resolved.

For the valid block-default combination containing both lists, explicit blocks
take precedence over allowed destinations. A backend that cannot represent both
lists must reject the combination rather than dropping either list.

### IsolationSession unrestricted networking (0.9)

IsolationSession cannot restrict networking. Exact v0.9 requests must describe
Expand Down
2 changes: 1 addition & 1 deletion docs/seatbelt/seatbelt-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ rejected.**
| `defaultPolicy: "allow"` | `egress.default: "allow"` | Identical profile output |
| `allowLocalNetwork: true` | `ingress.default: "allow"` | Identical profile output |
| `network.proxy.localhost` / loopback `network.proxy.url` | `runtimeConfig.networkProxy` | |
| `allowedHosts` | *(no equivalent)* | Under `"allow"`: accepted but **ignored** β€” outbound is already unrestricted, so the list narrows nothing. **Rejected** under `"block"` unless `builtinTestServer` |
| `allowedHosts` | *(no equivalent)* | With `"allow"`: rejected by shared legacy-network validation because an allowlist cannot narrow an allow default. With `"block"`: shared-valid, but Seatbelt rejects it unless `builtinTestServer` supplies the enforcement path |
| `blockedHosts` | *(no equivalent)* | **Rejected** always |
| *(no equivalent)* | `ingress.hostLoopback` | New in 0.8 β€” legacy configs never emit a host-loopback rule |

Expand Down
6 changes: 3 additions & 3 deletions sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ private void MarkLegacyField(string field)
_legacyFields.Add(field);
}

/// <summary>Allow outbound network access.</summary>
/// <summary>Allow outbound by default when no allowlist narrows the policy.</summary>
[JsonPropertyName("allowOutbound")]
public bool AllowOutbound
{
Expand All @@ -215,7 +215,7 @@ public bool AllowLocalNetwork
set => AuthoredAllowLocalNetwork = value;
}

/// <summary>Hosts explicitly allowed.</summary>
/// <summary>Destinations allowed under a default-block policy.</summary>
[JsonPropertyName("allowedHosts")]
public List<string> AllowedHosts
{
Expand All @@ -228,7 +228,7 @@ public List<string> AllowedHosts
}
}

/// <summary>Hosts explicitly blocked.</summary>
/// <summary>Destinations denied after allow rules are applied.</summary>
[JsonPropertyName("blockedHosts")]
public List<string> BlockedHosts
{
Expand Down
6 changes: 6 additions & 0 deletions sdk/dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ catch (MxcException ex)
}
```

For legacy `NetworkPolicy`, a non-empty `AllowedHosts` list selects a block
default even when `AllowOutbound` is true, so the allowlist narrows outbound
access. With no allowlist, `AllowOutbound = true` selects an allow default and
`BlockedHosts` expresses allow-all-except-these. A blocklist without either an
allowlist or `AllowOutbound` is rejected by the native SDK.

`MxcSandbox.RunAsync(policy, command)` offloads the blocking native call to the
thread pool. `MxcSandbox.NativeVersion` returns the loaded `mxc_ffi` version.
Optional feature outputs are returned through `RunResult.OutputMetadata`; for
Expand Down
7 changes: 7 additions & 0 deletions sdk/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ as directional default-deny for egress, ingress, and host loopback. See the
[Sandbox Policy 0.8.0 specification](https://github.com/microsoft/mxc/blob/main/docs/sandbox-policy/0.8.0/policy.md)
for the complete cross-platform authoring shape.

For legacy `SandboxPolicy` authoring, a non-empty `allowedHosts` list selects a
block default even when `allowOutbound` is true, so the list narrows outbound
access rather than forming the invalid `allow` + allowlist wire combination.
With no allowlist, `allowOutbound: true` selects an allow default and
`blockedHosts` expresses allow-all-except-these. A blocklist without either an
allowlist or `allowOutbound` is rejected.

Model 1 permits direct connections selected by IP/CIDR, protocol, and port
rules; it does not configure an application-layer proxy. Model 2 denies direct
internet access and supplies a loopback HTTP/S proxy endpoint. Backend-specific
Expand Down
22 changes: 16 additions & 6 deletions sdk/node/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,11 +539,18 @@ export function createConfigFromPolicy(
);
}
}
// Unix backends accept host lists without allowOutbound. Bubblewrap and
// LXC enforce them; WSLC does not (per-host filtering is non-functional β€”
// no in-kernel iptables + no CAP_NET_ADMIN β€” and is rejected at parse
// time); Seatbelt accepts them for SDK compatibility and leaves its
// limitations to native validation.
if (
policy.network.blockedHosts?.length &&
!policy.network.allowedHosts?.length &&
!policy.network.allowOutbound
) {
throw new Error(
"blockedHosts requires allowedHosts when network.defaultPolicy='block'",
);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unclear on why this is needed at SDK layer, but changes otherwise look good!

// Unix backends accept allowlists without allowOutbound. Bubblewrap and
// LXC enforce them under a block default; WSLC and Seatbelt leave their
// backend-specific limitations to native validation.
const acceptsHostRulesWithoutOutbound =
containment === 'wslc' ||
containment === 'seatbelt' ||
Expand All @@ -558,7 +565,10 @@ export function createConfigFromPolicy(
}

config.network = {
defaultPolicy: policy.network.allowOutbound ? 'allow' : 'block',
defaultPolicy:
policy.network.allowOutbound && !policy.network.allowedHosts?.length
? 'allow'
: 'block',
allowLocalNetwork: policy.network.allowLocalNetwork,
allowedHosts: policy.network.allowedHosts,
blockedHosts: policy.network.blockedHosts,
Expand Down
6 changes: 3 additions & 3 deletions sdk/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,13 +468,13 @@ export type SandboxPolicy = {
};
/** Network access restrictions. All flags default to false (no network access). */
network?: {
/** Whether to allow outbound connections to the Internet. (default: false) Legacy network field. */
/** Allow outbound by default when no allowlist narrows the policy. (default: false) Legacy network field. */
allowOutbound?: boolean;
/** Whether to allow connections to local networks. (default: false) Legacy network field. */
allowLocalNetwork?: boolean;
/** When set, ONLY these outbound hosts are reachable. Requires allowOutbound. Legacy network field. */
/** Destinations allowed under a default-block policy. Windows ProcessContainer also requires allowOutbound. Legacy network field. */
allowedHosts?: string[];
/** Hosts to block even when outbound is allowed. Requires allowOutbound. Legacy network field. */
/** Destinations denied after allow rules. Without allowedHosts, set allowOutbound so the default is allow. Legacy network field. */
blockedHosts?: string[];
/**
* Proxy configuration. Routes cooperating HTTP traffic through this proxy.
Expand Down
2 changes: 1 addition & 1 deletion sdk/node/tests/integration/linux-bubblewrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('Linux Bubblewrap network proxy (schema 0.6.0-alpha)', {
'fi';
config.network = {
...(config.network ?? {}),
defaultPolicy: 'allow',
defaultPolicy: 'block',
proxy: { builtinTestServer: true },
allowedHosts: ['pkgs.dev.azure.com'],
};
Expand Down
31 changes: 26 additions & 5 deletions sdk/node/tests/unit/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,7 @@ describe('createConfigFromPolicy', () => {
});
assert.ok(config.processContainer!.capabilities!.includes('internetClient'));
assert.ok(config.processContainer!.capabilities!.includes('privateNetworkClientServer'));
assert.strictEqual(config.network!.defaultPolicy, 'block');
assert.deepStrictEqual(config.network!.allowedHosts, ['example.com']);
assert.deepStrictEqual(config.network!.blockedHosts, ['evil.com']);
} finally {
Expand Down Expand Up @@ -1130,6 +1131,7 @@ describe('createConfigFromPolicy', () => {
// Abstract 'process' on Linux must apply the same iptables firewall
// enforcement as explicit 'bubblewrap', because the native binary
// resolves the abstract intent to Bubblewrap server-side.
assert.strictEqual(config.network!.defaultPolicy, 'block');
assert.strictEqual(config.network!.enforcementMode, 'firewall');
} finally {
restore();
Expand Down Expand Up @@ -1202,6 +1204,7 @@ describe('createConfigFromPolicy', () => {
'bubblewrap',
);
assert.strictEqual(config.containment, 'bubblewrap');
assert.strictEqual(config.network!.defaultPolicy, 'block');
assert.strictEqual(config.network!.enforcementMode, undefined);
assert.deepStrictEqual(config.network!.proxy, { builtinTestServer: true });
assert.deepStrictEqual(config.network!.allowedHosts, ['example.com']);
Expand Down Expand Up @@ -1499,7 +1502,7 @@ describe('createConfigFromPolicy', () => {
}
};

it('should pass allowedHosts through for Seatbelt compatibility', () => {
it('should preserve a shared-valid allowlist for native Seatbelt validation', () => {
mockDarwin();
try {
const config = createConfigFromPolicy({
Expand All @@ -1516,16 +1519,16 @@ describe('createConfigFromPolicy', () => {
}
});

it('should pass blockedHosts through for native Seatbelt validation', () => {
it('should pass blocklist-only outbound policy through for native Seatbelt validation', () => {
mockDarwin();
try {
const config = createConfigFromPolicy({
version: '0.7.0-alpha',
network: { blockedHosts: ['evil.com'] },
network: { allowOutbound: true, blockedHosts: ['evil.com'] },
});
assert.strictEqual(config.containment, 'seatbelt');
assert.deepStrictEqual(config.network!.blockedHosts, ['evil.com']);
assert.strictEqual(config.network!.defaultPolicy, 'block');
assert.strictEqual(config.network!.defaultPolicy, 'allow');
} finally {
restore();
}
Expand Down Expand Up @@ -1838,7 +1841,10 @@ describe('createConfigFromPolicy', () => {
version: '0.6.0-alpha',
network: { blockedHosts: ['evil.com'] },
}),
{ message: /allowedHosts\/blockedHosts require allowOutbound/ },
{
message:
"blockedHosts requires allowedHosts when network.defaultPolicy='block'",
},
);
} finally {
restore();
Expand Down Expand Up @@ -1973,6 +1979,7 @@ describe('createConfigFromPolicy', () => {
assert.deepStrictEqual(config.filesystem!.readwritePaths, ['/workspace']);
assert.deepStrictEqual(config.filesystem!.readonlyPaths, ['/data']);
assert.deepStrictEqual(config.filesystem!.deniedPaths, ['/secrets']);
assert.strictEqual(config.network!.defaultPolicy, 'block');
// Per applyLinuxNetworkPolicy, host filtering forces firewall mode.
assert.strictEqual(config.network!.enforcementMode, 'firewall');
});
Expand Down Expand Up @@ -2018,6 +2025,7 @@ describe('createConfigFromPolicy', () => {
network: { allowOutbound: true, allowedHosts: ['example.com'] },
}, 'lxc');
assert.strictEqual(config.containment, 'lxc');
assert.strictEqual(config.network!.defaultPolicy, 'block');
assert.strictEqual(config.network!.enforcementMode, 'firewall');
});

Expand All @@ -2031,6 +2039,19 @@ describe('createConfigFromPolicy', () => {
assert.strictEqual(config.network!.defaultPolicy, 'block');
assert.strictEqual(config.network!.enforcementMode, 'firewall');
});

it('should reject blockedHosts without an allowlist or outbound default', () => {
assert.throws(
() => createConfigFromPolicy({
version: '0.6.0-alpha',
network: { blockedHosts: ['evil.com'] },
}, 'lxc'),
{
message:
"blockedHosts requires allowedHosts when network.defaultPolicy='block'",
},
);
});
});
});

Expand Down
54 changes: 37 additions & 17 deletions src/backends/bubblewrap/common/src/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1984,23 +1984,45 @@ mod tests {

#[test]
fn validate_accepts_host_rules_when_a_proxy_enforces_them_at_0_8() {
// The proxy is the mechanism, so the same lists are fine with one.
// The proxy is the mechanism, so a valid allow-default blocklist
// reaches the environmental probe instead of failing policy validation.
let mut req = base_request();
req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict;
req.policy.default_network_policy = wxc_common::models::NetworkPolicy::Allow;
req.policy.blocked_hosts = vec!["evil.example.com".into()];
req.policy.network_proxy = ProxyConfig {
address: None,
builtin_test_server: true,
};

let unavailable = bwrap_version::BwrapUnavailable::NotFound;
let expected = unavailable.to_string();
let error = BubblewrapScriptRunner::new()
.validate_prepared_with_probe(&req, || Err(unavailable))
.unwrap_err();
assert_eq!(error.error_message, expected);
}

#[test]
fn validate_rejects_block_default_blocklist_without_allowlist() {
let mut req = base_request();
req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict;
req.policy.default_network_policy = wxc_common::models::NetworkPolicy::Block;
req.policy.blocked_hosts = vec!["evil.example.com".into()];
req.policy.network_proxy = ProxyConfig {
address: Some(ProxyAddress::new("127.0.0.1".into(), 3128)),
builtin_test_server: false,
};

if let Err(err) = BubblewrapScriptRunner::new().validate(&req) {
assert!(
!err.error_message
.contains("require an enforcement mechanism"),
"a proxy enforces the lists: {}",
err.error_message
);
}
let error = BubblewrapScriptRunner::new()
.validate_prepared_with_probe(&req, || {
panic!("environment probe must not run for an invalid legacy host list")
})
.unwrap_err();
assert_eq!(
error.error_message,
"blockedHosts requires allowedHosts when network.defaultPolicy='block'"
);
}

#[test]
Expand All @@ -2011,14 +2033,12 @@ mod tests {
req.policy.default_network_policy = wxc_common::models::NetworkPolicy::Block;
req.policy.allowed_hosts = vec!["api.github.com".into()];

if let Err(err) = BubblewrapScriptRunner::new().validate(&req) {
assert!(
!err.error_message
.contains("require an enforcement mechanism"),
"0.7 must not be rejected: {}",
err.error_message
);
}
let unavailable = bwrap_version::BwrapUnavailable::NotFound;
let expected = unavailable.to_string();
let error = BubblewrapScriptRunner::new()
.validate_prepared_with_probe(&req, || Err(unavailable))
.unwrap_err();
assert_eq!(error.error_message, expected);
}

#[test]
Expand Down
Loading
Loading