diff --git a/README.md b/README.md index 4180468bc..3d24b39e0 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,6 @@ Privacy information can be found at https://privacy.microsoft.com and in the Mic | [tests/scripts/README.md](tests/scripts/README.md) | Local and CI backend test suites | | [docs/host-prep.md](docs/host-prep.md) | Windows host preparation (`wxc-host-prep.exe`) | | [docs/diagnostics.md](docs/diagnostics.md) | Diagnostic logging and ETW | -| [docs/sandbox-policy/0.7.0/policy.md](docs/sandbox-policy/0.7.0/policy.md) | Sandbox policy 0.7.0 specification | | [docs/process-container/guide.md](docs/process-container/guide.md) | Windows AppContainer / BaseContainer guide | | [docs/lxc-support/lxc-backend.md](docs/lxc-support/lxc-backend.md) | LXC backend (Linux) | | [docs/bwrap-support/bubblewrap-backend.md](docs/bwrap-support/bubblewrap-backend.md) | Bubblewrap backend (Linux) | diff --git a/docs/authoring-a-new-feature.md b/docs/authoring-a-new-feature.md index 1e82d65a6..77451615c 100644 --- a/docs/authoring-a-new-feature.md +++ b/docs/authoring-a-new-feature.md @@ -18,8 +18,8 @@ Read these in order: -1. [Sandbox Policy spec](sandbox-policy/0.7.0/policy.md): what -Policy and ContainerConfig are, design principles. +1. [Configuration schema](schema.md): supported policy fields and their + default behavior. 2. [Versioning Design](versioning.md): how policy/schema/SDK versions relate and when to bump. @@ -101,8 +101,7 @@ If your feature touches SandboxPolicy, update If your feature adds policy or config fields, you will need to plumb them through `createConfigFromPolicy()` in `sdk/node/src/sandbox.ts`. See the -[worked example in the Sandbox Policy spec](sandbox-policy/0.7.0/policy.md#10-worked-example-ui-policy) -for a walkthrough. +[UI policy section](schema.md#ui-policy) for the supported request shape. --- diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 374305b23..93759cc3f 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -15,6 +15,11 @@ requiring root privileges or a container runtime. > old config does not migrate its policy. Legacy `defaultPolicy`, > `enforcementMode`, host lists, `allowLocalNetwork`, and `network.proxy` > are not accepted. See [schema migration](../schema.md). +> Directly constructed runtime requests with non-default retired fields are +> also rejected before sandbox provisioning. Bubblewrap also rejects +> directional requests it cannot enforce, such as `ingress.default: "allow"` +> or direct egress rules combined with a runtime proxy. An omitted `network` +> section takes the directional deny defaults. ## Prerequisites @@ -220,11 +225,6 @@ block's value, while bash (RHEL) fabricates a shorter `/usr/local/bin:/usr/bin` plus `TERM=dumb`. So neither reads back as empty from inside the workload, whatever MXC passed. -> **Historical (retired pre-v0.9 contracts):** the child received only -> `process.env`, with no default `PATH`. Those contracts are no longer -> registered: a request declaring one fails as an unsupported version before -> `inheritDefaultEnv` or any other process field is evaluated. - ### Filesystem Policy | Field | bwrap Mapping | Description | diff --git a/docs/ci-validation-infrastructure.md b/docs/ci-validation-infrastructure.md index d4d86f6c8..8855217d1 100644 --- a/docs/ci-validation-infrastructure.md +++ b/docs/ci-validation-infrastructure.md @@ -225,8 +225,8 @@ get fixed or wired. | Backend | Status | Notes | |---------|--------|-------| -| Process T1 | ✅ Good | Windows 24H2+ only. Runs the primitives suite, tier-gated to `base-container`. Includes the schema 0.8 directional networking phases (capability matrix, model-3 equivalence, explicit egress rules, host loopback, runtime proxy, reject surface) and the legacy 0.7 network lane. Remaining failures are genuine MXC bugs or harness limitations. | -| Process T3 | ✅ Good | Windows 23H2 only. Runs the primitives suite tier-gated to `appcontainer-dacl`, plus `T3-Workloads.ps1` (real programs — pwsh, git, node, python, cmd — on top of the T3 primitives). The 0.8 networking phases assert the documented *rejection* behavior here, since AppContainer cannot carry egress rules, proxy peer identity, or host-loopback configuration. | +| Process T1 | ✅ Good | Windows 24H2+ only. Runs the primitives suite, tier-gated to `base-container`. Includes supported directional networking phases (capability matrix, model-3 equivalence, explicit egress rules, host loopback, runtime proxy, reject surface). Remaining failures are genuine MXC bugs or harness limitations. | +| Process T3 | ✅ Good | Windows 23H2 only. Runs the primitives suite tier-gated to `appcontainer-dacl`, plus `T3-Workloads.ps1` (real programs — pwsh, git, node, python, cmd) on top of the T3 primitives. The directional networking phases assert the documented *rejection* behavior here, since AppContainer cannot carry egress rules, proxy peer identity, or host-loopback configuration. | | Bubblewrap | ✅ Good | | | LXC | ✅ Good | Some networking tests fail on distros other than Ubuntu 24.04; seems to be an issue with MXC. The in-process streaming handle is covered at PR time by `lxc-e2e.yml` instead, because this matrix runs prebuilt binaries. | | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | diff --git a/docs/examples.md b/docs/examples.md index 55fd6b562..b0388b8c5 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -100,10 +100,9 @@ separate ingress defaults: See [`tests/examples/30_network_0_8_directional.json`](../tests/examples/30_network_0_8_directional.json) -for the complete config and -[`sandbox-policy/0.8.0/networking/networking.md`](sandbox-policy/0.8.0/networking/networking.md) -for the historical GA design; use the -[supported schema guide](schema.md) for current backend authoring. +for the complete config and the +[supported schema guide](schema.md#directional-networking-supported-contracts) +for current backend authoring. ### Network Proxy @@ -146,10 +145,8 @@ their supported ingress policies differ. See their backend guides. Every supported contract (`0.9.0-alpha` or later) accepts the directional shape and rejects the retired `defaultPolicy`, host-list, and `network.proxy` -fields. This is the cross-backend schema (see -[`docs/sandbox-policy/0.8.0/networking/networking.md`](sandbox-policy/0.8.0/networking/networking.md) -for the full design and per-backend enforcement matrix), not a -backend-specific format: it's parsed the same way regardless of +fields. This is the [cross-backend schema](schema.md#directional-networking-supported-contracts), +not a backend-specific format: it's parsed the same way regardless of `containment`. Each backend independently declares which parts of it — if any — it actually enforces; a backend that hasn't declared support for a given field rejects a config that sets it. @@ -158,8 +155,7 @@ Note that `EGRESS_RULES` is what carries per-CIDR/port rules; a backend without it accepts only `egress.default`. On Seatbelt, `runtimeConfig.networkProxy` covers only loopback endpoints; there is no supported equivalent for a remote proxy URL or `builtinTestServer`. See -[`docs/sandbox-policy/0.8.0/networking/schema-updates.md`](sandbox-policy/0.8.0/networking/schema-updates.md) -for the full field mapping and [`tests/examples/31_mac_network_0_8.json`](../tests/examples/31_mac_network_0_8.json) +[`tests/examples/31_mac_network_0_8.json`](../tests/examples/31_mac_network_0_8.json) for a complete example: ```json diff --git a/docs/linux-wsl-roadmap-june-2026.md b/docs/linux-wsl-roadmap-june-2026.md index 3d9055bdf..1fc738cda 100644 --- a/docs/linux-wsl-roadmap-june-2026.md +++ b/docs/linux-wsl-roadmap-june-2026.md @@ -503,7 +503,7 @@ These items depend on the WSLC SDK team and are not unilaterally schedulable. > **Why network enforcement must be container-scoped (host vs. VM vs. container).** Network policy can be enforced at three layers: the Windows **host** (Windows Firewall), the WSL2 **VM**, or the **container** network namespace inside the VM. GA decision **D6 (per-sandbox scoping)** requires every sandbox's policy to be independent — concurrent WSLC containers must not affect each other's access — and names the container network namespace as WSLC's scoping identity. A machine-wide **host** firewall can't attribute traffic to one container vs. another, so it violates D6 (and per **D8**, host firewalls apply *on top of* enforcement, never *as* it). A **VM-wide** rule fails the same way when one utility VM hosts multiple containers — sandbox A's rules would bleed into sandbox B. Only the **container namespace** is inherently per-sandbox, which is why it's the required enforcement point. The catch: MXC can't install rules into that namespace today (`Privileged` doesn't grant `CAP_NET_ADMIN`, and the VM may lack iptables tooling). Hence SDK dep #1 — a VM-level API that applies rules **scoped to a specific container's namespace**: physically enforced at the VM boundary, logically attributed to one container. > -> **Contrast with Hyperlight/Nanvix, and the state-aware wrinkle.** Hyperlight (host-proxied sockets, per-instance) and Nanvix (per-guest egress filter) get D6 scoping for free because each sandbox *is* its own VM instance/process — no shared surface to bleed across. WSLC today is also effectively 1 sandbox : 1 VM (the one-shot flow creates a session, one container, then tears it down), but the highest-value WSLC optimization — **state-aware session reuse** (Misc #29), keeping a warm VM to amortize startup cost — makes one VM host **multiple** containers, at which point a host- or VM-wide rule genuinely bleeds across co-resident sandboxes. That is exactly when namespace-scoped enforcement (SDK dep #1) stops being merely cleaner and becomes mandatory. +> **Contrast with Hyperlight/Nanvix, and the state-aware wrinkle.** Hyperlight (network disabled for supported requests, per-instance) and Nanvix (all-deny or unrestricted networking, per-guest) keep their network posture scoped to each VM instance/process — no shared surface to bleed across. WSLC today is also effectively 1 sandbox : 1 VM (the one-shot flow creates a session, one container, then tears it down), but the highest-value WSLC optimization — **state-aware session reuse** (Misc #29), keeping a warm VM to amortize startup cost — makes one VM host **multiple** containers, at which point a host- or VM-wide rule genuinely bleeds across co-resident sandboxes. That is exactly when namespace-scoped enforcement (SDK dep #1) stops being merely cleaner and becomes mandatory. --- diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index fff330c41..8ad9520db 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -4,9 +4,10 @@ The LXC backend provides Linux container isolation using [LXC (Linux Containers)](https://linuxcontainers.org/lxc/). -For exact `0.9.0-alpha`, networking is directional-only: use `network.egress` -and `network.ingress`. LXC rejects `runtimeConfig.networkProxy`, so a v0.9 LXC -request has no proxy surface at all — see [Proxy](#proxy) below. +For every registered contract from `0.9.0-alpha` onward, networking is +directional-only: use `network.egress` and `network.ingress`. LXC rejects +`runtimeConfig.networkProxy`, so a supported LXC request has no proxy surface +at all — see [Proxy](#proxy) below. Legacy host lists and enforcement-mode fields in older examples are not accepted. Do not relabel an old request as v0.9 without migrating its policy. See [the schema migration reference](../schema.md). @@ -143,9 +144,6 @@ in `process.env` replaces it. > configuration, not just project input. Pass `"HOME=…"` to point elsewhere > when the workspace is untrusted. -Below 0.9 only `process.env` is passed through and `inheritDefaultEnv` is -rejected. - Shells like bash also have a fallback `PATH`, so a truly empty environment is not reachable through `process.env`. @@ -173,9 +171,15 @@ The legacy `network.proxy` fields also have no supported exact contract. Choose a backend that supports a loopback proxy if the workload needs one; relabeling a retired LXC proxy request as v0.9 cannot make its policy enforceable. +The retired host-list, enforcement-mode, local-network, and `network.proxy` +fields are no longer representable in typed Rust requests. LXC still rejects +the supported `runtimeConfig.networkProxy` field before creating a container. +Omitting `network.egress` or `network.ingress` means default deny for that +direction; retired fields never supply a fallback. + ### No network at all -A request that permits nothing and names no proxy keeps its own loopback and reaches nothing else. +A request that permits nothing keeps its own loopback and reaches nothing else. ## Usage @@ -247,7 +251,11 @@ LXC reads a run's network section only when the container starts. Omit `containerId` for a generated name. The claim is released when the handle drops. **Teardown is owed on every terminal path,** including a drop without `wait`: -the proxy pin and the network chains come down, and the container is released. +the network chains come down, and the container is released. A reused container +also has any stale proxy hosts-file pin from an earlier run cleared before its +new workload launches. MXC checks for marked lines inside the running +container using `lxc-attach` and removes them if present. A failed check +refuses to run the workload. A teardown failure is reported through `Sandbox::warnings`, which a bare drop leaves no handle to read. @@ -336,8 +344,8 @@ The zone query should answer the zone you assigned. and anything it starts run unfiltered in both directions for that interval. The requested command is attached afterwards. A connection opened during that window keeps working once the rules land, because the chains accept established flows. -- **A filtered container cannot renew a DHCP lease.** The chains permit loopback, - established flows, and DNS, with no carve-out for DHCP. A container that +- **A filtered container cannot renew a DHCP lease.** The chains permit loopback + and established flows, with no implicit carve-out for DHCP or DNS. A container that outlives its lease loses its address; one that finishes within the lease period is unaffected. - **A container that needs a network waits for an IPv4 address.** A dual-stack diff --git a/docs/nanvix-microvm/nanvix.md b/docs/nanvix-microvm/nanvix.md index 88e0b4def..0ea9ed61d 100644 --- a/docs/nanvix-microvm/nanvix.md +++ b/docs/nanvix-microvm/nanvix.md @@ -181,70 +181,15 @@ including egress allow with omitted ingress defaults. Disabled networking prevents guest socket creation (`OSError: [Errno 134]`). Unrestricted networking includes host-backed bind/listen capabilities; NanVix cannot independently enforce ingress or host-loopback restrictions. -Directional egress rules are explicitly rejected because the legacy IPv4 -filter does not implement their full semantics, including default-deny DNS. +Directional egress rules are explicitly rejected rather than translated to +the guest's IPv4 host filter, which cannot implement their full semantics, +including default-deny DNS. Runtime proxy configuration is also unsupported. -### Legacy per-host filter implementation (compatibility/reference only) - -The following describes the retained legacy runtime filter, not accepted v1.1 -JSON vocabulary. The exact cutover does not silently translate directional -rules into this weaker contract. - -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) -addresses at preflight; IPv6 (AAAA) results are dropped because the guest filter -is IPv4-only. Resolution failures are handled per direction so neither list ever -fails open: - -- **allowlist** (deny-by-default): each dropped entry is logged as a warning and - the run continues, since dropping an entry only *narrows* access. If the list - resolves to **no** IPv4 address at all, the run is rejected at preflight rather - than silently allowing all traffic. -- **blocklist** (allow-by-default): **any** entry that resolves to no IPv4 - address rejects the run at preflight. Silently dropping a blocked host would - let traffic the policy explicitly blocks flow freely, and the static preflight - filter cannot enforce a name that does not resolve — so the blocklist - fails closed. - -**DNS:** in allowlist mode the guest daemon automatically exempts the DNS port -(53), so name resolution works without adding the resolver to `allowedHosts`. - -Network proxies (`network.proxy`) are not supported and are rejected at -preflight. - -```jsonc -{ - "containment": "microvm", - "process": { "commandLine": "import urllib.request; ..." }, - // Historical legacy shape, not accepted by the exact v1.1 contract: - "network": { "defaultPolicy": "allow" } -} -``` - -```jsonc -{ - "containment": "microvm", - "process": { "commandLine": "import urllib.request; ..." }, - // Historical legacy shape, not accepted by the exact v1.1 contract: - "network": { "allowedHosts": ["example.com", "10.0.0.0/8"] } -} -``` - ## Not Supported -| Workload | Error | -| ------------------------------- | ----------------------------------- | -| Both `allowedHosts` + `blockedHosts` | Rejected at preflight (mutually exclusive) | -| File writing outside `/mnt/rw/` | `OSError: Read-only file system` | +| Workload | Error | +| -------------------------------------------- | -------------------------------- | +| Mixed directional networking or egress rules | Rejected before VM creation | +| Runtime proxy | Rejected before VM creation | +| File writing outside `/mnt/rw/` | `OSError: Read-only file system` | diff --git a/docs/process-container/examples/0.8.0-schema.md b/docs/process-container/examples/0.8.0-schema.md index 0321161e1..3759ba821 100644 --- a/docs/process-container/examples/0.8.0-schema.md +++ b/docs/process-container/examples/0.8.0-schema.md @@ -2,12 +2,12 @@ > **Audience:** MXC consumers and developers -> Historical design reference: schema 0.8 is retired. For supported requests, +> Schema 0.8 is retired. For supported requests, > use [current ProcessContainer networking guidance](../networking.md). -See the shared [0.7-to-0.8 network schema comparison](../../sandbox-policy/0.8.0/networking/schema-updates.md) -for egress, ingress, and proxy endpoint changes. This page covers the -ProcessContainer-specific proxy peer. +For the supported egress, ingress, and proxy endpoint fields, see +[directional networking](../../schema.md#directional-networking-supported-contracts). +The example below covers the ProcessContainer-specific proxy peer. ## Contained proxy diff --git a/docs/process-container/guide.md b/docs/process-container/guide.md index f5a4a6b1e..0d30f2964 100644 --- a/docs/process-container/guide.md +++ b/docs/process-container/guide.md @@ -5,9 +5,9 @@ This guide covers adding new OS-level features that flow through MXC's Windows process container pipeline. -Exact v0.9 networking uses directional egress/ingress, -`runtimeConfig.networkProxy`, and ProcessContainer proxy-peer identity. Legacy -networking fields remain available under their published contracts. Backend +Supported networking uses directional egress/ingress, +`runtimeConfig.networkProxy`, and ProcessContainer proxy-peer identity. Retired +networking fields are rejected by every registered contract. Backend selection depends on host capability and requested policy, not schema version. For which policy aspects this backend can enforce on each Windows 11 release, @@ -15,8 +15,8 @@ see [Windows OS-version policy support](./os-version-support.md). ## Prerequisites -1. Read the [Sandbox Policy spec](../sandbox-policy/0.7.0/policy.md) to - understand how `SandboxPolicy` maps to `ContainerConfig`. +1. Read the [supported configuration schema](../schema.md) for policy and + request fields. 2. Read [authoring-a-new-feature.md](../authoring-a-new-feature.md), especially Step 1 (feature spec) and Step 2 (OS changes). 3. Submit a feature spec so reviewers understand the end-to-end flow. diff --git a/docs/process-container/networking.md b/docs/process-container/networking.md index 5fa77cbbb..dbb11e17c 100644 --- a/docs/process-container/networking.md +++ b/docs/process-container/networking.md @@ -6,10 +6,9 @@ Supported exact contracts from `0.9.0-alpha` use the shared `network.egress` and `network.ingress` policy plus `runtimeConfig.networkProxy` and `processContainer.network.allowedProxyPeer` configuration. -Implementation companion to the parent -[MXC Network Configuration, GA design](../sandbox-policy/0.8.0/networking/networking.md) -doc. The parent owns the shared policy schema, connectivity models, and GA goal. -This doc covers only how the Windows ProcessContainer backend enforces them. +For the shared policy shape and defaults, see +[directional networking](../schema.md#directional-networking-supported-contracts). +This guide covers how the Windows ProcessContainer backend enforces them. ## 1. What this backend targets at GA @@ -206,7 +205,7 @@ middle rows provide different protections and are not ordered relative to each o For identity-scoped proxies, the scoped peer rule and `privateNetworkClientServer` do not bypass Windows Firewall's block-inbound-to-non-allowed-apps policy. A packaged AppContainer proxy uses the package-owned firewall -declaration shown in the [historical schema 0.8 manifest example](examples/0.8.0-schema.md); its application entry uses +declaration shown in the [proxy package manifest example](examples/0.8.0-schema.md); its application entry uses `uap10:RuntimeBehavior="packagedClassicApp"` with `uap10:TrustLevel="appContainer"`. An unpackaged AppContainer proxy requires its installer or administrator to own an equivalent rule scoped to the AppContainer profile SID, proxy executable, and configured port. @@ -280,7 +279,10 @@ proxy peer identity, and host-loopback allow fail with a typed unsupported-polic ## 3. WFP enforcement PSEC applies outbound WFP filters in the OS's elevated context and owns their lifetime. AppContainer fallback does not -install directional WFP filters. +install directional WFP filters. It uses capabilities for supported direction +defaults and keeps the external runtime proxy setup. Its network audit records retain the firewall fields with +`firewall_rules_created` and `firewall_rules_removed` at `0`, `firewall_applied` at `false`, and +`firewall_removal_ok` at `true`. WFP implements `egress` rules for public and private destinations. `internetClient` enables public-network access. `privateNetworkClientServer`, selected through `ingress.default`, is the prerequisite for private-network access and diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index da95948d1..a25b8d125 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -9,7 +9,7 @@ document are Windows 11**, and the minimum considered here is Windows 11 23H2. For the enforcement mechanisms themselves see the [UI policy schema](./UIPolicy_Schema.md) and the -[sandbox policy spec](../sandbox-policy/0.7.0/policy.md). +[supported configuration schema](../schema.md). ## Windows 11 releases @@ -125,7 +125,9 @@ Notes: ## Network policy -The release matrix describes the legacy schema 0.6/0.7 implementation. +The following release matrix documents the retired schema 0.6/0.7 +implementation, not support for executable requests. For current directional +policy and tier behavior, see [ProcessContainer networking](networking.md). | Aspect | 23H2 | 24H2 | 25H2 | 25H2+ | |--------|:--:|:--:|:--:|:--:| @@ -143,9 +145,10 @@ Notes: the forward-looking proxy architecture; support for the model-2 BaseContainer contract should replace this fallback in a separate change. -Schema 0.8 support is selected by runtime contract rather than Windows release: +Supported directional network policy (schema 0.9+) depends on the available +backend enforcement tier rather than the declared version: -| Schema 0.8 capability | PSEC | AppContainer fallback | +| Directional capability | PSEC | AppContainer fallback | |---|:---:|:---:| | Directional defaults represented by capabilities | ✅ | ✅ when the capability mapping preserves the request | | Explicit egress IP/CIDR/port/protocol rules | ✅ WFP | ❌ | @@ -155,7 +158,7 @@ Schema 0.8 support is selected by runtime contract rather than Windows release: PSEC owns the WFP policy lifetime through workload completion. The AppContainer fallback rejects `egress.default: "deny"` with `ingress.default: "allow"` because -`privateNetworkClientServer` is bidirectional and no schema 0.8 WFP filter is +`privateNetworkClientServer` is bidirectional and no per-container WFP filter is available there to block private-network egress. ## UI restrictions diff --git a/docs/reference/dotnet/v1/types.md b/docs/reference/dotnet/v1/types.md index 1c059743b..8bcd2cd18 100644 --- a/docs/reference/dotnet/v1/types.md +++ b/docs/reference/dotnet/v1/types.md @@ -785,7 +785,7 @@ public enum NetworkAction ## `Microsoft.Mxc.Sdk.V1.NetworkEgressPolicy` -Schema-0.8 outbound network policy. +Supported directional outbound network policy. ```csharp public sealed class NetworkEgressPolicy @@ -801,7 +801,7 @@ public sealed class NetworkEgressPolicy ## `Microsoft.Mxc.Sdk.V1.NetworkIngressPolicy` -Schema-0.8 inbound and host-loopback network policy. +Supported directional inbound and host-loopback network policy. ```csharp public sealed class NetworkIngressPolicy @@ -893,7 +893,7 @@ public sealed class NetworkRulePolicy ## `Microsoft.Mxc.Sdk.V1.NetworkRuntimeConfig` -Schema-0.8 runtime network values. +Supported runtime network values. ```csharp public sealed class NetworkRuntimeConfig @@ -1209,7 +1209,7 @@ Schema versions supported by this SDK release. ```csharp public static class SchemaVersions { - public const string Minimum = "0.6.0-alpha"; + public const string Minimum = "0.9.0-alpha"; public const string MaximumSupported = "1.1.0-alpha"; public const string LatestStable = "1.0.0"; } diff --git a/docs/sandbox-policy/0.7.0/policy.md b/docs/sandbox-policy/0.7.0/policy.md deleted file mode 100644 index dc7d8c36d..000000000 --- a/docs/sandbox-policy/0.7.0/policy.md +++ /dev/null @@ -1,579 +0,0 @@ -# MXC Sandbox Policy Spec v0.7.0 - -> **Audience:** MXC consumers - -> Historical design reference: pre-v0.9 contracts are no longer accepted. -> These examples describe the original policy design, not current authoring. -> See [the supported schema and migration guide](../../schema.md). - ---- - -## Table of Contents - -1. [Problem Statement](#1-problem-statement) -2. [Non-Goals](#2-non-goals) -3. [Design Principles](#3-design-principles) -4. [Architecture](#4-architecture) -5. [SandboxPolicy](#5-sandboxpolicy) -6. [ContainerConfig](#6-containerconfig) -7. [Containment Types](#7-containment-types) -8. [Versioning](#8-versioning) -9. [Development Guide](#9-development-guide) -10. [Worked Example: UI Policy](#10-worked-example-ui-policy) -11. [FAQ](#11-faq) - ---- - -## 1. Problem Statement - -- The SDK has no way to express isolation intent. Callers cannot request stronger containment - (microVM) when they need it. -- UI containment (clipboard, windows, input injection) has no cross-platform abstraction. - Existing drafts are Windows-specific. -- The boundary between user-facing Policy and backend-specific Config is undefined. - Contributors do not know where new features belong. -- New backends (Nanvix, WSLC, macOS) are planned. The current approach does not scale to - N backends x M policy surfaces. - ---- - -## 2. Non-Goals - -- **Enterprise policy injection:** IT admin restrictions (Group Policy, MDM) and merge strategy. -- **Runtime permission brokering:** Flatpak-style portal dialogs. MXC policies are upfront. -- **Multi-container orchestration:** Composing sandboxes or sandbox-to-sandbox communication. -- **Audit logging and telemetry:** Compliance logging is a separate spec. - ---- - -## 3. Design Principles - -### Principle 1: Intent, Not Mechanism - -Policy describes *what* the caller wants. ContainerConfig describes *how* the platform enforces it. - -A caller writes `network: { allowOutbound: true }`. They never write firewall rules, iptables -chains, or capability lists. Mechanisms belong in ContainerConfig and backend executors. - -### Principle 2: Default-Deny - -Omitted policy fields = most restrictive permissions. Adding a field opts *in* to a capability. - -```typescript -// Fully locked down: -spawnSandbox("script.sh", { version: "0.6.0-alpha" }); - -// Allow outbound network: -spawnSandbox("script.sh", { - version: "0.6.0-alpha", - network: { allowOutbound: true }, -}); -``` - -New fields added in future versions default to "denied" for existing policies that do not set -them. This is a security guarantee. - -### Principle 3: Cross-Platform Where Possible - -Policy fields work on two or more platforms. The enforcement mechanism differs per platform, but -the intent is the same. Fields that only apply to one backend live in ContainerConfig, not Policy. - -### Principle 4: Version Is a Contract - -> See [versioning.md](../../versioning.md) for the full versioning design. - -The Policy version and ContainerConfig schema version are locked in step. A version number -guarantees behavior. - ---- - -## 4. Architecture - -MXC has two user-facing concepts: - -1. **SandboxPolicy** (input): what the caller wants restricted. - Cross-platform security intent: filesystem, - network, UI, timeout. No OS-specific content. -2. **ContainerConfig** (output): the full configuration for one - specific backend. Returned by `createConfigFromPolicy()`. - Modifiable before spawning. Contains backend-specific sections. - -A **backend** is the containment technology that the executor -uses to create the sandbox (e.g., BaseProcessContainer on -Windows, LXC on Linux). - -### Flow - -``` -SandboxPolicy --> createConfigFromPolicy(policy, containment) --> ContainerConfig --> spawnSandboxFromConfig() --> executor --> OS -``` - -### Two API paths - -```typescript -// Simple: policy in, sandbox out. Always uses process containment. -spawnSandbox(script, policy); - -// Advanced: choose containment, get config, modify, then spawn. -const config = createConfigFromPolicy(policy, "process"); -config.processContainer!.ui!.isolation = "atoms"; // backend-specific tweak -spawnSandboxFromConfig(config); -``` - -`spawnSandbox` accepts a SandboxPolicy. For pre-built configs, use `spawnSandboxFromConfig`. - -### Layer diagram - -``` -┌───────────────────────────────────────────────────────────────┐ -│ LAYER 1: SDK + SandboxPolicy │ -│ │ -│ Users: GitHub CLI, Copilot, third-party agents │ -│ │ -│ SandboxPolicy: filesystem, network, ui, timeout │ -│ Simple: spawnSandbox(script, policy) │ -│ Advanced: createConfigFromPolicy(policy, "process") │ -│ → modify config │ -│ → spawnSandboxFromConfig(config) │ -└─────────────────────────────┬─────────────────────────────────┘ - │ ContainerConfig (JSON) - ▼ -┌───────────────────────────────────────────────────────────────┐ -│ LAYER 2: Executors (wxc-exec, lxc-exec) │ -│ │ -│ Parse ContainerConfig JSON, select backend runner │ -│ Backends: BaseProcessContainer, LXC, │ -│ microVM (Nanvix), WSLC │ -│ │ -│ Rust. Schema-validated. │ -└─────────────────────────────┬─────────────────────────────────┘ - │ OS API calls - ▼ -┌───────────────────────────────────────────────────────────────┐ -│ LAYER 3: OS Primitives │ -│ │ -│ Windows: BaseProcessContainer, BFS, Firewall, Job Objects │ -│ Linux: LXC cgroups, bind mounts, iptables, seccomp │ -│ │ -│ Kernel-level enforcement. │ -│ Never referenced by name in Layer 1. │ -└───────────────────────────────────────────────────────────────┘ -``` - -### Config granularity - -ContainerConfig translates policy into something the executors can act on. It is not a -general-purpose deployment manifest. - -Rules: - -- **Every Config field must be reachable** from policy or SDK defaults. If a - field has no path from user-facing input, it should not exist. -- **Keep Config minimal.** Only add fields that executors actually need. -- **SDK defaults to deny** for unfilled policy fields. - ---- - -## 5. SandboxPolicy - -SandboxPolicy is the user-facing input. It expresses security intent: what to allow, what to -deny. Cross-platform. No OS-specific content. - -```typescript -type SandboxPolicy = { - version: string; - filesystem?: { - readwritePaths?: string[]; - readonlyPaths?: string[]; - deniedPaths?: string[]; - tempDir?: "shared" | "isolated"; - }; - network?: { - allowOutbound?: boolean; - allowLocalNetwork?: boolean; - allowedHosts?: string[]; - blockedHosts?: string[]; - proxy?: { builtinTestServer: true } | { localhost: number } | { url: string }; - }; - ui?: { - allowWindows?: boolean; - clipboard?: "none" | "read" | "write" | "readwrite"; - allowInputInjection?: boolean; - }; - timeoutMs?: number; -}; -``` - -### `filesystem` - -| Field | Description | -|------------------|------------------------------------------------------------------------------| -| `readwritePaths` | Paths the sandbox can read and write. | -| `readonlyPaths` | Paths the sandbox can read but not write. | -| `deniedPaths` | Paths the sandbox cannot access at all. | -| `tempDir` | `"shared"`: host temp dir. `"isolated"`: private temp dir (`TEMP`/`TMPDIR`). | - -Omitted = no filesystem access beyond the default sandbox root. - -### `network` - -All flags default to `false` (no network access). - -| Field | Description | -|--------------------|-------------| -| `allowOutbound` | Allow outbound connections by default when no `allowedHosts` list narrows the policy. | -| `allowLocalNetwork`| Allow connections to local networks. | -| `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: }`, 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. - -### `ui` - -Cross-platform intent fields only. Backend-specific UI mechanisms live in ContainerConfig. - -| Field | Description | -|-----------------------|---------------------------------------------------------------| -| `allowWindows` | Whether the sandbox may create visible windows. Default: no. | -| `clipboard` | `"none"` (default), `"read"`, `"write"`, or `"readwrite"`. | -| `allowInputInjection` | Whether the sandbox may inject keyboard/mouse input. Default: no. | - -### `timeoutMs` - -Execution timeout in milliseconds. Omitted = SDK default (no timeout). - -### Default-deny - -An empty policy is fully locked down: - -```typescript -spawnSandbox("script.sh", { version: "0.6.0-alpha" }); -// No filesystem, no network, no UI, no input injection. -``` - ---- - -## 6. ContainerConfig - -`createConfigFromPolicy()` returns a ContainerConfig: the complete configuration for one -specific backend. Users receive it, may modify backend-specific fields, then pass it to -`spawnSandboxFromConfig()`. -Key rules: - -- **One backend per Config.** A Windows process config has an `processcontainer` section; no `lxc` - section. A Linux process config has an `lxc` section; no `processcontainer`. -- **User-modifiable.** Advanced users can set any Config field before spawning. -- **Schema-defined.** Schemas live in `schemas/`. The SDK TypeScript types mirror them. - -All configs share common sections (filesystem, network, ui). -Each backend adds its own sections on top: - -```typescript -type ContainerConfig = - | ProcessContainerConfig - | LxcContainerConfig - | MicroVmConfig; -``` - -### Example: ProcessContainerConfig - -```json -{ - "version": "0.6.0-alpha", - "containment": "process", - "process": { - "commandLine": "node agent.js", - "cwd": "C:\\workspace", - "env": ["NODE_ENV=production"], - "timeout": 30000 - }, - "lifecycle": { - "destroyOnExit": true, - "preservePolicy": false - }, - "filesystem": { - "readwritePaths": ["C:\\workspace"], - "readonlyPaths": ["C:\\tools"], - "deniedPaths": [] - }, - "network": { - "defaultPolicy": "outbound", - "enforcementMode": "firewall", - "allowedHosts": [], - "blockedHosts": [], - "proxy": null - }, - "processcontainer": { - "leastPrivilege": true, - "capabilities": [], - "ui": { - "isolation": "full", - "desktopSystemControl": false, - "systemSettings": "none", - "ime": false - } - }, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false - } -} -``` - -Backend-specific UI fields (`isolation`, `desktopSystemControl`, `systemSettings`, `ime`) -live inside `processcontainer.ui`, not at the top level. Top-level `ui` contains only the -cross-platform fields mapped from Policy. - -### Example: LxcContainerConfig - -```json -{ - "version": "0.6.0-alpha", - "containment": "lxc", - "process": { - "commandLine": "bash run.sh", - "cwd": "/workspace", - "env": [], - "timeout": 30000 - }, - "lifecycle": { - "destroyOnExit": true - }, - "filesystem": { - "readwritePaths": ["/workspace"], - "readonlyPaths": [], - "deniedPaths": [] - }, - "network": { - "defaultPolicy": "outbound", - "allowedHosts": [], - "blockedHosts": [] - }, - "lxc": { - "distribution": "alpine", - "release": "3.23" - } -} -``` - -No `processcontainer` section. No `ui` section. Backend-specific fields are scoped to the backend -that uses them. - ---- - -## 7. Containment Types - -The second parameter to `createConfigFromPolicy()` selects -the containment backend: - -```typescript -const config = createConfigFromPolicy(policy, "process"); -``` - -| Value | Windows backend | Linux backend | Status | -|--------------------|---------------------------|-----------------|-------------| -| `"process"` | BaseProcessContainer | LXC | Implemented | -| `"microvm"` | Nanvix | microVM | Future | -| `"agentSession"` | Agent session container | N/A | Future | - -Only `"process"` is end-to-end implemented today. - -`spawnSandbox(script, policy)` always defaults to `"process"`. - ---- - -## 8. Versioning - -> See [versioning.md](../../versioning.md) for full details. - -- Policy version = ContainerConfig schema version. Always bumped together. -- SDK version is independent. SDK v5.0 can use policy/schema v2.1. -- During alpha, expect breaking changes. - ---- - -## 9. Development Guide - -> See [authoring-a-new-feature.md](../../authoring-a-new-feature.md) for the full workflow and -> decision tree. -> -> See [process-container/guide.md](../../process-container/guide.md) for OS-level implementation -> details. - ---- - -## 10. Worked Example: UI Policy - -### 10.1 SDK user perspective - -**Simple path:** policy only, defaults to process containment: - -```typescript -const policy: SandboxPolicy = { - version: "0.6.0-alpha", - filesystem: { readwritePaths: ["C:\\workspace"] }, - network: {}, - ui: { - allowWindows: true, - clipboard: "read", - allowInputInjection: false, - }, - timeoutMs: 60000, -}; - -spawnSandbox("myapp.exe --flag1 arg", policy); -``` - -**Advanced path:** choose containment, get config, tweak: - -```typescript -const config = createConfigFromPolicy(policy, "process"); -config.processContainer!.ui!.isolation = "atoms"; -spawnSandboxFromConfig(config); -``` - -### 10.2 MXC developer perspective - -An MXC developer adding UI containment support would: - -**1. Add policy fields (if applicable)** (`sdk/node/src/types.ts`): - -If the feature is cross-platform security intent, add it to -`SandboxPolicy`. If the feature introduces a new containment -backend, add a new value to the `createConfigFromPolicy()` -containment parameter. UI is not a new backend, so we only -add policy fields here: - -```typescript -ui?: { - allowWindows?: boolean; - clipboard?: "none" | "read" | "write" | "readwrite"; - allowInputInjection?: boolean; -}; -``` - -**2. Add Config schema fields** (`schemas/dev/`): - -Top-level `ui` (maps from policy, all backends): - -```json -"ui": { - "disable": true, - "clipboard": "none", - "injection": false -} -``` - -Process container-specific UI fields (in this case Windows): - -```json -"processcontainer": { - "ui": { - "isolation": "full", - "desktopSystemControl": false, - "systemSettings": "none", - "ime": false - } -} -``` - -**3. Add Config TypeScript types** (`sdk/node/src/types.ts`): - -Top-level UI (all backends): - -```typescript -// Add to BaseContainerConfig: -ui: { - disable: boolean; - clipboard: string; - injection: boolean; -}; -``` - -Process container-specific UI (inside `processcontainer`): - -```typescript -// Add to ProcessContainerConfig.processcontainer: -ui: { - isolation: "desktop" | "handles" | "atoms" | "full"; - desktopSystemControl: boolean; - systemSettings: string; - ime: boolean; -}; -``` - -**4. Map policy to Config in SDK** (`sdk/node/src/sandbox.ts`): - -In `createConfigFromPolicy()`, map policy `ui` fields to -Config `ui` fields and fill backend-specific defaults: - -```typescript -// Top-level ui (from policy, all backends): -config.ui = { - disable: !policy.ui?.allowWindows, - clipboard: mapClipboard(policy.ui?.clipboard ?? 'none'), - injection: policy.ui?.allowInputInjection ?? false, -}; - -// Process container-specific ui (Windows only): -if (platform === 'win32' && config.containment === 'process') { - config.processcontainer.ui = { - isolation: 'full', - desktopSystemControl: false, - systemSettings: 'none', - ime: false, - }; -} -``` - -**5. Implement in executor** (`base_container_runner.rs`): - -Build the `ui_restrictions` bitmask from the `ui` section of -the ContainerConfig schema and translate it to OS-level Job -Object UI restriction flags. - -> See -> [authoring-a-new-feature.md](../../authoring-a-new-feature.md) -> for the full workflow. -> See -> [process-container/guide.md](../../process-container/guide.md) for -> the executor and OS implementation guide. - ---- - -## 11. FAQ - -### "Where does my new feature go: Policy or ContainerConfig?" - -Use the decision tree in -[authoring-a-new-feature.md](../../authoring-a-new-feature.md). In short: if it is a -cross-platform security intent that works on two or more platforms, it belongs in Policy. -Backend-specific mechanisms belong in ContainerConfig. - -### "What if my feature is Windows-only?" - -Put it in ContainerConfig under the appropriate backend section (e.g., `processcontainer` or `ui` -inside ProcessContainerConfig). Policy stays cross-platform. - -### "Can a developer bypass the SDK defaults?" - -Yes, via the advanced path. `createConfigFromPolicy()` returns secure defaults; the user -modifies any Config field before calling `spawnSandboxFromConfig()`. No bypass needed: Config is the -thing they modify. - -### "What happens if I omit all policy fields?" - -```typescript -spawnSandbox("script.sh", { version: "0.6.0-alpha" }); -``` - -Produces a fully locked-down config: no filesystem access, no network, UI disabled, no input -injection. Default-deny. - -### "Can I pass ContainerConfig JSON directly to an executor?" - -Yes. `wxc-exec config.json` accepts raw ContainerConfig. Useful for testing and internal -development. `createConfigFromPolicy()` is the recommended SDK path for production if needing control over -the configuration itself. diff --git a/docs/sandbox-policy/0.8.0/networking/networking.md b/docs/sandbox-policy/0.8.0/networking/networking.md deleted file mode 100644 index 8011989c0..000000000 --- a/docs/sandbox-policy/0.8.0/networking/networking.md +++ /dev/null @@ -1,583 +0,0 @@ -# MXC Network Configuration, GA - -> **Audience:** MXC consumers - -Schema 0.8.0 accepts the `network.egress` and `network.ingress` sections -described in this document. Schema 0.7 and earlier retain their legacy network -configuration shape. During the additive transition, schema 0.8 requests may -use either shape, but cannot mix them. - -This change defines and parses the schema 0.8 contract. A backend must also -declare validation support and implement enforcement before accepting a -directional policy; unsupported backends reject it in the stacked validation -work. - -## Overview - -The MXC network configuration describes what network access a sandboxed workload has. The schema is shared across all container types (process containers, WSLc, LXC, Bubblewrap, Seatbelt). Enforcement varies by backend and platform. - -This document covers the GA scope for the General Availability release. - -**GA Goal:** Reduce the network surface an AI agent can use to escape its sandbox or exfiltrate data. By default, all outbound traffic is dropped. The recommended path for GA is: localhost HTTP/S proxy for application traffic (API calls, package downloads). Direct outbound connections (raw sockets, SSH, custom TCP/UDP) are blocked by default and only allowed when explicitly permitted by IP/CIDR rules. This is a hard problem to solve across multiple platforms. GitHub Copilot expects sandboxes to behave consistently cross-platform, but each platform has different enforcement primitives. This document describes what MXC can enforce on each backend, where platform limitations exist. - -### GA Commitments: What Traffic Goes Where - -This document specifies the shared MXC networking schema and GA behavior for backends that support network configuration. Backend-specific support, enforcement differences, and unsupported modes are described in the GA Scope by Backend section. - -#### Connectivity models - -MXC defines three outbound connectivity models, listed in increasing order of -network restriction. Model 1 applies the configured L3/L4 filtering -(IP/CIDR/port/protocol allow/block rules). Direct egress allow and deny rules -do not apply when runtime proxy configuration selects model 2. - -- **Direct internet + L3/L4 filtering, no proxy (least restrictive).** The sandbox reaches the internet directly over HTTP(S) (and other protocols), subject only to IP/CIDR/port/protocol allow/block rules. No proxy is configured, so there is no application-layer (domain/URL/content) inspection. -- **No direct internet + loopback HTTP(S) proxy (more restrictive).** The container has no direct internet path; the - proxy is its only internet egress. Cooperating clients route HTTP(S) to the proxy, where the consumer can inspect - and filter it. A client that ignores the proxy and tries to reach the internet directly is dropped. Backend-specific - private-network behavior is described below. The single endpoint named by `runtimeConfig.networkProxy` is a defined, - narrowly scoped exception to the container-to-host closure. It does not grant general host-loopback access. -- **No direct internet + no inbound (most restrictive):** This is the most - restrictive model. External network traffic is dropped; backend-local - intra-sandbox IPC is described separately below. - -There was a fourth model that was looked at Direct internet + L3/L4 filtering + loopback HTTP(S) proxy. However, unlike model 2 which only allows traffic through a specific loopback port, direct internet access greatly decreases the ways to control egress and increases the opportunities for agent bypass. It is not a model we will have for GA. - -**GA goal:** model 2 (recommended to MXC consumers) on every backend. Every backend blocks direct internet access and -configures cooperating HTTP(S) clients to use the proxy. Backend-specific private-network behavior is described below. - -This loopback-only-plus-proxy-routing pattern is a well-established way to -confine sandboxed agent egress on macOS and Linux. The GA target enforces a -strict localhost-only egress restriction. See GA Scope by Backend for -per-backend details and compatibility limitations. - -Throughout this document, the deny-all-except-proxy posture (the GA goal) refers to model 2. - -#### Outbound Traffic Routing - -**Default stance:** outbound is blocked by default. The paths below describe how allowed traffic flows. - -**Proxy path (recommended for application traffic):** - -- **Protocol:** HTTP and HTTPS only -- **Destination:** Localhost proxy only (e.g., 127.0.0.1, ::1) -- **Ports:** Any port within the 1 – 65535 range. -- **Routing mechanism:** This applies to HTTP(S) only, never to other protocols. The sandbox's outbound is restricted so - the only reachable destination is the loopback proxy port. Cooperating clients are pointed at the proxy through - `HTTP_PROXY`/`HTTPS_PROXY` on Linux and macOS. Windows uses both per-AppContainer WinHTTP configuration and proxy - environment variables for non-WinHTTP clients. A client that ignores the proxy cannot reach the internet directly - on an enforcing model-2 path because the egress restriction drops everything except the localhost proxy port. In - model 1, such a client may instead egress directly, subject to the IP/CIDR/port/protocol rules. In model 2, access to - the configured proxy endpoint is the sanctioned egress path and is allowed independently of - `ingress.hostLoopback`; no other host-loopback endpoint is opened by the proxy configuration. -- **What is NOT routed:** Non-HTTP traffic (raw TCP/UDP sockets, SSH, custom protocols, QUIC, WebRTC, etc.) is never - redirected to the proxy. In model 2, direct internet traffic is blocked. On ProcessContainer, private-network - traffic follows `ingress.default` because AppContainer exposes one bidirectional private-network capability. In - model 1, non-HTTP traffic is subject to the IP/CIDR/port/protocol rules. Transparently routing this traffic through - the proxy is a gap that requires further design and is out of scope for GA. - -**Direct outbound path (model 1 only):** - -- **When allowed:** Only when explicitly allowed by IP/CIDR + port + protocol rules in `egress.allow`. In model 2 there is no direct egress path, so these connections are not possible regardless of any allow rules. -- **Use case:** e.g. SSH to a specific dev server, a direct TCP connection to a database, UDP to a specific endpoint, ICMP for diagnostics. -- **Caveat (coarse filtering):** Rules match IP/CIDR + port + protocol, not the application protocol. A port number does not identify a service (DNS need not use 53; a database may listen on any port), so allowing or denying a port is a blunt control rather than service-level filtering. -- **Enforcement:** WFP filters (Windows process containers), network namespace + iptables (WSLc/LXC/Bubblewrap). Model 1 for macOS is not supported for GA. Seatbelt cannot filter arbitrary destinations and macOS packet filtering is not fine-grained enough for per-sandbox scenarios out of the box. On Bubblewrap, model 1 egress is enforced from schema 0.8+ inside the sandbox's own network namespace — see "LXC and Bubblewrap: GA enforcement" below for what is and is not covered there. - -**Example:** - -| Consumer scenario | Which model they should use | -|---|---| -| HTTP/HTTPS API calls → localhost proxy (e.g., port 8080) | model 2 | -| SSH to 192.0.2.10:22 → explicit allow rule required | model 1 | -| DNS to 8.8.8.8:53 → explicit allow rule required (or blocked if resolver IP not allowed) | model 1 | -| Raw TCP to 140.82.112.0/20:443 → explicit allow rule required | model 1 | - -#### Host Loopback and Inbound Policy - -**Default stance:** Host-loopback and LAN/private-network inbound traffic are -blocked by default on backends that enforce the GA network policy. - -On backends with private loopback, this does not affect intra-container -loopback. Processes inside the same sandbox may communicate over localhost / -127.0.0.1 / ::1. Seatbelt has the exception described below. - -Ingress has two allow/deny controls and no rule arrays: - -- `ingress.default` controls LAN/private-network inbound traffic where the - backend supports it. -- `ingress.hostLoopback` controls host-loopback connectivity in both directions: container-to-host and - host-to-container, except for the single outbound proxy endpoint named by `runtimeConfig.networkProxy`. - -Both controls default to `deny` when omitted. `hostLoopback` resolves -independently of `ingress.default` and never inherits it: omitting -`hostLoopback` denies host-loopback connectivity even under -`ingress.default: allow`. Host-loopback access must therefore be requested -explicitly. - -An explicit `hostLoopback` value is independent of `default` for the -host-loopback path. For example, `default: deny` with `hostLoopback: allow` -permits bidirectional host-loopback connectivity while denying other inbound -traffic. A backend that cannot enforce both directions must reject -`hostLoopback: allow` rather than accept it with partial enforcement. - -When `runtimeConfig.networkProxy` is present, `hostLoopback: deny` remains valid -and blocks every host-loopback path other than outbound connections to that -exact proxy endpoint. This exception is part of model 2, not a general grant of -host-loopback access. An enforcing model-2 implementation must preserve that -meaning or reject the configuration. A temporary compatibility path may require -broader host-loopback access only when its backend section explicitly documents -the weaker posture; such a path does not provide the strict model-2 guarantee. - -**Scope:** - -- Intra-container loopback: allowed on backends with private loopback; - Seatbelt cannot separate it from host loopback -- Container-originated private-network traffic: controlled by `egress` on backends that cleanly separate - private-network ingress from egress; backend-specific limitations are documented below -- Container-to-host-loopback and host-loopback-to-container traffic: controlled by `ingress.hostLoopback`, except for - outbound access to the exact model-2 proxy endpoint -- LAN/private-network inbound: controlled by `ingress.default`, where supported -- WAN inbound: not enabled by the GA policy - -**Use cases for `ingress.hostLoopback: allow`:** - -- Caller-provided services other than the configured network proxy, listening on host loopback and accessed from the - container -- MCP servers in SSE/WebSocket mode (server listens on a port for client connections from host) -- Language server daemons (e.g., TypeScript language server) accessed from host IDE -- Local dev servers (e.g., npm run dev on port 3000) accessed from host browser - -#### Inter-Container Networking Policy - -**GA stance:** Only Windows process containers support this today. - -- **Windows process containers:** Two AppContainers can communicate over host loopback only if AppContainer loopback-exemption rules are installed for the pair. These rules are directional, so both directions must be granted. This allows pointed container to container communication over the loopback. MXC will allow this for GA. -- **WSLc / LXC / Bubblewrap:** Each sandbox has its own network namespace, so 127.0.0.1 is private to the sandbox and separate sandboxes cannot reach each other over loopback. Inter-container communication requires explicit virtual networking (veth/bridge/routing, a shared namespace, or a brokered host proxy/IPC path). LXC has mature primitives for this; Bubblewrap would require MXC to build the networking around it. This is out of scope for GA for all 3. -- **macOS (Seatbelt):** Seatbelt does not create a network namespace, so two Seatbelt-sandboxed processes share the host loopback and can communicate over 127.0.0.1 (or Unix sockets/XPC) if both profiles allow it. This is host-level IPC, not isolated container-to-container networking, and is not a GA commitment. - -#### Intra-Container Networking Policy - -Backends with private loopback allow loopback traffic within the same sandbox. -This enables multi-process applications, local IPC over localhost sockets, and -sandbox-local helper processes. Seatbelt shares the host network stack, so -`ingress.hostLoopback: deny` also prevents intra-sandbox TCP loopback; use -another permitted IPC mechanism when that posture is required. - -On backends with private loopback, traffic between processes in the same -sandbox is not governed by outbound IP/CIDR rules or host-to-container inbound -policy. - -## Schema - -### Connectivity model 1 and 3 - -Direct internet, no proxy (least restrictive). OR no egress at all: default deny with no allow rules (most restrictive) - -```json -{ - "network": { // Network policy - "egress": { - "default": "deny", - "allow": [ - { - "to": [ { "cidr": "140.82.112.0/20" } ], - "ports": [ { "protocol": "tcp", "port": 443 } ] - } - ], - "deny": [] - }, - "ingress": { - "default": "deny", - "hostLoopback": "deny" - } - } -} -``` - -### Connectivity model 2 - -No direct internet, loopback proxy only (more restrictive). - -```json -{ - "network": { - "egress": { "default": "deny" }, - "ingress": { "default": "deny", "hostLoopback": "deny" } - }, - "runtimeConfig": { // runtime data passed to MXC (not policy) - // http(s)://localhost:, http(s)://127.0.0.1:, or http(s)://[::1]: - "networkProxy": "http://127.0.0.1:8080" - } -} -``` - -This backend-neutral example expresses the portable model-2 policy intent: -general host-loopback and private-network access remain denied, while -`runtimeConfig.networkProxy` designates the exact outbound proxy endpoint for -the model-2 exception. Cooperating HTTP(S) clients are configured to use the -proxy; clients that ignore the proxy settings are blocked from direct egress. -Without runtime proxy configuration, the deny defaults form model 3. Backends -may use different mechanisms to make the proxy reachable. An enforcing path -must preserve this meaning; a backend that cannot do so rejects the -configuration unless its section below explicitly identifies a temporary, -weaker compatibility posture. The example expresses the shared contract; the -GA Scope by Backend section states whether each backend accepts this exact JSON -or requires a documented backend-specific configuration. - -This schema follows container-ecosystem conventions (CIDR peers, egress/ingress, to/ports), modeled loosely on -Kubernetes NetworkPolicy (the CNCF standard layered on CNI/OCI) rather than on platform firewall primitives. MXC keeps -an explicit deny list and a per-direction default, which are a deliberate extension over pure Kubernetes NetworkPolicy -(allow-only with `ipBlock.except`) to give an auditable default and block-precedence. - -Egress peer and port fields (used in `egress.allow[]` / `egress.deny[]`; not shown in the minimal example above): - -| Field | Type | Notes | -|---|---|---| -| `to[].cidr` | IPv4 / IPv6 CIDR, or 0.0.0.0/0 / ::/0 for any | Single CIDR string (CNI/Kubernetes style), replacing separate address + prefix length. | -| `to[].except` | list of CIDRs, optional | Exclusions within the peer's CIDR (Kubernetes `ipBlock.except` style). An exclusion narrows the rule that carries it and nothing else: it never states a verdict of its own, so an address it removes is decided by the remaining rules and the direction default. Windows process containers pass the exclusion to the platform (WFP); the Linux backends subtract it from the peer and program the covering blocks that remain, because an `iptables` rule cannot carry an exclusion and a separate rule would leak into later ones. The Linux backends bound the resulting expansion; the GA Scope by Backend section states the limits. Not supported on Seatbelt (no destination filtering). | -| `ports[].protocol` | tcp / udp / icmp / any | `any` matches at minimum TCP, UDP, and ICMPv4/6; a backend may match more. Enforced on Windows process containers (WFP) and the Linux backends (iptables); not supported on Seatbelt. | -| `ports[].port` | uint16, optional | Destination port. Omit `ports` to match all ports/protocols. | -| `ports[].endPort` | uint16, optional | End of a port range (Kubernetes `endPort` style); requires numeric port. Supported on Windows process containers (WFP) and the Linux backends (iptables); not supported on Seatbelt. | - -Omit `to` or `ports` to select their wildcard behavior. When either field is -present, its array must contain at least one selector; an explicit empty array -is rejected rather than broadened into a wildcard. - -`icmp` expands by destination address family. A rule containing IPv4 and IPv6 peers produces both ICMPv4 and ICMPv6 -filters; a rule without `to` also produces both. - -`any` is a floor, not an exhaustive protocol list. Every backend that enforces -port rules matches at minimum TCP, UDP, and ICMPv4/6; some match considerably -more — Windows process containers cover a much wider set of IP protocols. The -exact set is a backend property, so consult the backend's implementation doc -when a policy depends on a protocol outside that floor. Pairing `any` with a -`port` narrows the match to the protocols that carry port numbers, since a -port-scoped filter cannot name a portless protocol such as ICMP. - -Ingress has no CIDR peers or port rules. `ingress.default` and -`ingress.hostLoopback` are the complete GA ingress surface. - -## Design decisions - -### D1: Default-deny outbound - -**Decision:** Unlisted destinations are unreachable. A configuration that mentions nothing grants nothing. - -**Why:** This ensures the configuration explicitly describes the sandbox's network permissions (auditable on enforcing backends). Forgotten rules fail closed (safer). The same configuration means the same thing on different hosts and container types (portable intent, though enforcement fidelity varies by platform). - -**Limitation:** Enforcement requires an egress restriction at the OS level: -WFP on Windows process containers, a network namespace plus iptables on the -Linux backends, and a Seatbelt profile confining network-outbound to the -loopback proxy port on macOS. Rich IP/CIDR/port allow-lists are expressible on -Windows and the Linux backends but not on macOS, where Seatbelt restricts -egress to the proxy port rather than filtering arbitrary destinations. -Backend-specific docs identify temporary compatibility behaviors whose -enforcement is intentionally coarser. Outside those documented exceptions, a -backend rejects configurations it cannot enforce. - -### D2: Inbound and host-loopback connectivity are blocked by default - -**Decision:** GA defines outbound configuration and inbound control. -`ingress.default: deny` blocks LAN/private-network inbound traffic, and -`ingress.hostLoopback: deny` separately blocks host-loopback connectivity in both directions, except for the exact -outbound proxy endpoint when `runtimeConfig.networkProxy` selects model 2. The host-loopback value overrides `default` -for all other host-loopback traffic. -Intra-container loopback is allowed on backends with private loopback. -Seatbelt has the caveat described below. - -**Why inbound and host-loopback connectivity are blocked by default:** - -- **Attack surface:** Host-loopback access can expose host services to contained code and container listeners to the - host. For agentic workloads, either direction can create command-and-control, exfiltration, or lateral-movement paths. -- **Opt-in model:** Customers must explicitly set `ingress.hostLoopback: allow` when either direction is required beyond - the configured model-2 proxy endpoint. -- **GA enforcement:** Windows process containers use loopback exemption rules - scoped to the AppContainer SID. WSLc/LXC/Bubblewrap require paired routing - and filtering for both directions across their private network namespaces. - Seatbelt maps `ingress.default` to its existing - `(allow network-inbound (local ip))` behavior and enforces `hostLoopback` on - the container-to-host direction with a `localhost`-scoped `network-outbound` - rule. The inbound half is not expressible in a Seatbelt profile, so Seatbelt - refuses `hostLoopback: "allow"` under `ingress.default: "deny"` — an inbound - promise no rule can carry. It accepts `hostLoopback: "deny"` under - `ingress.default: "allow"`, enforcing the container-to-host half while the - blanket inbound grant over-permits the host-to-container half; that pair is - the only way Seatbelt can offer a listener without surrendering the - container-to-host direction, which is what a model-2 proxy depends on. That rule is scoped to the - host, not to loopback: SBPL's `localhost` means *this machine*, so it covers - every address bound to the host (loopback, LAN, and any public address) in - both the `deny` and `allow` cases. Other machines are unaffected — they - remain governed by `egress.default` — and Seatbelt offers no narrower - primitive, since a literal `127.0.0.1` is a profile syntax error. - -**Seatbelt caveat:** On Seatbelt there is no private loopback, so `ingress.hostLoopback: deny` also blocks -intra-sandbox TCP loopback, breaking loopback servers used by processes in the same sandbox. For intra-sandbox IPC on -macOS, Unix-domain sockets in a sandbox-private path rather than TCP loopback could be used. That said, Unix-domain -sockets come with their own security questions and should be outlined in a separate macOS doc if necessary. - -**Elevation caveat:** Installing these filters (WFP on Windows, iptables on the Linux backends) generally requires elevation. Elevating on every sandbox launch is out of the question, so MXC applies them through a privileged broker/service rather than from the unelevated launch path. A per-platform, per-technology elevation story must be defined in a separate MXC elevation design doc and is a prerequisite for this enforcement. - -> **Bubblewrap does not need this.** The caveat above holds only for filters installed on the *host*. Bubblewrap gives the sandbox its own network namespace and programs iptables inside it from a supervisor holding `CAP_NET_ADMIN` in an unprivileged user namespace — namespace-scoped capabilities are not host privilege, so the rules install with no elevation and no broker. The sandbox still cannot alter them, because it drops `CAP_NET_ADMIN` before the workload runs. Shipped at schema 0.8; see `docs/bwrap-support/bubblewrap-backend.md`. The elevation design remains a prerequisite for the host-filter backends only. - -### D3: IP literals and CIDRs only (no DNS names) - -**Decision:** Rule addresses must be IPv4/IPv6 literals or CIDRs. DNS names are rejected at validation time. The backend does not resolve names on behalf of callers. - -**Why:** Names are bypassable and non-deterministic. A container that resolves DNS itself can map a blocked name to an IP and connect directly. These results can change between config-time and runtime (TOCTOU) and vary by resolver/TTL/cache. IP/CIDR literals are deterministic and auditable. - -**GA DNS behavior:** GA does not implement domain-based DNS policy. DNS is not a first-class policy surface. If firewall rules block the DNS resolver IP, DNS queries fail. If rules allow the resolver IP, the sandbox can resolve any domain. - -**Secure domain allow-listing:** For HTTP(S) routed to the proxy, the proxy can inspect the domain on each CONNECT request in the HTTP header, and then choose to allow or deny by domain. The proxy resolves the hostname on the sandbox's behalf and enforces the domain allow-list before connecting. DNS resolution security at this point is on the proxy and not MXC. - -### D4: Explicit deny takes precedence over explicit allow - -**Decision:** When a connection matches both an `egress.allow` rule and an `egress.deny` rule, the deny wins. With `egress.default: "deny"`, no matching allow means no outbound access. With `egress.default: "allow"`, no matching deny means unrestricted outbound. - -**Why:** Fail-closed security posture. Deny rules act as overrides for broader allow rules (e.g., allow 0.0.0.0/0 but deny specific malicious IPs). - -### D5: Proxy is HTTP/S via platform-native APIs; localhost only for GA - -**Decision:** For GA, proxy routing covers HTTP and HTTPS traffic routed through the platform's native proxy surface. -The proxy must be on localhost (same-machine loopback) for GA. Remote proxies are out of scope for GA. When configured, -the exact endpoint in `runtimeConfig.networkProxy` is the sole sanctioned exception to container-to-host closure; -`ingress.hostLoopback: deny` blocks all other host-loopback access. - -**Platform-specific enforcement:** - -| Platform | Enforcement Mechanism | -|---|---| -| Windows (Process Containers) | PSEC BaseContainer path: per-AppContainer WinHTTP proxy configuration and scoped proxy-only access. AppContainer fallback rejects schema 0.8 runtime proxy. | -| WSLc | VM-level network policy permits only the translated proxy endpoint. Proxy variables are routing hints. | -| Linux (LXC, Bubblewrap) | iptables permits only the proxy endpoint; proxy variables are routing hints. | -| macOS (Seatbelt) | Seatbelt profile confines network-outbound to the loopback proxy port. MXC-set `HTTP_PROXY`/`HTTPS_PROXY` env variables are an advisory routing hint; a client that ignores the variables is denied by the profile (only the proxy port is reachable), so it is dropped, not bypassed. | - -**Backend implementation rule:** A backend implementing model 2: - -1. validates that `runtimeConfig.networkProxy` names an HTTP/S loopback endpoint with an explicit port; -2. makes only that outbound endpoint reachable, or only its backend-specific translation when the sandbox has a - private network namespace; -3. applies `ingress.hostLoopback` to every other container-to-host and host-to-container loopback path; and -4. rejects the configuration if it cannot preserve those semantics. - -A backend-specific compatibility path may instead require a broader ingress -setting only when its section documents both the required configuration and the -resulting loss of isolation. Capability gates such as ProcessContainer's -`ingress.default: "allow"` do not turn the proxy exception into general -host-loopback permission. - -**Why localhost only:** Remote proxies introduce trust boundary issues (proxy on different machine = different security context). Localhost proxy simplifies GA implementation and ensures proxy is under the same administrative control as the sandbox. - -**Proxy environment-variable hygiene (all backends):** The sandbox starts with all HTTP(S)-related proxy environment variables cleared to empty (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `FTP_PROXY`, `NO_PROXY`, and their lowercase variants). The sandbox never inherits host or stale proxy settings. MXC sets these variables explicitly, and only to the configured loopback proxy, when a proxy is in use (model 2); in model 1 they remain empty. - -**Consumer-provided proxy (all backends):** MXC does not provide, launch, or manage the proxy, and knows nothing about it beyond the host:port in the configuration. The consumer must start their proxy listening before launching the workload. MXC restricts the sandbox's egress to that endpoint and points the proxy variables at it. Making the proxy reachable from inside the sandbox is MXC's responsibility and is backend-specific. - -What is and is not routed through the proxy is described under Outbound Traffic Routing. - -### D6: Per-sandbox scoping - -**Decision:** Every configuration is scoped to one sandbox instance. Two concurrent sandboxes from different clients (e.g., gh-copilot and vscode) have independent configurations. One sandbox's configuration cannot affect another's network access. - -**Why:** Isolation between sandboxes prevents cross-sandbox interference and ensures configuration changes in one sandbox do not weaken another. - -**Implementation:** The sandbox identity used for scoping is backend-specific (AppContainer SID on Windows process containers, network namespace on WSLc/LXC, Seatbelt profile). - -### D7: Schema is container-type-agnostic; enforcement is backend-specific - -**Decision:** The network schema block is shared across all container types. The same JSON configuration means the same thing whether the backend is a Windows process container, a WSLc container, or Seatbelt on macOS. - -**Why:** Portable intent across platforms. Customers write one configuration that expresses their security policy; MXC maps it to backend-specific enforcement. - -**Reality:** Enforcement fidelity varies. A capability available on one backend (e.g., per-AppContainer WFP filters) may not have an equivalent on another. Cooperation-dependent routing (e.g., honoring proxy env vars) is allowed only as an optimization above an enforcing layer that already blocks non-cooperative traffic; it is never the enforcement mechanism itself. - -Backends that cleanly separate private-network ingress from egress apply both directions independently. Windows -AppContainer requires the bidirectional `privateNetworkClientServer` capability before private-network traffic can -flow in either direction. ProcessContainer therefore requires `ingress.default: "allow"` for outbound private-network -access, after which `egress` rules apply to both public and private outbound destinations. - -This capability coupling is a backend mapping, not a change to the shared -meaning of `ingress.default`. The ProcessContainer section identifies which -proxy deployments retain strict endpoint scoping and which compatibility -deployment additionally requires general host-loopback access. - -### D8: Delegation from the invoking user - -**Decision:** Like the filesystem configuration, the network configuration is a delegation: the contained code receives no more network access than the invoking user could exercise themselves. - -**Why:** The sandbox can only be more restricted, never less. A sandboxed process cannot reach a network destination that the invoking user's own process could not reach. Host-level firewalls, VPN configuration, and similar environmental controls still apply on top of this. - -## GA Scope by Backend - -GA includes all backends for their respective isolation capabilities. Network -configuration enforcement varies by backend. This section describes the GA -target for each backend; it does not claim that unfinished roadmap work is -already implemented. - -### Process containers (Windows): GA target and compatibility behavior - -The strict host-loopback guarantee applies to the identity-scoped -ProcessContainer path with OS-scoped proxy enforcement. The identity-less host -proxy path requires broader host-loopback access and is an explicitly documented -PSEC compatibility behavior rather than strict model-2 enforcement. The -AppContainer fallback rejects schema 0.8 runtime proxy requests because -they cannot preserve either posture. - -**Connectivity models:** - -- **Model 2 (recommended):** Grants no `internetClient`, so direct internet traffic is blocked. Any packaged proxy, - with or without AppContainer isolation, uses its Package Family Name in a non-blank `allowedProxyPeer`; an unpackaged - AppContainer proxy uses its profile name. Windows requires `ingress.default: "allow"` to grant the bidirectional - `privateNetworkClientServer` capability. With `allowedProxyPeer`, proxy reachability remains scoped to that peer and - endpoint and `ingress.hostLoopback` stays `"deny"`. An identity-less host proxy cannot use peer scoping and is the - documented development/testing compatibility path that requires `ingress.hostLoopback: "allow"`; it does not - provide the strict host-loopback-closure guarantee. -- **Model 1:** Grants `internetClient`, allowing direct internet egress under WFP IP/CIDR/port/protocol rules. - Private-network outbound also requires `ingress.default: "allow"` and remains subject to the same `egress` rules. -- **Model 3:** Grants no `internetClient`, private-network capability, or loopback exemptions. - -**Enforcement:** - -| Configuration concept | Enforcement mechanism | Notes | -|---|---|---| -| IP/CIDR allow/block | PSEC IPv4/IPv6 WFP filters scoped to AppContainer SID | Public and private destinations; unsupported on AppContainer fallback | -| Port filtering | Port filtering via WFP | Port ranges supported. | -| Protocol filtering | Protocol filtering via WFP | Schema values are `tcp`, `udp`, `icmp`, and `any`; WFP maps ICMP by address family. | -| Default-deny | PSEC WFP block-all baseline filter at lower precedence than explicit allows. | A non-empty allow list grants `internetClient` only as the capability prerequisite; WFP still limits egress to explicit allows. With no allows, `internetClient` is absent. | -| Proxy (HTTP/S only) | PSEC per-AppContainer WinHTTP configuration, endpoint filtering, and optional scoped peer access | Identity-scoped proxies keep `hostLoopback: "deny"`; the identity-less development/testing compatibility path requires `"allow"`; schema 0.8 proxy requests do not fall back to AppContainer | -| Per-sandbox scoping | AppContainer SID, unique per sandbox instance | | -| Private network | `privateNetworkClientServer` via `ingress.default` | Capability gate; `egress` filters outbound | -| Inbound | Capabilities and loopback rules | Private network uses `ingress.default`; loopback is separate | -| DNS | DNS queries follow same IP/CIDR allow/block rules as other traffic. No domain-based filtering. | If DNS resolver IP is blocked, DNS fails. If allowed, sandbox can resolve any domain. **For HTTP(S) via the proxy, DNS resolution happens in the proxy.** | -| Bypass resistance | High. Kernel-enforced WFP filters. Bypass requires kernel compromise or AppContainer escape (elevation). | | - -**Implementation doc:** [Process Container Networking Configuration, GA](../../../process-container/networking.md) - -### WSLc: GA enforcement - -**Default stance:** Deny all outbound through the VM-level network policy API. - -**Connectivity model:** The VM-level network policy API enforces all three models. For model 2, MXC translates the -caller's loopback proxy to a VM-reachable endpoint and permits only that endpoint. The original loopback address is not -reachable directly from the WSL2 VM. - -**Recommended path:** Localhost proxy (HTTP/S via env vars) with no direct -egress allow-list. - -**Enforcement:** - -| Configuration concept | Enforcement mechanism | Notes | -|---|---|---| -| IP/CIDR allow/block | VM-level network policy API | IPv4 + IPv6 at GA | -| Port filtering | VM-level network policy API | Port ranges supported | -| Protocol filtering | VM-level network policy API | tcp, udp, icmp | -| Default-deny | VM-level network policy API | | -| Proxy (HTTP/S only) | Proxy variables plus VM-level allow for the translated endpoint | Direct bypass is blocked | -| Per-container scoping | VM and container identity | | -| Inbound and host loopback | VM-level policy applies `ingress.default` and bidirectional `ingress.hostLoopback` | | -| DNS | DNS queries follow same IP/CIDR allow/block rules as other traffic. No domain-based filtering. | If DNS resolver IP is blocked, DNS fails. If allowed, sandbox can resolve any domain. **For HTTP(S) via the proxy, DNS resolution happens in the proxy.** | -| Bypass resistance | Medium. Depends on VM-level enforcement and correct per-container scoping. | | - -### LXC and Bubblewrap: GA enforcement - -LXC and Bubblewrap use iptables/nftables on the container network path. Their INPUT policy applies `ingress.default` -and the host-to-container half of `ingress.hostLoopback`; routing and output policy enforce its container-to-host half. -Model 2 permits only the proxy endpoint. - -Both backends subtract `to[].except` from the peer that carries it and program the covering blocks that remain, so one -peer can expand into many rules. Two ceilings bound that expansion: a single peer may expand into at most **256 address -blocks** once its exclusions are removed, and one `egress` policy may lower into at most **65,536 rules** in total, -counting every destination block in every port each rule names. A policy exceeding either ceiling is rejected with the -limit it hit; neither is silently truncated, and no partial policy is installed. - -> **Implementation status (Bubblewrap).** Egress is enforced from schema 0.8+, -> but not on the path described above. Unprivileged Bubblewrap has no host-side -> veth, so no chain can be hooked into `FORWARD`. Instead the sandbox gets its -> own network namespace and a supervisor holding namespace-scoped -> `CAP_NET_ADMIN` filters on `OUTPUT` inside it (see the D2 note above). Rule -> addresses must be IP literals or CIDRs per D3; a hostname is rejected. -> Model 2 is enforced by the same chain, which opens only the proxy endpoint. -> An `INPUT` chain applies the inbound deny posture in the same transaction, -> accepting loopback and reply traffic and dropping new inbound connections. It -> requires `nf_conntrack`; the sandbox fails to launch without it rather than -> running unenforced. -> -> Not yet covered: `ingress.default: "allow"` and `ingress.hostLoopback: -> "allow"` are both rejected, because honoring either needs slirp port -> forwarding and a port contract neither schema expresses. The legacy -> `network.allowLocalNetwork: true` is rejected on a private-namespace mode for -> the same reason. Deny is therefore the only reachable inbound posture today. -> -> Both deny postures are enforced outside proxy mode. `ingress.default` -> installs the `INPUT` chain described above. `ingress.hostLoopback` is -> bidirectional per this contract, so its deny also closes the container-to-host -> direction: under slirp that path is the gateway `10.0.2.2`, which maps onto -> the host's own loopback, and a drop for it is lowered *ahead* of every caller -> rule so that a broad allow — including a bare `0.0.0.0/0` — cannot reopen it -> on the first-match chain. An omitted `ingress` section enforces the same deny, -> since this contract resolves both ingress controls to `deny` when they are -> absent rather than treating them as an absence of policy. IPv4 -> only — slirp gives the sandbox no IPv6 route to the host. -> -> **Proxy mode is the defined exception.** A runtime proxy is reached at the -> gateway `10.0.2.2:`, which *is* host loopback, so the chain opens that -> one TCP endpoint and drops the rest of the gateway. That is exactly the -> exception the 0.8 contract sanctions: the endpoint named by -> `runtimeConfig.networkProxy` is allowed independently of -> `ingress.hostLoopback`, and no other host-loopback path is opened. A proxy -> config that states — or defaults to — `hostLoopback: "deny"` therefore gets -> the posture it writes; the deny remains in force for every host-loopback path -> other than that one endpoint. `ingress.hostLoopback` is not consulted in this -> mode, because the chain comes from a proxy-specific builder rather than the -> directional one that lowers the drop, but the result the caller observes -> matches the contract either way. -> -> Inbound denial does not currently depend on the `INPUT` chain in practice: -> no port forwarding is configured, so nothing outside the sandbox can reach in -> regardless. Schema 0.6/0.7 keeps the previous warn-and-continue behavior and -> gains no host-loopback rule. -> LXC is unaffected: it has a veth and runs privileged, and enforces as -> described. - -### macOS (Seatbelt): GA enforcement - -**Default stance:** Egress is confined to the loopback proxy port by the Seatbelt profile. - -**Connectivity model:** Model 2 and 3 only. Model 1 (direct egress under IP/CIDR rules) is not supported because Seatbelt cannot enforce arbitrary destination policy. It can deny network entirely or allow only the localhost proxy path, but not general IP/CIDR, hostname, port, or protocol allow-lists. - -**Recommended path:** Loopback proxy with the Seatbelt localhost egress restriction and proxy variables. - -**Enforcement:** - -| Configuration concept | Enforcement mechanism | Notes | -|---|---|---| -| Egress restriction (model 2) | Seatbelt profile: (deny default) then allow network-outbound only to `localhost:`. All other outbound (direct internet, raw sockets, direct DNS) is denied by the profile. | Confines egress to the proxy port; does not filter arbitrary destinations. | -| Proxy routing (HTTP/S) | `HTTP_PROXY`/`HTTPS_PROXY` set to the loopback proxy; cooperating clients route there. | A minority of clients ignore the variables; their traffic is dropped by the egress restriction, not bypassed. | -| IP/CIDR / port / protocol allow-lists | Not supported. | | -| Per-sandbox scoping | Seatbelt profile per sandbox-exec invocation | | -| Inbound | Seatbelt `network-inbound (local ip)` rule | Preserves current `allowLocalNetwork` behavior through `ingress.default`. `hostLoopback: "allow"` under `default: "deny"` is rejected with `policy_validation`; `hostLoopback: "deny"` under `default: "allow"` is accepted, enforcing only the container-to-host half. | -| DNS | Direct outbound DNS to an external resolver is blocked (egress confined to the proxy port); cooperating clients pass hostnames to the proxy, which resolves them. All others would be blocked. | | -| Bypass resistance | Medium. Egress is profile-restricted to the proxy port, so raw-socket and direct-DNS attempts are denied. Weaker than a separate network namespace (Seatbelt shares the host network stack) and depends on a correct profile. | | - -### Other backends - -- **Windows Sandbox:** Guest-side firewall only, with hardcoded rules. In GA for development/testing scenarios where network isolation is not critical. -- **Isolation Session:** No network filtering or denial is possible — outbound is open and a process inside can listen - on a localhost-reachable port. Schema 0.7 requires the unrestricted-network acknowledgment - (`network.defaultPolicy=allow` + `network.allowLocalNetwork=true`). The directional schema 0.8 acknowledgment is - reserved for the backend migration work; until that lands, callers must continue using the legacy unrestricted - acknowledgment. Other network/proxy policy is rejected. - In GA for process isolation only (identity, lifecycle). -- **Hyperlight, Nanvix:** Not in this GA scope doc. Additional follow up is needed to confirm their capabilities and whether they align with this doc. - -## Gaps and limitations - -**What GA cannot do:** - -- **DNS domain filtering:** The IP/CIDR schema cannot distinguish hostnames that resolve to the same IP. Domain allow/deny requires either deeper platform support or the proxy inspecting the CONNECT/request host plus blocking direct DNS egress (see D3). -- **Inter-container networking:** Containers cannot communicate with each other (except Windows process containers). -- **macOS direct-egress models:** Seatbelt cannot filter arbitrary remote destinations, so model 1 (direct egress under IP/CIDR/port/protocol rules) is not available on macOS; macOS supports model 2 (proxy-only). -- **Proxy arbitrary network traffic:** GA MXC configures proxies for HTTP/S traffic only. On Windows, only clients that - use the WinHTTP stack or correctly query the platform proxy configuration are proxied. Many libraries on all three - platforms use proxy environment variables as their configuration mechanism. On Linux and macOS these are the - standard way to apply proxy configurations; however, it is advisory only and not a full RFC standard. Libraries and - applications that honor them use the proxy; those that ignore them do not have their traffic directed to the proxy - and instead have their egress blocked. diff --git a/docs/sandbox-policy/0.8.0/networking/schema-updates.md b/docs/sandbox-policy/0.8.0/networking/schema-updates.md deleted file mode 100644 index 097fdcdd6..000000000 --- a/docs/sandbox-policy/0.8.0/networking/schema-updates.md +++ /dev/null @@ -1,151 +0,0 @@ -# MXC network schema updates: 0.7 to 0.8 - -> **Audience:** MXC consumers and developers - -Schema 0.8 adds explicit egress and ingress policy and moves proxy runtime data -outside the shared policy. During the additive transition, a 0.8 request may -use either the legacy fields or the new fields, but cannot mix both formats. -Schemas before 0.8 cannot use the new fields. - -## Field mapping - -| Schema 0.7 | Schema 0.8 | Change | -|---|---|---| -| `defaultPolicy: "allow"` | `egress.default: "allow"` | Same outbound posture | -| `defaultPolicy: "block"` | `egress.default: "deny"` | `block` is renamed `deny` | -| IP/CIDR entries in `allowedHosts` | `egress.allow[].to[].cidr` | Can scope by port/protocol | -| IP/CIDR entries in `blockedHosts` | `egress.deny[].to[].cidr` | Deny overrides allow | -| DNS names in `allowedHosts` or `blockedHosts` | No GA equivalent | Schema 0.8 accepts only IP/CIDR rules | -| `enforcementMode` | Removed | The backend enforces the policy or rejects it | -| `allowLocalNetwork` | `ingress.default` | Capability gate; outbound follows `egress` | -| No equivalent | `ingress.hostLoopback` | New bidirectional host-loopback connectivity control | -| `proxy.localhost` | `runtimeConfig.networkProxy` | Loopback proxy endpoint becomes runtime data | -| `proxy.url` with an HTTP/S loopback URL | `runtimeConfig.networkProxy` | Loopback URL remains supported | -| `proxy.url` with a remote or non-loopback URL | No GA equivalent | Schema 0.8 accepts only loopback proxy URLs | - -`proxy.builtinTestServer` has no schema 0.8 GA equivalent. - -On backends that cleanly separate private-network ingress from egress, `egress` governs all outbound traffic and -`ingress` governs traffic entering the container. ProcessContainer also applies `egress` rules to all outbound -destinations, but private-network traffic additionally requires `ingress.default: "allow"` to grant Windows' -bidirectional `privateNetworkClientServer` capability. - -This table maps the immutable 0.7 wire fields. In schema 0.7, `allowLocalNetwork` expresses inbound bind/listen -permission and is honored by Seatbelt; ProcessContainer capabilities are separate. It maps only to -`ingress.default` and must not change `egress.default`. Under schema 0.8, ProcessContainer uses that ingress value as -the capability gate for private-network traffic, while outbound private destinations remain subject to `egress`. - -## Direct egress - -Schema 0.7: - -```jsonc -{ - "network": { - "defaultPolicy": "block", - "enforcementMode": "both", - "allowedHosts": [ "140.82.112.0/20" ], - "allowLocalNetwork": false - } -} -``` - -Schema 0.8: - -```jsonc -{ - "network": { - "egress": { - "default": "deny", - "allow": [ - { - "to": [ { "cidr": "140.82.112.0/20" } ], - "ports": [ { "protocol": "tcp", "port": 443 } ] - } - ] - }, - "ingress": { - "default": "deny", - "hostLoopback": "deny" - } - } -} -``` - -This schema 0.8 example intentionally narrows the schema 0.7 all-port, -all-protocol allow to TCP port 443. Omit `ports` to preserve the schema 0.7 -rule exactly. - -## Proxy - -Schema 0.7 uses cooperative proxy variables: - -```jsonc -{ - "network": { - "proxy": { "localhost": 8080 } - } -} -``` - -Schema 0.8 moves the endpoint to runtime metadata: - -```jsonc -{ - "runtimeConfig": { - "networkProxy": "http://127.0.0.1:8080" - } -} -``` - -The omitted 0.8 `network` block uses deny defaults. When `runtimeConfig.networkProxy` is present, cooperating HTTP(S) -clients are configured to use the proxy; clients that ignore the proxy settings are blocked from direct egress. - -For example, this legacy policy: - -```jsonc -{ - "network": { - "defaultPolicy": "block", - "allowLocalNetwork": true - } -} -``` - -migrates to deny-default egress with allowed private/LAN inbound: - -```jsonc -{ - "network": { - "egress": { "default": "deny" }, - "ingress": { - "default": "allow", - "hostLoopback": "deny" - } - } -} -``` - -On backends that cleanly separate private-network ingress from egress, this does not grant outbound private-network or -internet access. On ProcessContainer, `ingress.default: "allow"` grants the bidirectional private-network capability, -but deny-default `egress` still blocks outbound public and private destinations unless an allow rule or the configured -proxy path applies. - -Backend-specific migration can require an additional acknowledgment without changing the shared field mapping: - -- Seatbelt cannot separate host loopback from other local inbound traffic, so `ingress.hostLoopback: "allow"` under - `ingress.default: "deny"` is rejected: no rule can carry the promised host-to-container grant. The reverse pair, - `ingress.hostLoopback: "deny"` under `ingress.default: "allow"`, is accepted and enforces the container-to-host - half only; the blanket inbound grant over-permits the host-to-container half, which is documented in the - [Seatbelt backend guide](../../../seatbelt/seatbelt-backend.md). -- Isolation Session cannot enforce any network restriction. Its directional - acknowledgment is reserved for the backend migration work; until that lands, - callers must continue using the legacy unrestricted acknowledgment - (`defaultPolicy: "allow"` plus `allowLocalNetwork: true`). - -## Backend-specific schema 0.8 configuration - -| Backend | Configuration | -|---|---| -| ProcessContainer | [Schema 0.8 proxy configuration](../../../process-container/examples/0.8.0-schema.md) | -| Seatbelt (macOS) | [Schema 0.8 configuration](../../../seatbelt/seatbelt-backend.md#schema-08-network-shape-egress--ingress--runtimeconfignetworkproxy) diff --git a/docs/sandbox-policy/0.8.0/policy.md b/docs/sandbox-policy/0.8.0/policy.md deleted file mode 100644 index 8fc5aa0d7..000000000 --- a/docs/sandbox-policy/0.8.0/policy.md +++ /dev/null @@ -1,150 +0,0 @@ -# MXC Sandbox Policy Spec v0.8.0 - -> **Audience:** MXC consumers - -> Historical design reference: schema 0.8 is retired. Use the -> [current schema and migration guide](../../schema.md) for supported requests. - -## SandboxPolicy - -`SandboxPolicy` is MXC's cross-platform JSON authoring contract. It expresses -portable filesystem, network, UI, and execution restrictions without selecting -backend-specific mechanisms. This page defines the language-neutral JSON shape; -SDK API coverage is version-dependent, and an SDK's native types or builders -may not yet expose every field. When supported, those APIs produce the -`ContainerConfig` consumed by MXC. - -```json -{ - "version": "0.8.0-alpha", - "network": { - "egress": { - "default": "deny", - "allow": [ - { - "to": [{ "cidr": "192.0.2.0/24" }], - "ports": [{ "protocol": "tcp", "port": 443 }] - } - ] - }, - "ingress": { - "default": "deny", - "hostLoopback": "deny" - } - }, - "ui": { - "allowWindows": false, - "clipboard": "none", - "allowInputInjection": false - }, - "timeoutMs": 30000 -} -``` - -### `filesystem` - -| Field | Description | -|---|---| -| `readwritePaths` | Paths the sandbox can read and write. | -| `readonlyPaths` | Paths the sandbox can read but not write. | -| `deniedPaths` | Paths the sandbox cannot access. | -| `clearPolicyOnExit` | Clear retained policy after execution. Maps to the inverse of `lifecycle.preservePolicy` and can cover multiple policy types, including filesystem and network policy. Defaults to `true`. | - -Omitted filesystem permissions remain denied. - -### `network` - -Schema 0.8 accepts either the legacy network fields or the directional fields, -but not both in one policy. - -#### Legacy network fields - -These fields preserve schema 0.6 and 0.7 authoring compatibility. - -| Field | Description | -|---|---| -| `allowOutbound` | Allow outbound network access. Defaults to `false`. | -| `allowLocalNetwork` | Allow inbound connections from local/private networks. Defaults to `false`. | -| `allowedHosts` | Hosts or CIDRs allowed by backends that support host filtering. | -| `blockedHosts` | Hosts or CIDRs denied by backends that support host filtering. | -| `proxy` | Legacy proxy configuration: `builtinTestServer`, `localhost`, or `url`. | - -#### `network.egress` - -| Field | Description | -|---|---| -| `default` | Action when no rule matches: `"allow"` or `"deny"`. Defaults to `"deny"`. | -| `allow` | Direct-egress rules to allow. | -| `deny` | Direct-egress rules to deny. Deny rules take precedence. | - -Each allow or deny rule has this shape: - -```typescript -type NetworkRuleConfig = { - to?: Array<{ - cidr: string; - except?: string[]; - }>; - ports?: Array<{ - protocol?: "tcp" | "udp" | "icmp" | "any"; - port?: number; - endPort?: number; - }>; -}; -``` - -| Rule field | Description | -|---|---| -| `to` | Destination CIDRs. Omission matches both IP families. | -| `to[].cidr` | IPv4 or IPv6 CIDR. | -| `to[].except` | CIDRs excluded from the containing peer. Each exclusion must use the same address family and be contained within `to[].cidr`. | -| `ports` | Protocol and destination-port selectors. Omission matches all. | -| `ports[].protocol` | `"tcp"`, `"udp"`, `"icmp"`, or `"any"`. Defaults to `"any"`. | -| `ports[].port` | Destination port from 1 through 65535. Omission matches every port. Must be omitted for `"icmp"`. | -| `ports[].endPort` | Inclusive range end from 1 through 65535. Requires `port`, must be greater than or equal to it, and must be omitted for `"icmp"`. | - -Explicit `to` and `ports` arrays must not be empty. - -#### `network.ingress` - -| Field | Description | -|---|---| -| `default` | LAN/private-network inbound action: `"allow"` or `"deny"`. Defaults to `"deny"`. | -| `hostLoopback` | Bidirectional host-loopback action: `"allow"` or `"deny"`. Defaults to `"deny"`. | - -Backends that cannot enforce a requested ingress combination reject it rather -than weakening the policy. - -### `runtimeConfig` - -Schema 0.8 introduces `runtimeConfig` for cross-platform runtime metadata -needed to execute a policy but which is not itself security-policy intent. It -is configuration rather than policy, and it is not tied to a specific backend. -The only runtime metadata currently defined is the network proxy endpoint. - -| Field | Description | -|---|---| -| `networkProxy` | HTTP/S loopback proxy URL with an explicit port. Requires `network.egress.default` to be `"deny"` with no direct allow or deny rules. | - -### `ui` - -| Field | Description | -|---|---| -| `allowWindows` | Allow visible windows. Defaults to `false`. | -| `clipboard` | `"none"`, `"read"`, `"write"`, or `"all"`. Defaults to `"none"`. | -| `allowInputInjection` | Allow keyboard or mouse input injection. Defaults to `false`. | - -### `timeoutMs` - -Execution timeout in milliseconds. Omission means no timeout. - -### Default-deny - -Omitted permissions remain denied. A schema 0.8 policy with no network fields -selects directional deny defaults. - -## Examples and detailed behavior - -- [Current directional config example (supported v1.0)](../../../tests/examples/30_network_0_8_directional.json) -- [Schema updates from 0.7 to 0.8](networking/schema-updates.md) -- [Network modes and rule semantics](networking/networking.md) diff --git a/docs/seatbelt/seatbelt-backend.md b/docs/seatbelt/seatbelt-backend.md index c115be864..8c33a2fea 100644 --- a/docs/seatbelt/seatbelt-backend.md +++ b/docs/seatbelt/seatbelt-backend.md @@ -136,9 +136,9 @@ Seatbelt matches AF_UNIX sockets by **path**, so MXC governs them with the | `deniedPaths` | ❌ | ❌ | Why: Node toolchains (tsx, vite, esbuild, jest workers) need both halves for -IPC. Gating them behind `allowLocalNetwork` would force real network ingress on -just to run a build. These rules are path-scoped, so they never widen IP -networking. +IPC. Gating them behind `ingress.default: "allow"` would force real network +ingress on just to run a build. These rules are path-scoped, so they never +widen IP networking. > ⚠️ **`connect()` is a capability `file-write*` alone didn't grant.** A broad > `readwritePaths` root lets the sandbox talk to any pre-existing listener @@ -183,8 +183,8 @@ Anything it hasn't declared is rejected up front. ### Fields (supported schema 0.9+) This is the cross-backend directional shape accepted by the registered exact -contracts. The original [0.8 networking design](../sandbox-policy/0.8.0/networking/networking.md) -is historical; schema 0.8 is no longer accepted. +contracts. Schema 0.8 is no longer accepted; see the +[supported network fields](../schema.md#directional-networking-supported-contracts). > **Omitting `network` entirely denies all IP networking.** Every field below > defaults to `deny`, so a config with no `network` block behaves exactly like @@ -325,26 +325,6 @@ combined with `runtimeConfig.networkProxy`. > that same port number**. It cannot be narrowed — a literal `127.0.0.1` is a > profile syntax error. -### Migrating retired 0.7 network fields - -Pre-0.9 contracts fail at version dispatch; none of these fields is accepted -in a supported request. Translate stored policies before declaring v0.9+: - -| Legacy (0.7) | Directional equivalent | Notes | -|---|---|---| -| `defaultPolicy: "block"` | `egress.default: "deny"` | Identical profile output | -| `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` / `blockedHosts` | *(no direct equivalent)* | Seatbelt has no per-host filter. Configure hostname restrictions on a caller-managed proxy instead. | -| *(no equivalent)* | `ingress.hostLoopback` | Legacy configs never expressed host-loopback direction explicitly. | - -The last row is the one migration hazard: 0.7 has no `hostLoopback` concept, so -`defaultPolicy: "allow"` leaves loopback **open**. Translating that to -`egress.default: "allow"` closes it, because `hostLoopback` defaults to `deny`. -Add `ingress: { "default": "allow", "hostLoopback": "allow" }` to preserve the -old behavior. - ## UI policy | Policy | Generated rule | @@ -630,7 +610,6 @@ rejected before Seatbelt validation. | `runtimeConfig.networkProxy` + non-empty direct `egress.allow` / `egress.deny` | Direct rules and proxy-only mode are alternatives | Remove direct rules and enforce destination policy at the proxy | | `runtimeConfig.networkProxy` with a non-loopback host | The runtime proxy endpoint must be loopback | Use `localhost`, `127.0.0.1`, or `[::1]` | | `processContainer.network.allowedProxyPeer` | Peer identity pinning isn't supported | Remove it | -| `defaultPolicy`, host lists, `network.proxy`, or `enforcementMode` | No supported exact contract defines them | Migrate to directional policy and a caller-managed runtime proxy where needed | #### Filesystem diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index ff937026d..7786ae378 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -547,6 +547,11 @@ rejection records from the same invocation. A successful launch emits no | `mxc.SandboxTornDown` | Per-run resources released, once per handle | ProcessContainer: `backend`, `identity`, `tier`, `pid`, `status`, `firewall_rules_removed`, `firewall_removal_ok`, `bfs_removed`, `proxy_stopped`, `preserve_policy`, `container_released`, `skip_reason`. IsolationSession: `backend`, `identity`, `phase`, `status`, `session_stopped`, `agent_user_deprovisioned`, `client_unregistered` | | `mxc.ConfigRejected` | A request was refused before it could run | `correlation_id`, `backend`, `reason`, `offending_field`, `phase` | +For the ProcessContainer AppContainer fallback, the firewall fields remain in +these records for compatibility: no local firewall rules are created or +removed, `firewall_applied` is `false`, and `firewall_removal_ok` is `true`. +Proxy setup failures still set `mxc.NetworkPolicyApplied.status` to failure. + ### Error semantics: `FallbackError` vs `ActivityError` Tier selection can *degrade* (proceed with weaker enforcement) or *fail* @@ -639,8 +644,10 @@ Excluded, and why: | `dry_run`, `testing_features_enabled` | Invocation modes, not policy. | | `experimental_enabled` | Authorizes selecting an experimental backend, not enforcement; changing it leaves policy identity unchanged. | -`network_enforcement_compatibility` is included because it changes how the -normalized network policy is interpreted and enforced. +The retired network compatibility marker is no longer part of the projection. +Hashes from builds that included it can differ even when the supported +effective policy is unchanged; compare policy hashes across builds only with +that projection change in mind. Enforcement-relevant backend configuration is hashed from `ExecutionRequest.windows_sandbox` and `ExecutionRequest.wslc`. The canonical @@ -708,7 +715,7 @@ that it was skipped. | Process outcome (M-ETW-1) | ✅ | ✅ | ✅ | ✅ (shared `create_process`) | | Enforcement degradation (M-ETW-2) | ✅ (shared dispatcher; records the tier actually selected) | ✅ | n/a — no tier/fallback ladder exists for this backend | n/a | | Policy hash (M-ETW-3) | ✅ | ✅ | ✅ | ✅ | -| Network policy (M-ETW-4) | ✅ (`enforcement_mode: capabilities` — policy travels in the sandbox spec and the OS enforces it, so `firewall_rules_created` is honestly `0`) | ✅ (`firewall` / `both`) | n/a — MXC rejects network and proxy policy for this backend before provisioning | n/a | +| Network policy (M-ETW-4) | ✅ (`enforcement_mode: capabilities` — policy travels in the sandbox spec and the OS enforces it, so `firewall_rules_created` is honestly `0`) | ✅ (`enforcement_mode: capabilities` for supported directional requests; egress default is reported as `allow` or `block`) | n/a — MXC rejects network and proxy policy for this backend before provisioning | n/a | | Sandbox teardown (M-ETW-5) | ✅ | ✅ | ✅ | ✅ (`stop` and `deprovision` phases) | | IsolationSession telemetry (M-ETW-6) | n/a | n/a | ✅ Applicable lifecycle events use `Microsoft.MXC`; no separate OS provider is assumed | ✅ Same provider path | | Configuration rejection (M-ETW-7) | ✅ | ✅ | ✅ | ✅ (`phase` names the rejecting phase) | diff --git a/docs/versioning.md b/docs/versioning.md index dd4e461d7..6199c3e33 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -67,7 +67,7 @@ take a caller-supplied schema version. Each v1 SDK targets the published exact `1.0.0` contract internally. Raw configuration APIs require the caller to declare a registered `version` -to select an immutable historical or mutable development contract. +to select an immutable published or mutable development contract. ```typescript // sdk/node/src/types.ts @@ -167,9 +167,9 @@ mxc/schemas/ └── mxc-config.schema.1.1.0-alpha.json (exact closed development contract) ``` -Retired stable schema files are **kept as immutable historical artifacts** — the -parser simply stops accepting those versions (the supported floor is -`0.9.0-alpha`). Released schemas are never edited or deleted. +Published stable schemas remain immutable and cannot be removed, including +those below the supported floor (`0.9.0-alpha`). The parser rejects retired +versions even though their schema artifacts remain in the repository. The development artifact is generated from the exact `mxc_contract::dev` model. It describes all eight closed one-shot and @@ -231,11 +231,11 @@ attribution after exact validation because they did not originate as external configuration JSON. Runtime behavior is selected from explicit normalized semantics rather than by -comparing contract-version strings. In particular, -`NetworkEnforcementCompatibility::Strict` is the only mode for registered -exact contracts and direct typed SDK requests. Retired pre-v0.9 contracts -cannot select legacy enforcement behavior. Direct typed SDK construction -clears only source-contract attribution; it does not weaken validation. +comparing contract-version strings. Registered exact contracts and direct typed +SDK requests use the same strict network enforcement. Retired pre-v0.9 +contracts cannot select legacy enforcement behavior. Direct typed SDK +construction clears only source-contract attribution; it does not weaken +validation. ### IsolationSession directional networking @@ -715,9 +715,9 @@ all experimental features in the config are active. There is no per-feature enable/disable mechanism — simplicity over granularity. **Migration after promotion:** Promotion changes publication and runtime -authorization, not the feature's JSON path. Old contracts retain their exact -historical shapes; new requests must use the shape defined by their declared -exact version. +authorization, not the feature's JSON path. Each exact contract retains its +version-specific shape; requests must use the shape defined by their declared +version. ## Deprecation Aliases @@ -730,9 +730,8 @@ schema, generated raw type, and raw SDK validator must agree on the spellings accepted by the declared `config.version`; aliases are not version-independent parser exceptions. -The historical v0 contracts retain their original compatibility aliases. -`appcontainer` and `appContainer` are accepted by v0.6-v0.9, while -`macos_sandbox` is accepted by v0.7-v0.9. The v1 line removes all three +The supported v0.9 contract accepts `appcontainer`, `appContainer`, and +`macos_sandbox` as compatibility aliases. The v1 line removes all three spellings at its major-version boundary, so raw v1.0 and v1.1 requests must use `processcontainer`, `processContainer`, and `seatbelt`. @@ -746,8 +745,8 @@ native parser; the TypeScript SDK validator may still surface a deprecation hint via `diagLog` while inspecting raw config. **Removal.** Removing an accepted alias is a breaking wire change and therefore -belongs at a major-version boundary. Older immutable contracts continue to -accept their historical spellings; the new major rejects them with the +belongs at a major-version boundary. The supported v0.9 contract continues to +accept its aliases; the new major rejects them with the standard exact-contract diagnostic. ## Open Questions diff --git a/docs/windows-sandbox/windows-sandbox.md b/docs/windows-sandbox/windows-sandbox.md index d1bfac152..fe70c8f5e 100644 --- a/docs/windows-sandbox/windows-sandbox.md +++ b/docs/windows-sandbox/windows-sandbox.md @@ -151,9 +151,9 @@ non-default values. | `filesystem.readwritePaths` | Existing directories mapped read-write at the same path | | `filesystem.readonlyPaths` | Existing directories mapped read-only at the same path | | `filesystem.deniedPaths` | Accepted outside shares; rejected when overlapping a mapped share | -| Default network policy `block` | Enforced by the guest firewall | -| Default network policy `allow` | Rejected | -| `allowedHosts` / `blockedHosts` | Rejected | +| Omitted network policy | Guest firewall blocks external networking | +| `network.egress` or `network.ingress` supplied (even empty or deny-only) | Rejected; omit both sections to use guest isolation | +| Retired `defaultPolicy` and host lists | Rejected by the exact contract | | Network proxy | Rejected | Mapped paths must be absolute existing directories. Files, nested mapped roots, diff --git a/docs/wsl/wsl-container-getting-started.md b/docs/wsl/wsl-container-getting-started.md index 964c657e1..032fe1464 100644 --- a/docs/wsl/wsl-container-getting-started.md +++ b/docs/wsl/wsl-container-getting-started.md @@ -422,11 +422,10 @@ rules, but a WSLC container runs **without** `CAP_NET_ADMIN` (the SDK's `Privileged` flag does not grant it), so those rules cannot be applied — and MXC has no VM-level enforcement hook either (WSLC cannot expose one without breaking other security promises such as MDE). Rather than fail the run at exec time, -such configs are **rejected at config-parse time**: +such configs are **rejected before provisioning**: ``` -WSLc: per-host egress filtering (allowedHosts with defaultPolicy='block', or -blockedHosts with defaultPolicy='allow') is not supported. ... +WSLc does not support network.egress allow/deny rules; networking is all-or-nothing ``` Use a runtime proxy with unrestricted bridged networking for cooperative host diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index ac44389a0..d2348e8e8 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -216,7 +216,7 @@ Backend/platform discovery, errors, telemetry, and helpers are also in | Terminal process outcome | `WaitResult` | All types above are in `Microsoft.Mxc.Sdk.V1`. See the -[networking guide](https://github.com/microsoft/mxc/blob/main/docs/sandbox-policy/0.8.0/networking/networking.md) +[networking guide](https://github.com/microsoft/mxc/blob/main/docs/schema.md#directional-networking-supported-contracts) for policy behavior and the [SDK API reference](https://github.com/microsoft/mxc/blob/main/docs/reference/README.md) for complete signatures and types. diff --git a/sdk/node/README.md b/sdk/node/README.md index 677c20c7e..ff9adea01 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -214,7 +214,7 @@ remain on the selected containment configuration. | Host consent presenter | `TelemetryConsentPresenter` | Network policy details are in the -[networking guide](https://github.com/microsoft/mxc/blob/main/docs/sandbox-policy/0.8.0/networking/networking.md); +[networking guide](https://github.com/microsoft/mxc/blob/main/docs/schema.md#directional-networking-supported-contracts); host-specific behavior and supported capabilities are documented in the backend guides under [`docs/`](https://github.com/microsoft/mxc/tree/main/docs). See the [SDK API reference](https://github.com/microsoft/mxc/blob/main/docs/reference/README.md) diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs index 89e661e0e..b76f7b1c5 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs @@ -10,10 +10,9 @@ use std::collections::HashSet; use crate::mxc_common::filesystem_resolve::FsIntent; -use crate::mxc_common::models::{ - ContainerPolicy, ExecutionRequest, NetworkAction, NetworkEnforcementCompatibility, - NetworkEnforcementMode, NetworkPolicy, ProxyAddress, -}; +use crate::mxc_common::models::{ExecutionRequest, NetworkAction, ProxyAddress}; +#[cfg(any(test, target_os = "linux"))] +use crate::mxc_common::models::{NetworkEnforcementMode, NetworkPolicy}; use crate::mxc_common::proxy_env::{is_managed_proxy_key, PROXY_SET_KEYS}; /// The fixed prefix of the command bwrap is asked to run. @@ -102,177 +101,67 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[ pub(crate) enum ResolvedNetworkMode { /// A private network namespace with no external connectivity. Isolated, - /// The host network namespace without MXC firewall filtering. - Shared, - /// Pre-0.8 cooperative proxy routing in the host network namespace. - LegacyProxy, - /// The host network namespace with per-destination iptables filtering. - /// - /// Pre-0.8 only. The rules land on the *host* chain, which the sandbox - /// never traverses, so this filters nothing; it is retained unchanged - /// because pre-0.8 callers already run under it. - FirewallFiltered, - /// Per-destination iptables filtering inside a slirp-backed private - /// namespace, with no proxy. 0.8+ only. + /// Directional iptables filtering inside a slirp-backed private namespace. FirewallEnforced, /// Cooperative proxy routing inside a slirp-backed private namespace, with /// egress closed to everything but the proxy. ProxyOnly, } -/// Whether this request uses strict network-contract behavior. -fn uses_strict_network_contract(request: &ExecutionRequest) -> bool { - request.network_enforcement_compatibility == NetworkEnforcementCompatibility::Strict -} - -/// Whether the policy carries the 0.8 directional shape. -/// -/// Either section is enough: the parser fills both together, but checking only -/// one would route an ingress-only policy into the legacy path, where its -/// directional intent is silently discarded. Shared by every site that splits -/// directional from legacy so the fail-closed reading cannot drift apart. -/// -/// Distinct from `crate::mxc_common::validator::directional_posture_supplied`, which -/// also folds in `network_mode_specified` and the proxy; the two are not -/// interchangeable. -pub(crate) fn is_directional(policy: &ContainerPolicy) -> bool { - policy.network_egress.is_some() || policy.network_ingress.is_some() -} - impl ResolvedNetworkMode { /// Classify the internal request using the proxy's resolved runtime state. pub(crate) fn from_request(request: &ExecutionRequest, proxy_active: bool) -> Self { if proxy_active { - return if uses_strict_network_contract(request) { - Self::ProxyOnly - } else { - Self::LegacyProxy - }; + return Self::ProxyOnly; } - // The directional 0.8 posture is authoritative when present: the parser - // fills these sections only on that path and leaves every legacy field - // at its default, so classifying from the legacy fields below would - // read a policy that never said either as `Isolated` and take the - // sandbox offline with the requested rules programmed nowhere. - // - // Either section makes it directional (see `is_directional`); a missing - // egress defaults to deny, failing closed. - // - // Directional never selects `Shared`: the host namespace has no inbound - // primitive, and `network_ingress` has no `_specified` twin, so a - // defaulted `ingress.default='deny'` is indistinguishable from one the - // caller wrote and could not be refused without refusing every - // "allow all outbound" policy with it. The private namespace honors - // both directions, so an open egress posture becomes an accept-all - // chain rather than an unfiltered namespace. - if is_directional(&request.policy) { - // Off the 0.8 contract there is no private namespace to program. - // Report the unfiltered truth and let `validate` refuse it rather - // than pretending the posture was applied. - if !uses_strict_network_contract(request) { - return Self::Shared; - } - let egress = request.policy.network_egress.clone().unwrap_or_default(); - // A bare deny is `--unshare-net`: cheaper than slirp plus a - // deny-all chain, identical in effect, and inbound is denied by - // the absence of connectivity rather than by a rule. - let ruleless = egress.allow.is_empty() && egress.deny.is_empty(); - return if ruleless && egress.default == NetworkAction::Deny { - Self::Isolated - } else { - Self::FirewallEnforced - }; - } - - let uses_firewall = matches!( - request.policy.network_enforcement_mode, - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both - ); - let has_host_rules = - !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty(); - - if uses_firewall && has_host_rules { - if uses_strict_network_contract(request) { - Self::FirewallEnforced - } else { - Self::FirewallFiltered - } - } else if request.policy.default_network_policy == NetworkPolicy::Block && !has_host_rules { + // An absent egress section is the supported deny default. Only a + // ruleless deny can omit slirp and the namespace-local firewall. + let egress = request.policy.network_egress.as_ref(); + if egress.is_none_or(|egress| { + egress.default == NetworkAction::Deny + && egress.allow.is_empty() + && egress.deny.is_empty() + }) { Self::Isolated } else { - Self::Shared + Self::FirewallEnforced } } - /// Whether current behavior gives the sandbox a private network namespace. - pub(crate) fn uses_private_netns(self) -> bool { - matches!( - self, - Self::Isolated | Self::ProxyOnly | Self::FirewallEnforced - ) - } - /// Whether the runner supplies a pre-created user namespace to Bubblewrap. pub(crate) fn uses_external_userns(self) -> bool { matches!(self, Self::ProxyOnly | Self::FirewallEnforced) } +} - /// Whether the mode uses the host-side firewall manager. - /// - /// False for `ProxyOnly` and `FirewallEnforced`, which are *not* - /// iptables-free: they program rules directly into the sandbox's own - /// network namespace from the supervisor (see `proxy_network`) rather than - /// going through the host manager. - #[cfg(any(target_os = "linux", test))] - pub(crate) fn requires_host_firewall_manager(self) -> bool { - matches!(self, Self::FirewallFiltered) - } +/// Interim protection for directly constructed requests while the shared +/// runtime model still exposes fields retired from every supported contract. +#[cfg(any(test, target_os = "linux"))] +pub(crate) fn retired_network_fields_rejection(request: &ExecutionRequest) -> Option<&'static str> { + let policy = &request.policy; + (policy.default_network_policy != NetworkPolicy::Block + || policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities + || policy.allow_local_network + || !policy.allowed_hosts.is_empty() + || !policy.blocked_hosts.is_empty()) + .then_some( + "Bubblewrap: retired network.defaultPolicy, enforcementMode, allowLocalNetwork, \ + allowedHosts and blockedHosts are not supported; use network.egress and \ + network.ingress.", + ) +} - /// Whether this mode actually applies the policy's host lists. - /// - /// `FirewallEnforced` filters by address in the sandbox's own namespace. - /// `ProxyOnly` closes egress to everything but the proxy endpoint, so the - /// lists are enforced at the proxy — true only for the builtin proxy; an - /// external one is refused by [`external_proxy_host_rules_rejection`]. - /// Every other mode leaves the lists unapplied. - pub(crate) fn enforces_host_rules(self) -> bool { - matches!(self, Self::FirewallEnforced | Self::ProxyOnly) - } +/// The built-in test proxy has no supported exact contract spelling. +#[cfg(any(test, target_os = "linux"))] +pub(crate) fn builtin_proxy_rejection(request: &ExecutionRequest) -> Option<&'static str> { + request.policy.network_proxy.builtin_test_server.then_some( + "Bubblewrap: network.proxy.builtinTestServer is retired; configure an externally \ + managed runtimeConfig.networkProxy.", + ) } -/// Rejection text for host lists no mechanism will enforce, on schema 0.8+. -/// -/// Owned here rather than in the parser: this is a statement about what -/// Bubblewrap can enforce, so it belongs with the backend that enforces it. -pub const BWRAP_UNENFORCED_HOST_RULES: &str = - "Bubblewrap: network.allowedHosts and network.blockedHosts require an enforcement \ - mechanism. With enforcementMode='capabilities' (the default) and no network.proxy, \ - nothing applies them: the host lists suppress the --unshare-net full block, so the \ - sandbox shares the host network namespace with no filtering at all. Set \ - network.enforcementMode to 'firewall' to filter by address inside the sandbox's own \ - network namespace, set network.proxy to enforce host filtering at the proxy layer, or \ - drop the host lists to get the namespace-level block that defaultPolicy='block' applies \ - on its own."; - -/// Rejection text for `allowLocalNetwork=false` under a shared namespace. -pub const BWRAP_LOCAL_NETWORK_SHARED_NS: &str = - "Bubblewrap: network.allowLocalNetwork=false is not enforced while the \ - sandbox shares the host network namespace (defaultPolicy='allow'). \ - The sandboxed process can still bind, listen and accept on host-local addresses. For \ - an unreachable sandbox use defaultPolicy='block' with no host rules and no proxy, \ - which applies --unshare-net; to keep the shared namespace, acknowledge the exposure \ - with allowLocalNetwork=true."; - -/// Rejection text for `allowLocalNetwork=true` under a private namespace. -pub const BWRAP_LOCAL_NETWORK_PRIVATE_NS: &str = - "Bubblewrap: network.allowLocalNetwork=true is confined to the sandbox's own \ - network namespace. Isolated, proxy and firewall modes apply --unshare-net, so a \ - listener inside the sandbox is reachable only from within it, never from the host. \ - Use defaultPolicy='allow' with no host rules and no proxy to share the host network \ - namespace."; - -/// Refuse a directional 0.8 posture Bubblewrap cannot honor. +/// Refuse an inbound posture Bubblewrap cannot honor. /// /// The backend declares `INGRESS_DEFAULT` and `HOST_LOOPBACK` in /// [`network_policy_support`], which tells shared validation it understands @@ -281,11 +170,6 @@ pub const BWRAP_LOCAL_NETWORK_PRIVATE_NS: &str = /// only thing standing between an unsupported value and a silent drop, so they /// ship in the same change as the declaration. /// -/// Deliberately *not* schema-gated. The pre-0.8 warning path exists for configs -/// that predate a rule; a directional section is a 0.8 construct, so seeing one -/// on an older schema is a caller error rather than a legacy config to carry -/// forward — and the mode resolver reports `Shared` for it, meaning nothing -/// would be programmed. pub fn directional_network_rejection(request: &ExecutionRequest) -> Option<&'static str> { let egress = request.policy.network_egress.as_ref(); let ingress = request.policy.network_ingress.as_ref(); @@ -293,10 +177,6 @@ pub fn directional_network_rejection(request: &ExecutionRequest) -> Option<&'sta return None; } - if !uses_strict_network_contract(request) { - return Some(BWRAP_DIRECTIONAL_PRE_0_8); - } - if let Some(ingress) = ingress { if ingress.default == NetworkAction::Allow { return Some(BWRAP_INGRESS_DEFAULT_ALLOW); @@ -309,14 +189,6 @@ pub fn directional_network_rejection(request: &ExecutionRequest) -> Option<&'sta None } -/// Rejection text for a directional section on a pre-0.8 schema. -pub const BWRAP_DIRECTIONAL_PRE_0_8: &str = - "Bubblewrap: network.egress/network.ingress require schema 0.9.0-alpha or later. \ - Earlier schemas run the sandbox in the host network namespace, where the \ - directional posture would be accepted and then never programmed. Raise the \ - config's version field, or express the policy with defaultPolicy, \ - allowedHosts and blockedHosts."; - /// Rejection text for an inbound-accepting directional posture. pub const BWRAP_INGRESS_DEFAULT_ALLOW: &str = "Bubblewrap: network.ingress.default='allow' is not supported. The sandbox runs in a \ @@ -332,85 +204,25 @@ pub const BWRAP_HOST_LOOPBACK_ALLOW: &str = cannot dial a sandbox listener and nothing bridges the two. Use \ network.ingress.hostLoopback='deny'."; -/// Reject host lists that no mechanism will apply, on schema 0.8+. -/// -/// Host lists suppress the `Isolated` mode, so a mode that does not apply them -/// leaves the sandbox on the host namespace unfiltered. This is the only layer -/// that checks it: the parser validates structure, and every caller — CLI, -/// SDK, FFI, or a Rust caller handing `mxc_engine` a hand-built -/// `ExecutionRequest` — reaches a runner, while only JSON configs reach the -/// parser. -pub fn unenforced_host_rules_rejection(request: &ExecutionRequest) -> Option<&'static str> { - if !uses_strict_network_contract(request) { - return None; - } - let has_host_rules = - !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty(); - let mode = - ResolvedNetworkMode::from_request(request, request.policy.network_proxy.is_enabled()); - - (has_host_rules && !mode.enforces_host_rules()).then_some(BWRAP_UNENFORCED_HOST_RULES) -} - -/// Rejection text for an external proxy combined with host lists. -/// -/// Duplicated (verbatim) from the parser's pre-existing check of the same -/// combination. The parser keeps its copy for JSON configs; this one covers -/// callers who hand a runner an `ExecutionRequest` directly. -pub const BWRAP_EXTERNAL_PROXY_HOST_RULES: &str = - "Bubblewrap: an external network.proxy (url/localhost) cannot be combined with \ - allowedHosts, blockedHosts, or defaultPolicy='block'. The external proxy is expected to \ - enforce its own host policy; MXC does not forward host lists to it. Use \ - 'network.proxy.builtinTestServer: true' (testing only) for MXC-enforced host filtering, \ - or remove the host policy."; - -/// Rejection text for a proxy combined with firewall enforcement. -/// -/// Duplicated (verbatim) from the parser's pre-existing check of the same -/// combination, exactly as [`BWRAP_EXTERNAL_PROXY_HOST_RULES`] is. The parser -/// keeps its copy for JSON configs; this one covers callers who hand a runner -/// an `ExecutionRequest` directly. Keep the two strings identical. -pub const BWRAP_PROXY_WITH_FIREWALL: &str = "Bubblewrap: network.proxy cannot be combined with \ - network.enforcementMode='firewall' or 'both'. The cooperative \ - env-var proxy enforces hosts at the proxy layer; iptables-based \ - enforcement requires privilege and is mutually exclusive."; - -/// Rejection text for a proxy combined with a directional egress rule set. -/// -/// Distinct from [`BWRAP_EXTERNAL_PROXY_HOST_RULES`] because the two describe -/// different mistakes: that one names the legacy host-list fields, none of -/// which a directional caller has set. A caller told to remove `allowedHosts` -/// when they wrote `network.egress` would have nothing to act on. -pub const BWRAP_PROXY_DIRECTIONAL_EGRESS: &str = - "Bubblewrap: network.proxy cannot be combined with a directional network.egress rule set. \ +/// Rejection text for a proxy combined with direct egress. +#[cfg(any(test, target_os = "linux"))] +pub(crate) const BWRAP_PROXY_DIRECTIONAL_EGRESS: &str = + "Bubblewrap: runtimeConfig.networkProxy cannot be combined with direct network.egress rules \ + or default='allow'. \ A proxy resolves to proxy-only egress, whose chain opens the proxy endpoint alone and \ never reads network.egress, so the rules would be dropped in silence. Use \ network.egress.default='deny' with no allow/deny rules (the proxy-only posture), or \ - remove network.proxy and express the policy with network.egress."; + remove runtimeConfig.networkProxy and express the policy with network.egress."; -/// Validate-time twin of the parser's external-proxy host-list rejection. -/// -/// MXC applies the host lists itself only for the builtin test proxy; an -/// operator-supplied proxy is never handed them, so accepting the combination -/// would drop the requested policy silently. Not schema-gated, matching the -/// parser. -pub fn external_proxy_host_rules_rejection(request: &ExecutionRequest) -> Option<&'static str> { +/// Reject direct egress that proxy-only routing would otherwise discard. +#[cfg(any(test, target_os = "linux"))] +pub(crate) fn proxy_with_egress_rejection(request: &ExecutionRequest) -> Option<&'static str> { let proxy = &request.policy.network_proxy; if !proxy.is_enabled() { return None; } - // Checked ahead of the builtin exemption because it binds to *every* - // enabled proxy. Both proxy flavors resolve to `ResolvedNetworkMode:: - // ProxyOnly`, whose chain comes from `EgressPlan::for_proxy` and never - // reads `network_egress` -- so a directional rule set is dropped in - // silence either way. The exemption below is about host *lists*, which - // MXC does apply itself for the builtin proxy; nothing applies directional - // rules under a proxy, so the exemption must not extend to them. - // - // JSON configs cannot reach this (the parser constrains the same posture), - // which leaves programmatic callers: precisely who this twin exists for. - let directional_conflicts = request + request .policy .network_egress .as_ref() @@ -418,114 +230,8 @@ pub fn external_proxy_host_rules_rejection(request: &ExecutionRequest) -> Option egress.default != NetworkAction::Deny || !egress.allow.is_empty() || !egress.deny.is_empty() - }); - if directional_conflicts { - return Some(BWRAP_PROXY_DIRECTIONAL_EGRESS); - } - - if proxy.builtin_test_server { - return None; - } - let has_host_rules = - !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty(); - // `default_network_policy` is a legacy-shape field: the directional path - // never writes it, so it sits at its `Block` default whether or not the - // caller said anything about egress. Reading it as a caller statement - // would refuse every directional runtime proxy -- the one posture the - // parser already constrains to `egress.default='deny'` with no direct - // rules, which *is* proxy-only egress and carries no host lists that could - // be silently dropped. - let legacy_shape = !is_directional(&request.policy); - let conflicts = has_host_rules - || (legacy_shape && request.policy.default_network_policy == NetworkPolicy::Block); - - conflicts.then_some(BWRAP_EXTERNAL_PROXY_HOST_RULES) -} - -/// Validate-time twin of the parser's proxy + firewall-enforcement rejection. -/// -/// [`ResolvedNetworkMode::from_request`] tests the proxy first and returns -/// `ProxyOnly`, so `enforcementMode` is never consulted on that path: without -/// this gate the runner silently discards an explicitly-set, security-relevant -/// field. Public SDK policy entry points validate through the exact parser: -/// `mxc_sdk::v1::build_request*` constructs an exact `1.0.0` Rust contract -/// value and normalizes it through `config_parser`, and `SandboxRequest`'s -/// inner `ExecutionRequest` is `pub(crate)`. This gate also protects direct -/// engine callers holding an `ExecutionRequest`. Not schema-gated, matching -/// the parser. -pub fn proxy_with_firewall_rejection(request: &ExecutionRequest) -> Option<&'static str> { - let conflicts = request.policy.network_proxy.is_enabled() - && matches!( - request.policy.network_enforcement_mode, - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both - ); - conflicts.then_some(BWRAP_PROXY_WITH_FIREWALL) -} - -/// Validate-time twin of [`local_network_diagnostic`]: the same mismatch, but -/// only for schemas that reject it rather than warn. -/// -/// Keys off proxy *enablement* rather than a resolved address because -/// `builtinTestServer` has no address until the proxy starts, which is after -/// validation. -/// -/// Rejects on the value, not on whether the caller wrote the field. `false` is -/// the schema default *and* a deny, so an omitted `allowLocalNetwork` still -/// asks for inbound denial. Callers wanting the shared namespace acknowledge -/// the exposure with `allowLocalNetwork=true`. The warning path keeps the -/// looser check: it only logs. -pub fn local_network_rejection(request: &ExecutionRequest) -> Option<&'static str> { - if !uses_strict_network_contract(request) { - return None; - } - let mode = - ResolvedNetworkMode::from_request(request, request.policy.network_proxy.is_enabled()); - local_network_diagnostic_for_mode(request, mode) -} - -/// Describe a `network.allowLocalNetwork` setting Bubblewrap cannot honor, or -/// `None` when the sandbox's namespace already matches the request. -/// -/// `allowLocalNetwork` governs whether the sandboxed process may bind/listen on -/// local IPs and accept **inbound** connections (it says nothing about outbound -/// reachability of local addresses — that is `defaultPolicy`/`allowedHosts`). -/// Bubblewrap has no inbound-only primitive: the sandbox either gets a private -/// network namespace or shares the host's, and neither can be narrowed further. -/// Unprivileged bwrap has no veth to scope iptables to, and seccomp cannot -/// dereference the `sockaddr` passed to `bind`, so an AF_INET-only filter is not -/// expressible. The namespace choice therefore decides the outcome, and this -/// returns the mismatch so the runner can say so out loud rather than dropping -/// the field silently. -/// -/// The private-namespace arm satisfies `false` only at the sandbox boundary: -/// bwrap brings `lo` up inside the new namespace, so sandbox processes can still -/// bind and connect to each other over their own loopback. That stays inside the -/// caller's trust boundary — those processes already share pipes, files and the -/// mount namespace — so it is not warned about. -pub fn local_network_diagnostic( - request: &ExecutionRequest, - proxy_address: Option<&ProxyAddress>, -) -> Option<&'static str> { - let network_mode = ResolvedNetworkMode::from_request(request, proxy_address.is_some()); - local_network_diagnostic_for_mode(request, network_mode) -} - -/// Describe an inbound-policy mismatch using a previously resolved mode. -/// -/// The text carries no severity prefix so it can serve both the pre-0.8 -/// warning and the 0.8+ rejection; callers add "WARNING: " when logging. -pub(crate) fn local_network_diagnostic_for_mode( - request: &ExecutionRequest, - network_mode: ResolvedNetworkMode, -) -> Option<&'static str> { - match ( - request.policy.allow_local_network, - network_mode.uses_private_netns(), - ) { - (false, false) => Some(BWRAP_LOCAL_NETWORK_SHARED_NS), - (true, true) => Some(BWRAP_LOCAL_NETWORK_PRIVATE_NS), - _ => None, - } + }) + .then_some(BWRAP_PROXY_DIRECTIONAL_EGRESS) } /// `PATH` for the sandboxed child, from schema 0.9. @@ -547,8 +253,7 @@ const DEFAULT_TERM: &str = "xterm-256color"; /// directories — a relative `--chdir` would otherwise resolve against whatever /// cwd bwrap carried into the namespace, leaving `HOME` naming a different /// directory than the one the child landed in. Normalizing against the sandbox -/// root is what makes them agree, so it is gated on the schema that introduced -/// `HOME`; below 0.9 the caller's spelling reaches `--chdir` untouched. +/// root is what makes them agree for every supported contract. /// /// A policy grant is deliberately *not* consulted: bwrap enters one only when /// `process.cwd` names it, so treating it as the start directory would put @@ -556,13 +261,7 @@ const DEFAULT_TERM: &str = "xterm-256color"; fn start_directory(request: &ExecutionRequest) -> Option { Some(request.working_directory.as_str()) .filter(|dir| !dir.is_empty()) - .map(|dir| { - if request.supplies_default_env() { - crate::mxc_common::models::sandbox_absolute_path(dir) - } else { - dir.to_string() - } - }) + .map(crate::mxc_common::models::sandbox_absolute_path) } /// The default environment: `PATH`, `TERM`, and — when one resolves — `HOME`. @@ -587,8 +286,8 @@ fn default_env(request: &ExecutionRequest) -> Vec<(String, String)> { /// The entries the child should get, as `KEY=VALUE` strings. /// /// The state dispatch and overlay merge are shared; see -/// [`crate::mxc_common::default_env::resolve_env`]. Below 0.9 the caller's entries are -/// passed through untouched. +/// [`crate::mxc_common::default_env::resolve_env`]. Explicit replacement entries +/// are passed through untouched. fn resolved_env(request: &ExecutionRequest) -> Vec { crate::mxc_common::default_env::resolve_env(request, || default_env(request)) } @@ -610,8 +309,7 @@ pub fn build_args(request: &ExecutionRequest, proxy_address: Option<&ProxyAddres /// The returned vector does **not** include the `bwrap` binary name itself — /// callers pass it to `Command::new("bwrap").args(&args)`. /// -/// `proxy_address` is the proxy endpoint visible from the sandbox (if the -/// request has `network.proxy` configured). +/// `proxy_address` is the sandbox-visible runtime proxy endpoint, if configured. /// When `Some`, the builder: /// - emits `--unshare-net` and expects the runner to provide `--userns FD` /// plus slirp-backed connectivity, @@ -663,12 +361,9 @@ pub(crate) fn build_args_classified_with_mode( // removed the network enforcement it was sandboxed by. args.push("--die-with-parent".into()); - // Network: full-block and proxy modes use a private namespace. Proxy mode - // receives rootless connectivity from the runner's slirp supervisor. - // Per-host firewall mode continues to share the host namespace. - if network_mode.uses_private_netns() { - args.push("--unshare-net".into()); - } + // Every supported mode has a private namespace; slirp provides connectivity + // only after its firewall is installed. + args.push("--unshare-net".into()); // -- Base filesystem (deny-by-default; see `BASELINE_RO_BIND_PATHS`) --- // bwrap applies mounts in order; later mounts at the same path shadow @@ -747,11 +442,8 @@ pub(crate) fn build_args_classified_with_mode( } // -- Network proxy env vars ------------------------------------------- - // Cooperative env-var proxying: well-behaved tools (curl, requests, - // etc.) honor these and route through the proxy where allow / block - // enforcement happens. Tools that bypass these variables (raw sockets) - // are NOT enforced -- this is a documented limitation of the - // unprivileged proxy model. + // Cooperating tools route through the externally managed proxy; raw + // sockets cannot bypass the namespace-local default-DROP chain. // // We deliberately do NOT set NO_PROXY here. Exempting any destination // would let cooperating clients bypass the configured proxy policy. @@ -772,7 +464,6 @@ pub(crate) fn build_args_classified_with_mode( #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::ContainerPolicy; fn base_request() -> ExecutionRequest { ExecutionRequest { @@ -785,13 +476,8 @@ mod tests { /// `process.env` resolution, which schema 0.9 gave a default block. mod env { use super::*; - use crate::mxc_common::models::DefaultEnvCompatibility; - - fn request(compatibility: DefaultEnvCompatibility) -> ExecutionRequest { - ExecutionRequest { - default_env_compatibility: compatibility, - ..Default::default() - } + fn request() -> ExecutionRequest { + ExecutionRequest::default() } fn value<'a>(entries: &'a [String], key: &str) -> Option<&'a str> { @@ -810,7 +496,7 @@ mod tests { #[test] fn an_omitted_env_gets_the_default_block() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = "/workspace".into(); let entries = resolved_env(&r); @@ -832,21 +518,21 @@ mod tests { #[test] fn an_explicitly_empty_env_stays_empty() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec![]); assert!(resolved_env(&r).is_empty()); } #[test] fn a_supplied_env_is_used_verbatim() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FOO=bar".into()]); assert_eq!(resolved_env(&r), vec!["FOO=bar".to_string()]); } #[test] fn inherit_default_env_layers_over_the_default_block() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FOO=bar".into(), "PATH=/only/mine".into()]); r.inherit_default_env = true; let entries = resolved_env(&r); @@ -864,7 +550,7 @@ mod tests { #[test] fn home_follows_the_directory_the_child_starts_in() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = "/workspace".into(); assert_eq!(value(&resolved_env(&r), "HOME"), Some("/workspace")); @@ -875,7 +561,6 @@ mod tests { fn a_caller_entry_without_a_value_reaches_no_setenv() { for inherit in [false, true] { let mut r = base_request(); - r.default_env_compatibility = DefaultEnvCompatibility::DefaultBlock; r.env = Some(vec!["FEATURE_FLAG".into(), "FOO=bar".into()]); r.inherit_default_env = inherit; let args = build_args(&r, None); @@ -897,7 +582,7 @@ mod tests { /// enters must not become its `HOME`. #[test] fn a_policy_grant_alone_does_not_become_home() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = String::new(); // A real directory, so the shared resolver's `is_dir` probe would @@ -917,7 +602,7 @@ mod tests { #[test] fn home_and_chdir_agree() { for cwd in ["", "/workspace", "work", "./work", "a/../b", "/x/../y/./z"] { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = cwd.into(); let args = build_args(&r, None); @@ -942,7 +627,7 @@ mod tests { ("a/../b", "/b"), ("/x/../y/./z", "/y/z"), ] { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = cwd.into(); assert_eq!(value(&resolved_env(&r), "HOME"), Some(expected)); @@ -952,7 +637,6 @@ mod tests { #[test] fn the_default_block_reaches_the_argument_list() { let mut r = base_request(); - r.default_env_compatibility = DefaultEnvCompatibility::DefaultBlock; r.env = None; let args = build_args(&r, None); @@ -969,14 +653,8 @@ mod tests { } #[test] - fn the_schema_gate_does_not_apply_when_no_proxy_is_active() { - // Without an active proxy the version is irrelevant — a 0.8 request - // still classifies on policy alone (default policy is block, so this - // lands on the plain isolated namespace, not the proxy one). - let request = ExecutionRequest { - network_enforcement_compatibility: NetworkEnforcementCompatibility::Strict, - ..base_request() - }; + fn an_omitted_network_policy_is_isolated() { + let request = ExecutionRequest { ..base_request() }; assert_eq!( ResolvedNetworkMode::from_request(&request, false), ResolvedNetworkMode::Isolated @@ -1001,501 +679,12 @@ mod tests { assert!(args.contains(&"--die-with-parent".to_string())); } - struct NetworkPlanCase { - name: &'static str, - compatibility: NetworkEnforcementCompatibility, - default_policy: NetworkPolicy, - enforcement_mode: NetworkEnforcementMode, - allowed_hosts: &'static [&'static str], - blocked_hosts: &'static [&'static str], - proxy_active: bool, - expected: ResolvedNetworkMode, - } - - #[test] - fn classifies_current_network_policy_modes() { - let cases = [ - NetworkPlanCase { - name: "default block is isolated", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Block, - enforcement_mode: NetworkEnforcementMode::Capabilities, - allowed_hosts: &[], - blocked_hosts: &[], - proxy_active: false, - expected: ResolvedNetworkMode::Isolated, - }, - NetworkPlanCase { - name: "default allow is shared", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Allow, - enforcement_mode: NetworkEnforcementMode::Capabilities, - allowed_hosts: &[], - blocked_hosts: &[], - proxy_active: false, - expected: ResolvedNetworkMode::Shared, - }, - NetworkPlanCase { - name: "firewall allow rules are enforced in-namespace at 0.8", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Block, - enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: &["203.0.113.7"], - blocked_hosts: &[], - proxy_active: false, - expected: ResolvedNetworkMode::FirewallEnforced, - }, - NetworkPlanCase { - name: "firewall allow rules are enforced for host rules", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Block, - enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: &["example.com"], - blocked_hosts: &[], - proxy_active: false, - expected: ResolvedNetworkMode::FirewallEnforced, - }, - NetworkPlanCase { - name: "combined enforcement block rules are enforced at 0.8", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Allow, - enforcement_mode: NetworkEnforcementMode::Both, - allowed_hosts: &[], - blocked_hosts: &["198.51.100.9"], - proxy_active: false, - expected: ResolvedNetworkMode::FirewallEnforced, - }, - NetworkPlanCase { - name: "combined enforcement block rules are enforced for host rules", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Allow, - enforcement_mode: NetworkEnforcementMode::Both, - allowed_hosts: &[], - blocked_hosts: &["example.com"], - proxy_active: false, - expected: ResolvedNetworkMode::FirewallEnforced, - }, - NetworkPlanCase { - name: "capabilities mode with host rules stays shared", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Block, - enforcement_mode: NetworkEnforcementMode::Capabilities, - allowed_hosts: &["example.com"], - blocked_hosts: &[], - proxy_active: false, - expected: ResolvedNetworkMode::Shared, - }, - NetworkPlanCase { - name: "proxy takes precedence over isolation", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Block, - enforcement_mode: NetworkEnforcementMode::Capabilities, - allowed_hosts: &[], - blocked_hosts: &[], - proxy_active: true, - expected: ResolvedNetworkMode::ProxyOnly, - }, - NetworkPlanCase { - name: "proxy takes precedence over firewall filtering", - compatibility: NetworkEnforcementCompatibility::Strict, - default_policy: NetworkPolicy::Allow, - enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: &[], - blocked_hosts: &["example.com"], - proxy_active: true, - expected: ResolvedNetworkMode::ProxyOnly, - }, - ]; - - for case in cases { - let mut request = ExecutionRequest { - network_enforcement_compatibility: case.compatibility, - ..Default::default() - }; - request.policy.default_network_policy = case.default_policy; - request.policy.network_enforcement_mode = case.enforcement_mode; - request.policy.allowed_hosts = case - .allowed_hosts - .iter() - .map(|host| (*host).into()) - .collect(); - request.policy.blocked_hosts = case - .blocked_hosts - .iter() - .map(|host| (*host).into()) - .collect(); - - let actual = ResolvedNetworkMode::from_request(&request, case.proxy_active); - assert_eq!(actual, case.expected, "{}", case.name); - } - } - - #[test] - fn resolved_network_mode_capabilities_match_current_behavior() { - assert!(ResolvedNetworkMode::Isolated.uses_private_netns()); - assert!(ResolvedNetworkMode::ProxyOnly.uses_private_netns()); - assert!(ResolvedNetworkMode::ProxyOnly.uses_external_userns()); - assert!(!ResolvedNetworkMode::Isolated.uses_external_userns()); - assert!(!ResolvedNetworkMode::LegacyProxy.uses_private_netns()); - assert!(ResolvedNetworkMode::FirewallFiltered.requires_host_firewall_manager()); - assert!(!ResolvedNetworkMode::Shared.requires_host_firewall_manager()); - // ProxyOnly enforces via in-netns rules, not the host manager. - assert!(!ResolvedNetworkMode::ProxyOnly.requires_host_firewall_manager()); - // FirewallEnforced is the same shape as ProxyOnly minus the proxy: its - // rules are programmed from inside the sandbox's own namespace, which - // is the whole reason it filters where FirewallFiltered does not. - assert!(ResolvedNetworkMode::FirewallEnforced.uses_private_netns()); - assert!(ResolvedNetworkMode::FirewallEnforced.uses_external_userns()); - assert!(!ResolvedNetworkMode::FirewallEnforced.requires_host_firewall_manager()); - } - - #[test] - fn network_block_adds_unshare_net() { - let mut r = base_request(); - r.policy.default_network_policy = NetworkPolicy::Block; - let args = build_args(&r, None); - assert!(args.contains(&"--unshare-net".to_string())); - } - - #[test] - fn network_allow_omits_unshare_net() { - let mut r = base_request(); - r.policy.default_network_policy = NetworkPolicy::Allow; - let args = build_args(&r, None); - assert!(!args.contains(&"--unshare-net".to_string())); - } - - #[test] - fn network_block_with_host_rules_omits_unshare_net() { - let mut r = base_request(); - r.policy.default_network_policy = NetworkPolicy::Block; - r.policy.allowed_hosts = vec!["example.com".into()]; - let args = build_args(&r, None); - assert!( - !args.contains(&"--unshare-net".to_string()), - "should omit --unshare-net when host rules require iptables" - ); - } - - // ------- allowLocalNetwork diagnostic tests ------------------------- - - #[test] - fn local_network_denied_under_private_netns_is_not_warned() { - // Default policy (block, no host lists, no proxy) applies - // --unshare-net: nothing outside can reach in, so allowLocalNetwork=false - // is satisfied at the sandbox boundary and needs no warning. - let r = base_request(); - assert!(!r.policy.allow_local_network); - assert!(local_network_diagnostic(&r, None).is_none()); - } - - #[test] - fn local_network_denied_on_shared_netns_warns() { - let mut r = base_request(); - r.policy.default_network_policy = NetworkPolicy::Allow; - let msg = local_network_diagnostic(&r, None).expect("shared netns cannot honor the deny"); - assert!(msg.contains("allowLocalNetwork=false")); - } - - #[test] - fn local_network_denied_with_host_rules_warns() { - let mut r = base_request(); - r.policy.blocked_hosts = vec!["evil.example.com".into()]; - assert!(local_network_diagnostic(&r, None).is_some()); - } - - #[test] - fn local_network_denied_with_0_8_proxy_is_not_warned() { - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - let addr = ProxyAddress::new("127.0.0.1".into(), 8080); - assert!(local_network_diagnostic(&r, Some(&addr)).is_none()); - } - - #[test] - fn local_network_allowed_under_private_netns_warns() { - let mut r = base_request(); - r.policy.allow_local_network = true; - let msg = local_network_diagnostic(&r, None).expect("--unshare-net isolates the listener"); - assert!(msg.contains("allowLocalNetwork=true")); - } - - #[test] - fn local_network_allowed_on_shared_netns_is_honored() { - let mut r = base_request(); - r.policy.allow_local_network = true; - r.policy.default_network_policy = NetworkPolicy::Allow; - assert!(local_network_diagnostic(&r, None).is_none()); - } - - // ------- allowLocalNetwork rejection (schema 0.8+) ------------------ - - #[test] - fn local_network_mismatch_is_rejected_at_0_8() { - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - r.policy.default_network_policy = NetworkPolicy::Allow; - let msg = local_network_rejection(&r).expect("shared netns cannot honor the deny"); - assert!(msg.contains("allowLocalNetwork=false")); - // The text is reused verbatim as an error, so it must carry no severity. - assert!(!msg.contains("WARNING")); - } - - #[test] - fn an_omitted_local_network_is_rejected_like_an_explicit_deny_at_0_8() { - // `false` is the schema default *and* a deny, so silence still asks for - // inbound denial. - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - r.policy.default_network_policy = NetworkPolicy::Allow; - assert!(!r.policy.allow_local_network, "default is the deny posture"); - let msg = local_network_rejection(&r).expect("an omitted deny is still a deny"); - assert!(msg.contains("allowLocalNetwork=false")); - // Actionable only if it names the acknowledgment. - assert!(msg.contains("allowLocalNetwork=true")); - } - - #[test] - fn acknowledged_local_network_exposure_is_accepted_at_0_8() { - // The escape hatch from the rejection above. - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - r.policy.default_network_policy = NetworkPolicy::Allow; - r.policy.allow_local_network = true; - assert!(local_network_rejection(&r).is_none()); - } - - #[test] - fn honored_local_network_is_not_rejected_at_0_8() { - // block + no host rules + no proxy is a private netns, which satisfies - // allowLocalNetwork=false; nothing to reject. - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - assert!(local_network_rejection(&r).is_none()); - } - - #[test] - fn builtin_test_server_counts_as_an_active_proxy_when_rejecting() { - // builtinTestServer has no address until the proxy starts, which is - // after validation, so the rejection keys off enablement instead. - // defaultPolicy='allow' would be a shared namespace on its own, so the - // rejection fires only if the proxy is recognized without an address. - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - r.policy.default_network_policy = NetworkPolicy::Allow; - r.policy.allow_local_network = true; - assert!(local_network_rejection(&r).is_none()); - - r.policy.network_proxy.builtin_test_server = true; - assert!(r.policy.network_proxy.address.is_none()); - assert!(local_network_rejection(&r).is_some()); - } - - /// Without this twin a programmatic caller would sail past the parser's - /// rejection and have the host policy dropped on the floor. - #[test] - fn external_proxy_with_host_rules_is_rejected() { - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - r.policy.default_network_policy = NetworkPolicy::Allow; - r.policy.network_proxy.address = Some(ProxyAddress::new("proxy.example.com".into(), 3128)); - assert!(external_proxy_host_rules_rejection(&r).is_none()); - - r.policy.allowed_hosts = vec!["10.0.0.1".into()]; - let msg = external_proxy_host_rules_rejection(&r).expect("lists are never forwarded"); - assert!(msg.contains("external network.proxy")); - - r.policy.allowed_hosts.clear(); - r.policy.blocked_hosts = vec!["10.0.0.1".into()]; - assert!(external_proxy_host_rules_rejection(&r).is_some()); - - r.policy.blocked_hosts.clear(); - r.policy.default_network_policy = NetworkPolicy::Block; - assert!(external_proxy_host_rules_rejection(&r).is_some()); - } - - /// The builtin test proxy *is* handed the lists, so it must stay accepted. - #[test] - fn builtin_test_server_with_host_rules_is_not_rejected() { - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - r.policy.network_proxy.builtin_test_server = true; - r.policy.allowed_hosts = vec!["10.0.0.1".into()]; - assert!(external_proxy_host_rules_rejection(&r).is_none()); - } - - /// The parser refuses this pairing, and every public entry point reaches - /// the runner through the parser -- so this pins the runner's own twin - /// rather than a live bypass. Without it, `from_request` returns - /// `ProxyOnly` before `enforcementMode` is read, and an in-tree caller - /// holding an `ExecutionRequest` has the field dropped without a word. - #[test] - fn a_proxy_with_firewall_enforcement_is_rejected() { - let mut r = base_request(); - r.policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".into(), 3128)); - - for mode in [ - NetworkEnforcementMode::Firewall, - NetworkEnforcementMode::Both, - ] { - r.policy.network_enforcement_mode = mode.clone(); - assert_eq!( - proxy_with_firewall_rejection(&r), - Some(BWRAP_PROXY_WITH_FIREWALL), - "{mode:?} must be refused with the parser's own message" - ); - } - - // `is_enabled()` covers both proxy flavors, so the builtin refuses too. - let mut builtin = base_request(); - builtin.policy.network_proxy.builtin_test_server = true; - builtin.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; - assert!(proxy_with_firewall_rejection(&builtin).is_some()); - - // Negative controls: the default mode never invokes iptables, and the - // gate must key on the proxy rather than on the mode alone. - r.policy.network_enforcement_mode = NetworkEnforcementMode::Capabilities; - assert!(proxy_with_firewall_rejection(&r).is_none()); - - r.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; - r.policy.network_proxy.address = None; - assert!(proxy_with_firewall_rejection(&r).is_none()); - } - - /// A directional runtime proxy must not trip the legacy host-list guard. - /// - /// `default_network_policy` is a legacy-shape field the directional path - /// never writes, so it sits at its `Block` default. Reading it as if the - /// caller had asked for `defaultPolicy='block'` refused *every* directional - /// runtime proxy -- which made `RUNTIME_PROXY` declarable but unusable, - /// since the parser requires exactly `egress.default='deny'` with no direct - /// rules for that field. The guard must still fire on the legacy shape, and - /// still fire on real host lists in either shape, so all three are pinned - /// here. - #[test] - fn a_directional_runtime_proxy_is_not_refused_by_the_legacy_host_list_guard() { - use crate::mxc_common::models::{NetworkEgressPolicy, NetworkRule}; - - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - r.policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".into(), 3128)); - - // Legacy shape, defaulted `Block`: still refused, as before. - r.policy.default_network_policy = NetworkPolicy::Block; - assert!( - external_proxy_host_rules_rejection(&r).is_some(), - "the legacy shape must keep its refusal" - ); - - // Directional shape: the same defaulted `Block` is not a caller - // statement, so it must no longer refuse. - r.policy.network_egress = Some(NetworkEgressPolicy { - default: NetworkAction::Deny, - ..Default::default() - }); - assert!( - external_proxy_host_rules_rejection(&r).is_none(), - "a directional proxy-only posture carries no host lists to drop" - ); - - // An ingress-only directional request is directional too: keying the - // legacy discriminator on `egress` alone read the defaulted `Block` as - // a caller statement and refused it. - r.policy.network_egress = None; - r.policy.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy::default()); - assert!( - external_proxy_host_rules_rejection(&r).is_none(), - "an absent egress section is not a legacy `defaultPolicy='block'`" - ); - r.policy.network_ingress = None; - r.policy.network_egress = Some(NetworkEgressPolicy { - default: NetworkAction::Deny, - ..Default::default() - }); - - // Real host lists are still refused even on the directional shape -- - // the parser forbids the mix, so this only reaches a programmatic - // caller, which is exactly who this twin exists for. - r.policy.allowed_hosts = vec!["10.0.0.1".into()]; - assert!( - external_proxy_host_rules_rejection(&r).is_some(), - "host lists are never forwarded to an external proxy" - ); - r.policy.allowed_hosts.clear(); - - // Directional egress that is *not* the proxy-only posture must fail - // closed. `ProxyOnly` builds its chain from `EgressPlan::for_proxy` and - // never reads `network_egress`, so accepting these would drop the - // requested rules in silence -- the same class of bug the host-list - // half of this guard prevents. - for unhonorable in [ - NetworkEgressPolicy { - default: NetworkAction::Allow, - ..Default::default() - }, - NetworkEgressPolicy { - default: NetworkAction::Deny, - allow: vec![NetworkRule::default()], - ..Default::default() - }, - NetworkEgressPolicy { - default: NetworkAction::Deny, - deny: vec![NetworkRule::default()], - ..Default::default() - }, - ] { - r.policy.network_egress = Some(unhonorable.clone()); - assert_eq!( - external_proxy_host_rules_rejection(&r), - Some(BWRAP_PROXY_DIRECTIONAL_EGRESS), - "a directional rule set combined with a proxy would be dropped in silence" - ); - - // The builtin proxy takes the same chain from `for_proxy`, so the - // exemption it enjoys for host *lists* -- which MXC applies itself - // -- must not extend to directional rules, which nothing applies. - let mut builtin = r.clone(); - builtin.policy.network_proxy.address = None; - builtin.policy.network_proxy.builtin_test_server = true; - assert_eq!( - external_proxy_host_rules_rejection(&builtin), - Some(BWRAP_PROXY_DIRECTIONAL_EGRESS), - "the builtin proxy drops directional rules just as silently" - ); - } - - // The builtin proxy keeps its host-list exemption: that is the whole - // point of the flag, and narrowing it would be a behavior change. - r.policy.network_egress = Some(NetworkEgressPolicy { - default: NetworkAction::Deny, - ..Default::default() - }); - r.policy.network_proxy.address = None; - r.policy.network_proxy.builtin_test_server = true; - r.policy.allowed_hosts = vec!["10.0.0.1".into()]; - assert!( - external_proxy_host_rules_rejection(&r).is_none(), - "MXC enforces host lists itself for the builtin proxy" - ); - } - /// A directional egress policy must land on the one mode whose chains the /// sandbox actually traverses. /// - /// This is the invariant that was broken: `ResolvedNetworkMode` classified - /// from the legacy fields alone, which the directional parse path leaves at - /// their defaults, so a rule-bearing policy resolved to `Isolated` and the - /// runner — which builds the egress chain only under `FirewallEnforced` - /// (bwrap_runner.rs) — never programmed the rules at all. Unit tests on - /// `EgressPlan` cannot see this: they call the plan directly and so skip - /// the gate that decides whether it is ever called. #[test] fn a_directional_rule_selects_the_namespace_whose_chain_is_programmed() { - let request = directional_egress_request( - NetworkEnforcementCompatibility::Strict, - NetworkAction::Deny, - true, - ); + let request = directional_egress_request(NetworkAction::Deny, true); assert_eq!( ResolvedNetworkMode::from_request(&request, false), ResolvedNetworkMode::FirewallEnforced @@ -1509,43 +698,27 @@ mod tests { /// because the host namespace could not deny inbound at all. #[test] fn a_ruleless_directional_policy_still_honors_the_inbound_posture() { - let denied = directional_egress_request( - NetworkEnforcementCompatibility::Strict, - NetworkAction::Deny, - false, - ); + let denied = directional_egress_request(NetworkAction::Deny, false); assert_eq!( ResolvedNetworkMode::from_request(&denied, false), ResolvedNetworkMode::Isolated ); - let allowed = directional_egress_request( - NetworkEnforcementCompatibility::Strict, - NetworkAction::Allow, - false, - ); + let allowed = directional_egress_request(NetworkAction::Allow, false); let mode = ResolvedNetworkMode::from_request(&allowed, false); assert_eq!(mode, ResolvedNetworkMode::FirewallEnforced); - assert!(mode.uses_private_netns()); + assert!(build_args(&allowed, None).contains(&"--unshare-net".to_string())); } - /// No directional posture may share the host network namespace under the - /// 0.8 contract: there is no inbound primitive there, and a defaulted - /// `ingress.default='deny'` is indistinguishable from a written one, so - /// `Shared` would silently drop the inbound half of the policy. + /// Neither default nor rules may share the host namespace. #[test] fn no_directional_posture_shares_the_host_namespace() { for default in [NetworkAction::Allow, NetworkAction::Deny] { for with_rule in [false, true] { - let request = directional_egress_request( - NetworkEnforcementCompatibility::Strict, - default, - with_rule, - ); - let mode = ResolvedNetworkMode::from_request(&request, false); + let request = directional_egress_request(default, with_rule); assert!( - mode.uses_private_netns(), - "default={default:?} with_rule={with_rule} resolved to {mode:?}" + build_args(&request, None).contains(&"--unshare-net".to_string()), + "default={default:?} with_rule={with_rule} omitted --unshare-net" ); } } @@ -1556,86 +729,31 @@ mod tests { /// mode derives its chain from the resolved proxy endpoint. #[test] fn a_directional_section_does_not_divert_a_proxy_run() { - let mut request = directional_egress_request( - NetworkEnforcementCompatibility::Strict, - NetworkAction::Deny, - true, - ); - request.policy.network_proxy.builtin_test_server = true; + let mut request = directional_egress_request(NetworkAction::Deny, false); + request.policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".into(), 3128)); assert_eq!( ResolvedNetworkMode::from_request(&request, true), ResolvedNetworkMode::ProxyOnly ); } - /// An ingress-only directional request must not fall to the legacy arm. - /// - /// It did: keying on `network_egress` alone sent it there, where a leftover - /// `defaultPolicy='allow'` resolved to `Shared` -- the host namespace, with - /// no chain at all -- silently discarding both the ingress denial and the - /// host-loopback drop. + /// An ingress-only request retains isolation with implicit egress deny. #[test] fn an_ingress_only_directional_request_still_gets_a_private_namespace() { - use crate::mxc_common::models::{NetworkIngressPolicy, NetworkPolicy}; + use crate::mxc_common::models::NetworkIngressPolicy; let mut request = base_request(); - request.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; request.policy.network_ingress = Some(NetworkIngressPolicy::default()); - request.policy.default_network_policy = NetworkPolicy::Allow; - let mode = ResolvedNetworkMode::from_request(&request, false); - assert_ne!( - mode, - ResolvedNetworkMode::Shared, - "the host namespace programs no chain, so the deny posture would be lost" - ); - assert!(mode.uses_private_netns(), "{mode:?}"); - } - - /// The predicate is now shared by three call sites, so pin it directly: - /// a per-site test would let one drift without failing the others. - #[test] - fn either_directional_section_alone_marks_the_policy_directional() { - use crate::mxc_common::models::{NetworkEgressPolicy, NetworkIngressPolicy}; - - let cases = [ - (None, None, false), - (Some(NetworkEgressPolicy::default()), None, true), - (None, Some(NetworkIngressPolicy::default()), true), - ( - Some(NetworkEgressPolicy::default()), - Some(NetworkIngressPolicy::default()), - true, - ), - ]; - - for (egress, ingress, expected) in cases { - let policy = ContainerPolicy { - network_egress: egress.clone(), - network_ingress: ingress.clone(), - ..Default::default() - }; - assert_eq!( - is_directional(&policy), - expected, - "egress={:?} ingress={:?}", - egress.is_some(), - ingress.is_some() - ); - } + assert_eq!(mode, ResolvedNetworkMode::Isolated); + assert!(build_args(&request, None).contains(&"--unshare-net".to_string())); } - /// Build a directional request: no legacy network field is touched, which - /// is exactly what the parser produces on that path. - fn directional_egress_request( - compatibility: NetworkEnforcementCompatibility, - default: NetworkAction, - with_rule: bool, - ) -> ExecutionRequest { + /// Build a supported egress request. + fn directional_egress_request(default: NetworkAction, with_rule: bool) -> ExecutionRequest { use crate::mxc_common::models::{NetworkEgressPolicy, NetworkRule}; let mut request = base_request(); - request.network_enforcement_compatibility = compatibility; request.policy.network_egress = Some(NetworkEgressPolicy { default, allow: if with_rule { @@ -1650,14 +768,12 @@ mod tests { /// Build a directional request carrying both sections. fn directional_request( - compatibility: NetworkEnforcementCompatibility, ingress_default: NetworkAction, host_loopback: NetworkAction, ) -> ExecutionRequest { use crate::mxc_common::models::{NetworkEgressPolicy, NetworkIngressPolicy}; let mut request = base_request(); - request.network_enforcement_compatibility = compatibility; request.policy.network_egress = Some(NetworkEgressPolicy::default()); request.policy.network_ingress = Some(NetworkIngressPolicy { default: ingress_default, @@ -1670,11 +786,7 @@ mod tests { /// list to forward one, so an inbound-accepting posture cannot be honored. #[test] fn an_inbound_accepting_directional_posture_is_refused() { - let request = directional_request( - NetworkEnforcementCompatibility::Strict, - NetworkAction::Allow, - NetworkAction::Deny, - ); + let request = directional_request(NetworkAction::Allow, NetworkAction::Deny); assert_eq!( directional_network_rejection(&request), Some(BWRAP_INGRESS_DEFAULT_ALLOW) @@ -1684,11 +796,7 @@ mod tests { /// The sandbox's loopback is its own namespace's, not the host's. #[test] fn a_host_loopback_accepting_posture_is_refused() { - let request = directional_request( - NetworkEnforcementCompatibility::Strict, - NetworkAction::Deny, - NetworkAction::Allow, - ); + let request = directional_request(NetworkAction::Deny, NetworkAction::Allow); assert_eq!( directional_network_rejection(&request), Some(BWRAP_HOST_LOOPBACK_ALLOW) @@ -1699,70 +807,43 @@ mod tests { /// just refusing every directional config. #[test] fn a_fully_denied_directional_posture_is_accepted() { - let request = directional_request( - NetworkEnforcementCompatibility::Strict, - NetworkAction::Deny, - NetworkAction::Deny, - ); + let request = directional_request(NetworkAction::Deny, NetworkAction::Deny); assert_eq!(directional_network_rejection(&request), None); } - /// A programmatic request with no directional sections does not reach - /// the directional gate. Exact v0.9 JSON cannot supply these legacy fields. #[test] - fn a_programmatic_request_without_directional_fields_bypasses_the_directional_gate() { + fn directly_constructed_retired_network_fields_are_refused() { let mut request = base_request(); - request.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - request.policy.default_network_policy = NetworkPolicy::Allow; - request.policy.allowed_hosts = vec!["10.0.0.1".into()]; - assert_eq!(directional_network_rejection(&request), None); + let mut cases: [fn(&mut ExecutionRequest); 5] = [ + |r| r.policy.default_network_policy = NetworkPolicy::Allow, + |r| r.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall, + |r| r.policy.allow_local_network = true, + |r| r.policy.allowed_hosts.push("192.0.2.1".into()), + |r| r.policy.blocked_hosts.push("192.0.2.2".into()), + ]; + for set_field in &mut cases { + set_field(&mut request); + assert!(retired_network_fields_rejection(&request).is_some()); + request = base_request(); + } + assert!(retired_network_fields_rejection(&request).is_none()); } - /// Pin the strict mode resolver's namespace choice against explicit - /// expectations. These are programmatic policies, not v0.9 JSON: the - /// runner separately rejects combinations it cannot enforce. #[test] - fn strict_programmatic_network_modes_use_the_expected_namespace_across_the_matrix() { - for proxy in [false, true] { - for mode in [ - NetworkEnforcementMode::Capabilities, - NetworkEnforcementMode::Firewall, - NetworkEnforcementMode::Both, - ] { - for policy in [NetworkPolicy::Allow, NetworkPolicy::Block] { - for hosts in [false, true] { - let mut r = base_request(); - r.network_enforcement_compatibility = - NetworkEnforcementCompatibility::Strict; - r.policy.network_proxy.builtin_test_server = proxy; - r.policy.network_enforcement_mode = mode.clone(); - r.policy.default_network_policy = policy.clone(); - r.policy.allowed_hosts = if hosts { - vec!["10.0.0.1".into()] - } else { - vec![] - }; - - // A proxy or enforceable firewall rule needs a private - // namespace; ruleless block is isolated without slirp. - let uses_firewall = matches!( - mode, - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both - ); - let expected = proxy - || (uses_firewall && hosts) - || (policy == NetworkPolicy::Block && !hosts); - - let resolved = ResolvedNetworkMode::from_request(&r, proxy); - assert_eq!( - resolved.uses_private_netns(), - expected, - "proxy={proxy} mode={mode:?} policy={policy:?} hosts={hosts}" - ); - } - } - } - } + fn only_external_runtime_proxies_are_accepted() { + let mut request = directional_egress_request(NetworkAction::Deny, false); + request.policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".into(), 3128)); + assert!(proxy_with_egress_rejection(&request).is_none()); + request.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }); + assert_eq!( + proxy_with_egress_rejection(&request), + Some(BWRAP_PROXY_DIRECTIONAL_EGRESS) + ); + request.policy.network_proxy.builtin_test_server = true; + assert!(builtin_proxy_rejection(&request).is_some()); } #[test] @@ -2028,9 +1109,7 @@ mod tests { #[test] fn proxy_active_uses_private_network_and_external_user_namespace() { - let mut r = base_request(); - r.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - r.policy.default_network_policy = NetworkPolicy::Block; + let r = base_request(); let addr = ProxyAddress::new("127.0.0.1".into(), 12345); let args = build_args(&r, Some(&addr)); assert!( @@ -2074,7 +1153,7 @@ mod tests { fn proxy_active_does_not_exempt_loopback_via_no_proxy() { // Setting NO_PROXY=localhost,127.0.0.1 would let cooperating HTTP // clients bypass the proxy for host-loopback destinations. - // Any bypass would silently defeat allowedHosts/blockedHosts. + // Any bypass would silently defeat externally managed proxy policy. let r = base_request(); let addr = ProxyAddress::new("127.0.0.1".into(), 7777); let args = build_args(&r, Some(&addr)); diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index 0bd358c32..eb1de2274 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -9,21 +9,9 @@ //! arguments via [`crate::bwrap_common::bwrap_command::build_args`], then spawns `bwrap` //! with stdout/stderr capture and optional timeout enforcement. //! -//! For per-host network filtering (`allowedHosts`/`blockedHosts`) the runner -//! supports two paths: -//! - **Cooperative env-var proxy** (default, no privilege required): when -//! `network.proxy` is configured the runner launches an unprivileged HTTP -//! proxy via [`crate::mxc_common::unix_proxy_coordinator::UnixProxyCoordinator`] -//! and the command builder injects `HTTP_PROXY` / `HTTPS_PROXY` / -//! `NO_PROXY` env vars into the sandbox. -//! - **iptables firewall** (requires `CAP_NET_ADMIN` / root): when -//! `network.enforcementMode` is `firewall` or `both`, the runner reuses -//! [`crate::lxc_common::network_iptables::NetworkIptablesManager`] from the LXC -//! backend. -//! -//! When only `defaultPolicy: "block"` is set (no host lists and no proxy), -//! the runner uses `--unshare-net` for zero-overhead full isolation -//! without root. +//! Directional network rules are programmed inside a private namespace; +//! ruleless deny uses `--unshare-net` without additional network tooling. +//! A runtime proxy uses the private namespace and a caller-managed endpoint. use std::collections::HashSet; use std::fmt::Write as FmtWrite; @@ -34,7 +22,6 @@ use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; -use crate::lxc_common::network_iptables::{EgressHookPoint, NetworkIptablesManager}; use crate::mxc_common::interruptible_reader::{wrap_pipe, InterruptibleReader, ReadCanceller}; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ExecutionRequest, ScriptResponse}; @@ -68,7 +55,7 @@ impl BubblewrapScriptRunner { } impl SandboxBackend for BubblewrapScriptRunner { - /// Bubblewrap programs the directional 0.8 posture into iptables chains in + /// Bubblewrap programs directional policy into iptables chains in /// the sandbox's own network namespace, so it enforces the outbound default, /// the allow/deny rules, and both inbound fields. /// @@ -78,13 +65,9 @@ impl SandboxBackend for BubblewrapScriptRunner { /// `bwrap_command::directional_network_rejection` below. Declaring them /// without that refusal would be a fail-open, so the two belong together. /// - /// `RUNTIME_PROXY` is declared because the parser normalizes - /// `runtimeConfig.networkProxy` into the same `policy.network_proxy` the - /// legacy `network.proxy` field feeds, pinned to loopback, and only under - /// the `egress.default='deny'` with no direct rules posture. That is - /// exactly `ResolvedNetworkMode::ProxyOnly`, which this backend already - /// enforces via the proxy's own private-namespace egress chain, so the 0.8 - /// spelling reaches the identical enforcement as the 0.7 one. + /// `RUNTIME_PROXY` is declared because `runtimeConfig.networkProxy` is + /// normalized into `policy.network_proxy` and enforced in a private + /// namespace with only its pinned endpoint reachable. /// /// `PROXY_PEER_IDENTITY` stays undeclared: it is a ProcessContainer concept /// with no Bubblewrap equivalent, so shared validation refuses it here. @@ -196,6 +179,12 @@ impl BubblewrapScriptRunner { where F: FnOnce() -> Result, { + if let Some(reason) = bwrap_command::retired_network_fields_rejection(request) { + return Err(ScriptResponse::error(reason)); + } + if let Some(reason) = bwrap_command::builtin_proxy_rejection(request) { + return Err(ScriptResponse::error(reason)); + } validate_network_policy_support(request, self.network_policy_support())?; // User-input validation runs before the environmental `bwrap` @@ -207,10 +196,6 @@ impl BubblewrapScriptRunner { )); } - // `network.proxy.builtinTestServer` is gated centrally in - // `validate_common` (ahead of every `ScriptRunner::run`), so no - // backend-local check is needed here. - // Refuse a credential-bearing proxy URL here as well as at parse time. // The parser guard only covers requests it built; `ExecutionRequest` // and `ProxyAddress::from_url` are public, so a caller can hand this @@ -235,7 +220,7 @@ impl BubblewrapScriptRunner { // Built from the redacted form so the rejection cannot become // the leak it is rejecting. return Err(ScriptResponse::error(&format!( - "Bubblewrap: network.proxy.url must not carry credentials ('{}'). \ + "Bubblewrap: runtimeConfig.networkProxy must not carry credentials ('{}'). \ Bubblewrap passes the proxy URL to bwrap as a --setenv command-line \ argument, and process arguments are world-readable through \ /proc//cmdline, so the password would be visible to every local \ @@ -247,33 +232,9 @@ impl BubblewrapScriptRunner { } } - // Schema 0.8+ fails closed on a network element Bubblewrap cannot - // honor, rather than running more permissive than requested. Pre-0.8 - // keeps the warning, so existing configs are unaffected. Sits with the - // input checks so a host without bwrap is still told what is wrong. - // - // This is the only layer that checks what Bubblewrap can enforce: the - // parser validates structure, and it only sees JSON configs, while a - // Rust caller can build an `ExecutionRequest` and reach the runner - // directly. (The two proxy cases below are the exception — the parser - // has rejected those combinations since before this backend gained its - // own validation, so both layers refuse them.) - // - // Order matches the parser's: a proxy with an explicit firewall mode is - // also caught by the external-proxy check below (a default policy of - // `Block` alone satisfies it), so checking that first would hand the - // caller a different message than the parser gives for the same - // request. - if let Some(reason) = bwrap_command::proxy_with_firewall_rejection(request) { - return Err(ScriptResponse::error(reason)); - } - if let Some(reason) = bwrap_command::external_proxy_host_rules_rejection(request) { - return Err(ScriptResponse::error(reason)); - } - if let Some(reason) = bwrap_command::unenforced_host_rules_rejection(request) { - return Err(ScriptResponse::error(reason)); - } - if let Some(reason) = bwrap_command::local_network_rejection(request) { + // Programmatic requests also need the parser's proxy-only restriction: + // direct egress must not be accepted and then discarded at launch. + if let Some(reason) = bwrap_command::proxy_with_egress_rejection(request) { return Err(ScriptResponse::error(reason)); } if let Some(reason) = bwrap_command::directional_network_rejection(request) { @@ -293,13 +254,10 @@ impl BubblewrapScriptRunner { // would either resolve twice or pin an address the egress chain never // opened. // - // The builtin test server has no address until it is started (the - // parser leaves a port-0 placeholder), so its endpoint stays on the - // runtime check in `run`; this only covers an operator-supplied proxy. let proxy_only = ResolvedNetworkMode::from_request(request, request.policy.network_proxy.is_enabled()) == ResolvedNetworkMode::ProxyOnly; - if proxy_only && !request.policy.network_proxy.builtin_test_server { + if proxy_only { if let Some(address) = request.policy.network_proxy.address.as_ref() { if let Err(error) = proxy_network::SandboxProxy::check_without_resolving(address) { return Err(ScriptResponse::error(&error)); @@ -313,13 +271,8 @@ impl BubblewrapScriptRunner { } } - // Firewall enforcement builds its chain from the policy's host lists, - // and every rule address must be an IP literal or CIDR: the sandbox - // resolves DNS itself, so a name can be mapped to an address the chain - // never authorized and is therefore unenforceable. Rejecting here - // rather than in the parser covers both callers -- `validate` runs - // ahead of every `spawn`, while a programmatic `mxc_engine` caller - // never passes through the parser at all. + // Validate direct CIDR/port rules before any environment probe or + // provisioning; reuse precisely this plan at spawn. let firewall_enforced = ResolvedNetworkMode::from_request(request, request.policy.network_proxy.is_enabled()) == ResolvedNetworkMode::FirewallEnforced; @@ -340,8 +293,7 @@ impl BubblewrapScriptRunner { } if proxy_only || firewall_enforced { // Proxy and firewall are mutually exclusive, so this names the one - // the caller actually asked for; the probe's advice is worded from - // it rather than always naming network.proxy. + // the caller actually asked for. let use_case = if proxy_only { proxy_network::PrivateNetworkUse::ProxyOnlyEgress } else { @@ -368,8 +320,7 @@ fn check_pin_against_denied_hosts(request: &ExecutionRequest) -> Result<(), Stri let proxy_only = ResolvedNetworkMode::from_request(request, request.policy.network_proxy.is_enabled()) == ResolvedNetworkMode::ProxyOnly; - // The builtin test server is never a hostname, so it is never pinned. - if !proxy_only || request.policy.network_proxy.builtin_test_server { + if !proxy_only { return Ok(()); } match request.policy.network_proxy.address.as_ref() { @@ -400,7 +351,7 @@ fn warn_unreachable_v6_targets(plan: &network_rules::EgressPlan, logger: &mut Lo namespace has no IPv6 connectivity: slirp4netns is launched without \ '--enable-ipv6', so these rules install and are never traversed. The \ destination stays unreachable despite the rule. Use an IPv4 address, or \ - network.proxy, if the workload needs to reach it.", + runtimeConfig.networkProxy, if the workload needs to reach it.", targets.len(), targets.join(", ") )); @@ -428,9 +379,9 @@ impl BubblewrapScriptRunner { if let Err(err) = proxy.start( &request.policy.network_proxy, "127.0.0.1", - &request.policy.allowed_hosts, - &request.policy.blocked_hosts, - request.policy.default_network_policy.clone(), + &[], + &[], + crate::mxc_common::models::NetworkPolicy::Block, logger, ) { return Err(ScriptResponse::error(&format!( @@ -484,10 +435,7 @@ impl BubblewrapScriptRunner { } } } - // Firewall enforcement without a proxy: the same private namespace, - // programmed from the policy's host lists instead of a single - // endpoint. There is no hostname to pin because rule addresses are - // literals and CIDRs only. + // Direct directional egress uses the same private namespace. None if network_mode == ResolvedNetworkMode::FirewallEnforced => { // Reuse the validated plan. It is derived from network policy // only, which the filesystem normalization between the two @@ -523,11 +471,6 @@ impl BubblewrapScriptRunner { // 2. Build the bwrap argument vector. `denied_files` is the file-mask // subset classified during symlink resolution (see // [`resolve_denied_paths`]). - if let Some(warning) = - bwrap_command::local_network_diagnostic_for_mode(request, network_mode) - { - let _ = writeln!(logger, "WARNING: {}", warning); - } let mut args = bwrap_command::build_args_classified_with_mode( request, proxy_address, @@ -551,44 +494,7 @@ impl BubblewrapScriptRunner { args.len() ); - // 3. Determine whether the host-side firewall manager is needed. Proxy - // mode does not use it: it programs its own rules inside the - // sandbox's network namespace instead (see proxy_network). - let needs_iptables = network_mode.requires_host_firewall_manager(); - let container_name = if request.container_id.is_empty() { - format!("bwrap-{:08x}", std::process::id()) - } else { - request.container_id.clone() - }; - - let fw_manager = if needs_iptables { - let _ = writeln!( - logger, - "Bubblewrap: applying iptables rules for host-level network filtering" - ); - let mut mgr = build_firewall_manager(&container_name); - match mgr.apply_firewall_rules(&request.policy, logger) { - Ok(true) => {} - Ok(false) => { - proxy.stop(logger); - return Err(ScriptResponse::error( - "Bubblewrap: failed to apply iptables firewall rules.", - )); - } - Err(e) => { - proxy.stop(logger); - return Err(ScriptResponse::error(&format!( - "Bubblewrap: network policy error: {}", - e - ))); - } - } - Some(mgr) - } else { - None - }; - - // 4. Spawn `bwrap`. + // 3. Spawn `bwrap`. let mut command = Command::new("bwrap"); command.args(&args); let pty = match stdio { @@ -620,8 +526,6 @@ impl BubblewrapScriptRunner { ) { Ok(pty) => Some(pty), Err(error) => { - let mut fw_manager = fw_manager; - cleanup_iptables(&mut fw_manager, logger); stop_proxy_network(&mut proxy_network, logger); proxy.stop(logger); return Err(ScriptResponse::error(&format!( @@ -630,13 +534,10 @@ impl BubblewrapScriptRunner { } }, }; - // Pipes mode: put bwrap in its own process group so a timeout / `kill()` - // can tree-kill it with a single `killpg` without touching the host's - // group. Inherit mode keeps bwrap in the executor's group (so it retains - // the controlling terminal and can't be SIGTTIN-stopped reading it); - // there, killing bwrap relies on `--die-with-parent` to take the - // sandbox down, since bwrap forks and is not itself PID 1 of the new - // pid namespace. + // Pipes and PTY modes put bwrap in its own process group so a timeout + // or `kill()` can tree-kill it without touching the host's group. + // Inherit mode keeps bwrap in the executor's group (so it retains the + // controlling terminal); `--die-with-parent` takes its sandbox down. let group = stdio != StdioMode::Inherit; if stdio == StdioMode::Pipes { command.process_group(0); @@ -648,8 +549,6 @@ impl BubblewrapScriptRunner { let mut child = match command.spawn() { Ok(process) => process, Err(error) => { - let mut fw_manager = fw_manager; - cleanup_iptables(&mut fw_manager, logger); stop_proxy_network(&mut proxy_network, logger); proxy.stop(logger); return Err(ScriptResponse::error(&format!( @@ -677,8 +576,6 @@ impl BubblewrapScriptRunner { if let Err(error) = startup_result { let _ = child.kill(); let _ = child.wait(); - let mut fw_manager = fw_manager; - cleanup_iptables(&mut fw_manager, logger); stop_proxy_network(&mut proxy_network, logger); proxy.stop(logger); return Err(ScriptResponse::error(&error)); @@ -702,8 +599,6 @@ impl BubblewrapScriptRunner { (out_result, err_result) => { let _ = child.kill(); let _ = child.wait(); - let mut fw_manager = fw_manager; - cleanup_iptables(&mut fw_manager, logger); stop_proxy_network(&mut proxy_network, logger); proxy.stop(logger); let error = out_result.err().or(err_result.err()); @@ -739,7 +634,6 @@ impl BubblewrapScriptRunner { group, proxy, proxy_network, - fw_manager, monitor, timeout, started, @@ -750,7 +644,7 @@ impl BubblewrapScriptRunner { } /// A spawned `bwrap` sandbox: the child process, its parent-side pipe ends, -/// and the per-run network proxy / iptables state torn down once it exits. +/// and the per-run private network state torn down once it exits. struct BwrapChild { /// The lock is what keeps [`ProviderMonitor`]'s thread from signalling a /// pid this side has already reaped. @@ -769,7 +663,6 @@ struct BwrapChild { group: bool, proxy: UnixProxyCoordinator, proxy_network: Option, - fw_manager: Option, monitor: Option, timeout: Option, started: Instant, @@ -782,14 +675,12 @@ impl BwrapChild { self.child.lock().unwrap_or_else(PoisonError::into_inner) } - /// Tear down per-run network state (iptables rules + proxy). Idempotent at - /// the manager level. + /// Tear down per-run proxy and private network state. fn cleanup(&mut self, logger: &mut Logger) { // Stopping the supervisor closes the descriptor the monitor watches, so // disarming first is what keeps a normal teardown from reading as a // provider that died. self.monitor.take(); - cleanup_iptables(&mut self.fw_manager, logger); if let Some(mut network) = self.proxy_network.take() { network.stop(logger); } @@ -1175,26 +1066,6 @@ impl Drop for BubblewrapSandboxProcess { } } -/// Build the iptables manager for a Bubblewrap sandbox. -/// -/// Unprivileged bwrap has no container network namespace MXC can enforce in: -/// the sandbox either shares the host's, where a chain would filter the host -/// itself, or holds a private one bwrap created and MXC does not manage (see -/// `local_network_diagnostic` in `bwrap_command`). The chain is built and -/// never hooked, which leaves this backend's egress policy unenforced. -fn build_firewall_manager(container_name: &str) -> NetworkIptablesManager { - NetworkIptablesManager::new(container_name, EgressHookPoint::Unhooked) -} - -/// Best-effort iptables cleanup. Called on both success and error paths. -fn cleanup_iptables(manager: &mut Option, logger: &mut Logger) { - if let Some(ref mut mgr) = manager { - if mgr.rules_applied() { - let _ = mgr.remove_firewall_rules(logger); - } - } -} - /// Tear down the proxy network namespace against the caller's logger. /// /// `Drop` would also stop it, but only through a throwaway in-memory logger, so @@ -1336,9 +1207,7 @@ fn resolve_through_symlinks(path: &Path) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::{ - NetworkEnforcementCompatibility, NetworkEnforcementMode, ProxyAddress, ProxyConfig, - }; + use crate::mxc_common::models::{ProxyAddress, ProxyConfig}; fn base_request() -> ExecutionRequest { ExecutionRequest { @@ -1389,7 +1258,6 @@ mod tests { (NetworkAction::Deny, NetworkAction::Allow), ] { let mut request = base_request(); - request.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; request.policy.network_egress = Some(NetworkEgressPolicy::default()); request.policy.network_ingress = Some(NetworkIngressPolicy { default, @@ -1451,7 +1319,6 @@ mod tests { // produces (`apply_directional_network` fills them in together). fn directional() -> ExecutionRequest { let mut request = base_request(); - request.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; request.policy.network_mode_specified = true; request.policy.network_egress = Some(NetworkEgressPolicy::default()); request.policy.network_ingress = Some(NetworkIngressPolicy::default()); @@ -1558,9 +1425,7 @@ mod tests { // The claim the bit makes is that a `runtimeConfig.networkProxy` // lands on the proxy machinery this backend already enforces: // the env injection, and no refusal on the way there. - if crate::bwrap_common::bwrap_command::external_proxy_host_rules_rejection(request) - .is_some() - { + if crate::bwrap_common::bwrap_command::proxy_with_egress_rejection(request).is_some() { return Err( "the runtime-proxy shape is refused before it reaches the proxy".into(), ); @@ -1615,9 +1480,8 @@ mod tests { Some(host_loopback), ), ( - // Declared: the parser normalizes `runtimeConfig.networkProxy` - // into the same `policy.network_proxy` the legacy field feeds, - // so it lands on machinery this backend already enforces. + // The normalized runtime proxy reaches the private-namespace + // proxy-only enforcement path. NetworkPolicySupport::RUNTIME_PROXY, true, { @@ -1654,7 +1518,7 @@ mod tests { // A bit added to `ALL` but not categorized above fails here. let categorized = decisions .iter() - .fold(NetworkPolicySupport::LEGACY, |acc, (bit, ..)| acc | *bit); + .fold(NetworkPolicySupport::default(), |acc, (bit, ..)| acc | *bit); assert!( categorized.contains(NetworkPolicySupport::ALL) && NetworkPolicySupport::ALL.contains(categorized), @@ -1707,26 +1571,22 @@ mod tests { } } - #[test] - fn the_firewall_manager_this_backend_builds_has_nowhere_to_enforce() { - // Unprivileged bwrap leaves MXC no container namespace to hook, so the - // manager must say so at construction. A manager that claimed a - // namespace would hook an OUTPUT chain in whatever namespace the - // process happens to be in -- the host's -- and filter the host itself. - let mgr = build_firewall_manager("bwrap-cov"); - - assert!( - !mgr.is_hooked(), - "Bubblewrap has no namespace to enforce in, so the manager it builds \ - must not claim one" - ); - } - #[test] fn an_ipv6_allow_warns_that_it_cannot_carry_traffic() { + use crate::mxc_common::models::{NetworkEgressPolicy, NetworkPeer, NetworkRule}; + + let allow = |cidr: &str| NetworkRule { + to: vec![NetworkPeer { + cidr: cidr.parse().expect("test CIDR"), + except: vec![], + }], + ports: vec![], + }; let mut req = base_request(); - req.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Block; - req.policy.allowed_hosts = vec!["2001:db8::1".into(), "203.0.113.5".into()]; + req.policy.network_egress = Some(NetworkEgressPolicy { + allow: vec![allow("2001:db8::1/128"), allow("203.0.113.5/32")], + ..Default::default() + }); let plan = network_rules::EgressPlan::for_request(&req).expect("both literals are enforceable"); @@ -1734,7 +1594,7 @@ mod tests { warn_unreachable_v6_targets(&plan, &mut logger); let out = logger.warnings().join("\n"); assert!( - out.contains("2001:db8::1"), + out.contains("2001:db8::1/128"), "must name the v6 target: {out}" ); assert!( @@ -1751,8 +1611,10 @@ mod tests { // Nothing unreachable, nothing to say. let mut v4_only = base_request(); - v4_only.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Block; - v4_only.policy.allowed_hosts = vec!["203.0.113.5".into()]; + v4_only.policy.network_egress = Some(NetworkEgressPolicy { + allow: vec![allow("203.0.113.5/32")], + ..Default::default() + }); let plan = network_rules::EgressPlan::for_request(&v4_only).expect("a v4 literal is enforceable"); let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); @@ -1760,60 +1622,6 @@ mod tests { assert!(logger.warnings().is_empty(), "no v6 allow, no warning"); } - #[test] - fn validate_reports_the_proxy_firewall_conflict_ahead_of_the_external_proxy_gate() { - // A bare programmatic request trips both gates: the default policy is - // `Block`, which on its own satisfies the external-proxy check. The - // parser reports the proxy/firewall conflict first, so the runner must - // too, or the two layers name different problems for one request. - let mut req = base_request(); - req.policy.network_proxy = ProxyConfig { - address: Some(ProxyAddress::new("127.0.0.1".into(), 3128)), - builtin_test_server: false, - }; - req.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; - - // Both gates must fire, or this asserts nothing about ordering. - assert!(bwrap_command::external_proxy_host_rules_rejection(&req).is_some()); - assert!(bwrap_command::proxy_with_firewall_rejection(&req).is_some()); - - let runner = BubblewrapScriptRunner::new(); - let err = runner - .validate(&req) - .expect_err("the combination is refused"); - assert!( - err.error_message - .contains(bwrap_command::BWRAP_PROXY_WITH_FIREWALL), - "expected the parser's proxy/firewall message, got: {}", - err.error_message - ); - } - - #[test] - fn validate_does_not_locally_gate_builtin_test_server() { - use crate::mxc_common::models::NetworkEgressPolicy; - - // The builtinTestServer gate moved to `crate::mxc_common::validator::validate_common` - // (enforced centrally for every backend). The bwrap runner must therefore no - // longer reject it locally. Reaching the injected environment probe - // proves this without depending on private-network tools on the host. - let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - req.policy.network_egress = Some(NetworkEgressPolicy::default()); - req.policy.network_proxy = ProxyConfig { - address: None, - builtin_test_server: true, - }; - req.testing_features_enabled = false; - - let unavailable = bwrap_version::BwrapUnavailable::NotFound; - let expected = unavailable.to_string(); - let err = BubblewrapScriptRunner::new() - .validate_prepared_with_probe(&req, || Err(unavailable)) - .unwrap_err(); - assert_eq!(err.error_message, expected); - } - /// Proxy-only mode rewrites the endpoint to slirp's gateway and opens /// exactly that address, so an endpoint neither step can express has to be /// refused at policy time -- before a proxy is started. @@ -1822,7 +1630,6 @@ mod tests { use crate::mxc_common::models::NetworkEgressPolicy; let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; req.policy.network_egress = Some(NetworkEgressPolicy::default()); req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("[::1]".into(), 3128)), @@ -1869,23 +1676,16 @@ mod tests { ]; for egress in unhonorable { - for builtin in [false, true] { - let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - req.policy.network_egress = Some(egress.clone()); - req.policy.network_proxy = ProxyConfig { - address: (!builtin).then(|| ProxyAddress::new("127.0.0.1".into(), 3128)), - builtin_test_server: builtin, - }; - - let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); - assert_eq!( - err.error_message, - bwrap_command::BWRAP_PROXY_DIRECTIONAL_EGRESS, - "validate must refuse a proxy that would drop directional rules \ - (builtin={builtin})" - ); - } + let mut req = base_request(); + req.policy.network_egress = Some(egress); + req.policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".into(), 3128)); + let err = BubblewrapScriptRunner::new() + .validate_prepared_with_probe(&req, || panic!("policy must fail before probe")) + .unwrap_err(); + assert_eq!( + err.error_message, + bwrap_command::BWRAP_PROXY_DIRECTIONAL_EGRESS + ); } } @@ -1896,7 +1696,6 @@ mod tests { use crate::mxc_common::models::{NetworkAction, NetworkEgressPolicy}; let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; req.policy.network_egress = Some(NetworkEgressPolicy { default: NetworkAction::Deny, ..Default::default() @@ -1921,7 +1720,6 @@ mod tests { // The egress rules are IPv4-only, so this endpoint could never be // opened -- `run` would discover that only after starting slirp. let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; req.policy.network_egress = Some(NetworkEgressPolicy::default()); req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("2001:db8::1".into(), 3128)), @@ -1942,33 +1740,6 @@ mod tests { ); } - /// The parser leaves a port-0 placeholder address on a `builtinTestServer` - /// request; the real endpoint is only known once the server is started. The - /// endpoint check must therefore skip it, or every builtin-proxy sandbox is - /// rejected at validation with "requires a non-zero proxy port". - #[test] - fn validate_does_not_apply_the_endpoint_check_to_the_builtin_test_server() { - let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - req.policy.network_proxy = ProxyConfig { - address: Some(ProxyAddress::new("127.0.0.1".into(), 0)), - builtin_test_server: true, - }; - - let runner = BubblewrapScriptRunner::new(); - let message = runner - .validate(&req) - .err() - .map(|err| err.error_message) - .unwrap_or_default(); - - assert!( - !message.contains("non-zero proxy port"), - "the builtin proxy's placeholder port must not be validated as an \ - operator-supplied endpoint: {message}" - ); - } - /// A hostname endpoint is reached by pinning it over `/etc/hosts`, and the /// pin outranks every policy mount -- so a policy that denies the file must /// refuse the combination rather than silently hand the file back. @@ -1977,7 +1748,6 @@ mod tests { use crate::mxc_common::models::NetworkEgressPolicy; let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; req.policy.network_egress = Some(NetworkEgressPolicy::default()); req.policy.denied_paths = vec!["/etc/hosts".into()]; req.policy.network_proxy = ProxyConfig { @@ -2007,7 +1777,6 @@ mod tests { #[test] fn a_dotdot_spelling_of_a_denied_hosts_file_still_refuses_the_pin() { let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("proxy.example.com".into(), 3128)), builtin_test_server: false, @@ -2036,7 +1805,6 @@ mod tests { use crate::mxc_common::models::NetworkEgressPolicy; let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; req.policy.network_egress = Some(NetworkEgressPolicy::default()); req.policy.denied_paths = vec!["/etc/hosts".into()]; req.policy.network_proxy = ProxyConfig { @@ -2052,39 +1820,51 @@ mod tests { assert_eq!(err.error_message, expected); } - /// A rule address the backend cannot enforce must be refused, not silently - /// approximated. `validate` is the enforcement point rather than the parser - /// because it runs ahead of every spawn, including the programmatic - /// `mxc_engine` path that never sees the parser. + /// Direct callers can construct a retired policy even though exact + /// contracts cannot. Refuse every non-default legacy field before probing. #[test] - fn validate_rejects_a_programmatic_hostname_firewall_rule_under_strict_policy() { - let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - req.policy.network_enforcement_mode = - crate::mxc_common::models::NetworkEnforcementMode::Firewall; - req.policy.allowed_hosts = vec!["api.github.com".into()]; - - let message = BubblewrapScriptRunner::new() - .validate_prepared_with_probe(&req, || { - panic!("a hostname firewall rule must be rejected before probing bwrap") - }) - .unwrap_err() - .error_message; - - assert!( - message.contains("api.github.com") && message.contains("not an IP address or CIDR"), - "the rejection must name the offending entry and the reason: {message}" - ); + fn validate_rejects_retired_fields_before_the_environment_probe() { + let setters: [fn(&mut ExecutionRequest); 5] = [ + |r| r.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Allow, + |r| { + r.policy.network_enforcement_mode = + crate::mxc_common::models::NetworkEnforcementMode::Firewall + }, + |r| r.policy.allow_local_network = true, + |r| r.policy.allowed_hosts.push("203.0.113.7".into()), + |r| r.policy.blocked_hosts.push("203.0.113.8".into()), + ]; + for set_field in setters { + let mut req = base_request(); + set_field(&mut req); + let err = BubblewrapScriptRunner::new() + .validate_prepared_with_probe(&req, || panic!("retired policy reached bwrap probe")) + .unwrap_err(); + assert!( + err.error_message.contains("retired network"), + "{}", + err.error_message + ); + } } #[test] - fn validate_accepts_programmatic_literal_and_cidr_firewall_rules() { + fn validate_accepts_programmatic_directional_cidr_rules() { + use crate::mxc_common::models::{NetworkEgressPolicy, NetworkPeer, NetworkRule}; + let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - req.policy.network_enforcement_mode = - crate::mxc_common::models::NetworkEnforcementMode::Firewall; - req.policy.allowed_hosts = vec!["203.0.113.7".into(), "10.0.0.0/8".into()]; - req.policy.blocked_hosts = vec!["2001:db8::/32".into()]; + let rule = |cidr: &str| NetworkRule { + to: vec![NetworkPeer { + cidr: cidr.parse().expect("test CIDR"), + except: vec![], + }], + ports: vec![], + }; + req.policy.network_egress = Some(NetworkEgressPolicy { + allow: vec![rule("203.0.113.7/32"), rule("10.0.0.0/8")], + deny: vec![rule("2001:db8::/32")], + ..Default::default() + }); let unavailable = bwrap_version::BwrapUnavailable::NotFound; let expected = unavailable.to_string(); @@ -2094,90 +1874,6 @@ mod tests { assert_eq!(err.error_message, expected); } - #[test] - fn validate_rejects_an_unhonorable_local_network_request_under_strict_policy() { - // defaultPolicy='allow' shares the host netns, so allowLocalNetwork - // =false cannot be honored. Runs ahead of the bwrap probe, so this - // holds on hosts without bwrap installed. Host lists are no longer a - // vehicle for this: at 0.8 they resolve to a private namespace under - // either enforcement mechanism. - let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - req.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Allow; - - let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); - assert!( - err.error_message.contains("allowLocalNetwork=false"), - "unexpected error: {}", - err.error_message - ); - } - - #[test] - fn validate_rejects_local_network_under_a_private_netns_with_strict_policy() { - // The other half of the local-network contract, and the one the - // ingress chain depends on: IngressPlan maps allowLocalNetwork=true to - // an inbound NEW ACCEPT, which is only unreachable because validate - // refuses the combination. Without this test, relaxing the rejection - // would silently open inbound rather than fail a build. - let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - req.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; - req.policy.allowed_hosts = vec!["10.0.2.2/32".into()]; - req.policy.allow_local_network = true; - - let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); - assert!( - err.error_message.contains("allowLocalNetwork=true"), - "unexpected error: {}", - err.error_message - ); - } - - #[test] - fn validate_rejects_host_rules_no_mechanism_will_enforce_under_strict_policy() { - // The gap this closes: host lists suppress --unshare-net, but under - // 'capabilities' with no proxy nothing applies them, so a default-deny - // policy ran with fully open egress on the host's namespace. - let mut req = base_request(); - req.network_enforcement_compatibility = NetworkEnforcementCompatibility::Strict; - req.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Block; - req.policy.allowed_hosts = vec!["api.github.com".into()]; - req.policy.allow_local_network = true; - - let err = BubblewrapScriptRunner::new() - .validate_prepared_with_probe(&req, || { - panic!("unenforced host rules must be rejected before probing bwrap") - }) - .unwrap_err(); - assert_eq!( - err.error_message, - bwrap_command::BWRAP_UNENFORCED_HOST_RULES - ); - } - - #[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 = crate::mxc_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, - }; - - 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] fn validate_rejects_empty_script_before_environment_probe() { // Empty script_code is a user-input error and must be surfaced diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/network_rules.rs b/src/mxc-sdk/src/backends/bubblewrap/common/network_rules.rs index d87a736fa..b43ba7242 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/network_rules.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/network_rules.rs @@ -8,24 +8,17 @@ //! *what* to install. It covers both callers: //! //! - proxy-only egress, whose plan is a single accepted endpoint, and -//! - `enforcementMode: "firewall"`, whose plan comes from the policy's host -//! lists. +//! - directional egress, whose plan comes from network.egress and ingress. //! //! # Addresses are IP literals and CIDRs, never names //! -//! The 0.8 networking contract (`docs/sandbox-policy/0.8.0/networking`, D3) -//! makes rule addresses IPv4/IPv6 literals or CIDRs and rejects DNS names at -//! validation time: a name is bypassable and non-deterministic, since the -//! sandbox can resolve it itself and the answer varies by resolver, TTL and -//! cache. Domain-level control is the proxy's job — it inspects the CONNECT -//! host — not the packet filter's. +//! Directional peers name IPv4/IPv6 CIDRs. Domain-level control belongs to +//! the externally managed proxy, not the packet filter. //! //! # The rules file never contains caller text //! //! Every address is re-rendered from its parsed form, so what reaches the -//! supervisor's shell is the canonical output of Rust's IP address types plus -//! an integer prefix. A rejected string cannot reach the script, and an -//! accepted one cannot carry anything but digits, dots, colons and a slash. +//! supervisor's shell is canonical IP address data plus an integer prefix. use std::collections::HashSet; use std::fmt; @@ -33,7 +26,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use crate::mxc_common::models::{ ContainerPolicy, ExecutionRequest, NetworkAction, NetworkCidr, NetworkEgressPolicy, - NetworkPeer, NetworkPolicy, NetworkPort, NetworkProtocol, NetworkRule, + NetworkPeer, NetworkPort, NetworkProtocol, NetworkRule, }; /// Which `iptables` binary carries a rule. @@ -70,8 +63,8 @@ const PAYLOAD_INDEX_MIN_WIDTH: usize = 3; /// so a mis-sort would both reorder first-match rules and land the hooks over a /// half-built chain -- a brief fail-open. /// -/// The width is derived from `total` rather than fixed because the host lists -/// are unbounded: any constant width is a silent correctness cliff one entry +/// The width is derived from `total` rather than fixed because directional +/// rules can expand: any constant width is a silent correctness cliff one entry /// past it (`rules.v4.1000` sorts before `rules.v4.101`). Deriving it means /// lexical order equals numeric order for every count. pub(crate) fn payload_file_name(family: RuleFamily, index: usize, total: usize) -> String { @@ -124,84 +117,6 @@ pub(crate) struct RuleAddress { } impl RuleAddress { - /// Classify one `allowedHosts`/`blockedHosts` entry. - /// - /// # Errors - /// - /// Returns the caller-facing reason when the entry is empty, malformed, or - /// a DNS name, which D3 rejects rather than resolves. - pub(crate) fn parse(entry: &str) -> Result { - let trimmed = entry.trim(); - if trimmed.is_empty() { - return Err("Bubblewrap: an empty network rule address is not a destination".into()); - } - - if let Some((address, prefix)) = trimmed.split_once('/') { - return Self::parse_cidr(trimmed, address, prefix); - } - - match trimmed.parse::() { - Ok(IpAddr::V4(ip)) => Ok(Self { - family: RuleFamily::V4, - text: ip.to_string(), - }), - Ok(IpAddr::V6(ip)) => match ip.to_ipv4_mapped() { - Some(v4) => Ok(Self { - family: RuleFamily::V4, - text: v4.to_string(), - }), - None => Ok(Self { - family: RuleFamily::V6, - text: ip.to_string(), - }), - }, - Err(_) => Err(name_rejected(trimmed)), - } - } - - /// Validate `address/prefix`, keeping the prefix within its family's width. - fn parse_cidr(entry: &str, address: &str, prefix: &str) -> Result { - let parsed = match address.parse::() { - Ok(ip) => ip, - Err(_) => return Err(name_rejected(entry)), - }; - - let width = match parsed { - IpAddr::V4(_) => 32, - IpAddr::V6(_) => 128, - }; - let bits: u8 = prefix.parse().map_err(|_| { - format!( - "Bubblewrap: network rule '{entry}' has a malformed CIDR prefix; expected a \ - number between 0 and {width}." - ) - })?; - if bits > width { - return Err(format!( - "Bubblewrap: network rule '{entry}' has a CIDR prefix wider than its address \ - family allows; expected a number between 0 and {width}." - )); - } - - // The prefix is validated against the family the caller wrote, so a - // mapped block is only rewritten once its own notation checks out. - let (family, canonical, bits) = match parsed { - IpAddr::V4(ip) => (RuleFamily::V4, ip.to_string(), bits), - IpAddr::V6(ip) => match mapped_v4_block(ip, bits) { - Some((v4, v4_bits)) => (RuleFamily::V4, v4.to_string(), v4_bits), - None if covers_ipv4_mapped_range(ip, bits) => { - return Err(straddles_mapped_range(entry)) - } - None => (RuleFamily::V6, ip.to_string(), bits), - }, - }; - - Ok(Self { - family, - text: format!("{canonical}/{bits}"), - }) - } - /// The block matching every address in `family`. /// /// A directional rule with no `to` matches both families ("Omission matches @@ -230,10 +145,8 @@ impl RuleAddress { /// An IPv4-mapped CIDR as its IPv4 equivalent, when it has one. /// /// Linux puts a genuine IPv4 packet on the wire for a mapped destination, so an -/// `ip6tables` rule naming one never matches: under `defaultPolicy: allow` a -/// mapped `blockedHosts` entry would otherwise fail open. LXC normalizes -/// literals and `/96`-or-longer CIDRs the same way, so those policies mean the -/// same thing on both; it does not yet reject the straddling blocks below. +/// `ip6tables` rule naming one never matches: under an open directional +/// default a mapped deny rule would otherwise fail open. /// /// The mapped range is the last 32 bits of `::ffff:0:0/96`, so an IPv6 prefix /// of `96 + n` is exactly an IPv4 prefix of `n`. A shorter prefix also covers @@ -267,8 +180,8 @@ fn covers_ipv4_mapped_range(ip: Ipv6Addr, bits: u8) -> bool { /// The message for an IPv6 block that swallows the IPv4-mapped range. /// /// Such a block cannot be honored as written. Its mapped half travels as IPv4, -/// so an `ip6tables` rule never matches it — under `defaultPolicy: allow` a -/// `blockedHosts` entry would fail open. Projecting it onto IPv4 instead would +/// so an `ip6tables` rule never matches it — an open egress default would +/// fail open. Projecting it onto IPv4 instead would /// silently widen the rule to all of IPv4 (the mapped half of any sub-`/96` /// block is the whole space), turning `::/0` into "and every IPv4 address too". /// Neither reading is safe to guess, so the caller is asked to say which they @@ -283,21 +196,6 @@ fn straddles_mapped_range(entry: &str) -> String { ) } -/// The message for an address that is neither a literal nor a CIDR. -/// -/// Names are called out explicitly: rejecting them is a deliberate contract -/// decision, so the error explains the alternative rather than reading as a -/// parse failure. -fn name_rejected(entry: &str) -> String { - format!( - "Bubblewrap: network rule '{entry}' is not an IP address or CIDR block. Host filtering \ - matches on addresses, not names: a sandbox resolves DNS itself, so a name can be mapped \ - to another address and is not enforceable. Use an IPv4/IPv6 literal or CIDR (for example \ - '203.0.113.4' or '10.0.0.0/8'), or route the traffic through network.proxy, which \ - filters on the requested host." - ) -} - /// A destination block in numeric form, so exclusions can be computed on it. /// /// `base` is always the block's first address, so comparisons are plain integer @@ -363,10 +261,7 @@ impl Block { /// /// Rejects a prefix wider than its address family, and a sub-`/96` IPv6 /// block that swallows the IPv4-mapped range, for the reason - /// [`straddles_mapped_range`] gives. `RuleAddress::parse` makes the same - /// refusals on the legacy string path; both entry points into the renderer - /// must agree, or the directional shape becomes a way to install the rule - /// the legacy shape refuses. + /// [`straddles_mapped_range`] gives. fn from_cidr(cidr: &NetworkCidr) -> Result<(RuleFamily, Self), String> { // Checked against the *source* family, before mapped rewriting: a // `::ffff:0:0/97` is out of range as v6 even though the /33 it rewrites @@ -658,7 +553,7 @@ fn lower_ports(ports: &[NetworkPort]) -> Result, String> { /// Which traffic a rule narrows to, beyond its destination address. /// -/// Both halves are optional because the 0.8 contract makes them optional: +/// Both halves are optional in supported directional contracts: /// `ports` omitted "matches all", and `protocol` defaults to `any`. Absent /// means the match expression is left off entirely rather than guessed, so a /// rule that names no protocol filters none. @@ -707,7 +602,7 @@ impl EgressRule { // ICMP is a different protocol number and a different iptables // match on each family: `ip6tables` rejects `-p icmp` outright, so // carrying the V4 token into the V6 table fails the whole restore - // and installs nothing. The 0.8 contract's `icmp` selector means + // and installs nothing. The directional `icmp` selector means // "ICMP for this destination's family", which is what this renders. Some("icmp") if self.address.family == RuleFamily::V6 => "-p icmpv6 ".to_string(), Some(protocol) => format!("-p {protocol} "), @@ -730,7 +625,7 @@ impl EgressRule { /// /// Ordering is the contract: rules are appended to the chain in the order held /// here, and the terminal verdict closes each family. Denies precede allows so -/// an explicit deny overrides a broader allow, per the 0.8 contract's D4. +/// an explicit deny overrides a broader allow. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EgressPlan { rules: Vec, @@ -743,7 +638,7 @@ pub(crate) struct EgressPlan { /// `nf_tables` applies a restore as a single netlink transaction with a bounded /// message size. Exceeding it fails the *whole* table with `sendmsg() failed: /// Message too long` and installs nothing, so an unbounded transaction would -/// turn a large host list into a sandbox that cannot launch. Measured on +/// turn a large directional rule set into a sandbox that cannot launch. Measured on /// `iptables v1.8.10` the ceiling sits near 20 KiB of payload text, and lower /// per rule for rules carrying more match expressions -- each expression costs /// more on the kernel side than in text, so the limit is a byte ceiling rather @@ -774,32 +669,12 @@ impl EgressPlan { } } - /// The outbound posture for one request, from whichever schema shape it used. - /// - /// Schema 0.8 carries the outbound posture in either the legacy host lists - /// or the directional `network.egress` section, never both, so this selects - /// the authoritative one instead of merging them. Every caller goes through - /// here; [`Self::for_policy`] stays legacy-only so the chain consumers have - /// today cannot drift. - /// - /// `network_egress` is the format discriminator because the parser fills it - /// in only on the directional path (`network_parser::apply_directional_network`) - /// and leaves it `None` on the legacy one. `network_mode_specified` looks like - /// the obvious signal and is *not*: the legacy path sets it too, from - /// `defaultPolicy`/`allowedHosts`/`blockedHosts`, so keying on it would route - /// every legacy firewall config into the directional builder. + /// The supported directional outbound posture. An omitted section denies. /// /// # Errors /// - /// Propagates the selected builder's rejection. + /// Propagates directional rule validation errors. pub(crate) fn for_request(request: &ExecutionRequest) -> Result { - // Either section present means the directional shape. The parser fills - // both in together, but losing the host-loopback drop is silent, so - // this does not depend on that invariant. - if !crate::bwrap_common::bwrap_command::is_directional(&request.policy) { - return Self::for_policy(request); - } - Self::for_directional_policy_with_ingress( &request.policy.network_egress.clone().unwrap_or_default(), // An absent ingress section still denies: `hostLoopback` defaults @@ -872,47 +747,6 @@ impl EgressPlan { }) } - /// The `enforcementMode: "firewall"` posture, from the policy's host lists. - /// - /// `defaultPolicy` sets the terminal verdict for both families, so a family - /// with no rules of its own still inherits the requested posture instead of - /// being silently open or silently closed. - /// - /// # Errors - /// - /// Returns the first address that is not a literal or CIDR. - pub(crate) fn for_policy(request: &ExecutionRequest) -> Result { - let policy = &request.policy; - let terminal = match policy.default_network_policy { - NetworkPolicy::Allow => RuleVerdict::Accept, - NetworkPolicy::Block => RuleVerdict::Drop, - }; - - // Denies first: the chain is evaluated in order and the first match - // wins, so this is what makes an explicit deny beat a broader allow. - let mut rules = Vec::with_capacity(policy.blocked_hosts.len() + policy.allowed_hosts.len()); - for entry in &policy.blocked_hosts { - rules.push(EgressRule { - verdict: RuleVerdict::Drop, - address: RuleAddress::parse(entry)?, - port: PortSpec::ALL, - }); - } - for entry in &policy.allowed_hosts { - rules.push(EgressRule { - verdict: RuleVerdict::Accept, - address: RuleAddress::parse(entry)?, - port: PortSpec::ALL, - }); - } - - Ok(Self { - rules, - v4_terminal: terminal, - v6_terminal: terminal, - }) - } - /// The chain body for one family: the loopback exemption, the family's /// rules in order, and the terminal verdict that closes it. /// @@ -972,7 +806,7 @@ impl EgressPlan { /// /// Bubblewrap's sandbox already has no inbound path -- it runs in a private /// network namespace and slirp is launched without port forwarding -- so this -/// chain is defense in depth plus the shape the 0.8 contract's D6 specifies, +/// chain is defense in depth plus the directional contract's inbound policy, /// rather than new protection. It becomes load-bearing the moment any inbound /// path is configured. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -984,30 +818,13 @@ pub(crate) struct IngressPlan { impl IngressPlan { /// The posture the policy asks for. /// - /// Schema 0.8 carries the inbound posture in either the legacy - /// `allowLocalNetwork` field or the directional `network.ingress` section, - /// never both, so the format selects which one is authoritative rather than - /// the two being merged. The legacy arm is left exactly as it was: - /// consumers still on the legacy shape must keep the chain they have. - /// - /// `network_ingress` is the format discriminator because the parser fills it - /// in only on the directional path and leaves it `None` on the legacy one. - /// `network_mode_specified` is *not* usable here: the legacy path sets it - /// too, from `defaultPolicy`/`allowLocalNetwork`/host lists. - /// - /// Only the deny posture is reachable on the private-namespace path today. - /// `allowLocalNetwork: true` is refused before this point at schema 0.8+ - /// (see `bwrap_command::local_network_diagnostic_for_mode`) and its - /// directional twins `ingress.default: allow` and `ingress.hostLoopback: - /// allow` are refused alongside it, because honoring either needs slirp port - /// forwarding and a port contract neither schema expresses. The mapping is - /// written out in full anyway so the policy lives in one readable place and - /// lifting a rejection is a change at the rejection, not here. + /// Only deny is currently reachable: validation refuses inbound allow + /// before a sandbox is created because slirp has no inbound forwarding. pub(crate) fn for_policy(policy: &ContainerPolicy) -> Self { - let accepts_new_inbound = match policy.network_ingress.as_ref() { - Some(ingress) => ingress.default == NetworkAction::Allow, - None => policy.allow_local_network, - }; + let accepts_new_inbound = policy + .network_ingress + .as_ref() + .is_some_and(|ingress| ingress.default == NetworkAction::Allow); Self { new_inbound: if accepts_new_inbound { @@ -1031,7 +848,7 @@ impl IngressPlan { /// so bypass the `NEW` verdict. Narrow this to `ESTABLISHED` when host port /// forwarding lands and there is an inbound path for that to matter on. /// - /// The terminal verdict is `DROP` regardless of `defaultPolicy`, which + /// The terminal verdict is `DROP` regardless of egress.default, which /// governs egress only -- an open outbound posture must not open inbound. /// /// Both families get the same body. IPv6 needs an RFC 4890 ICMPv6 carve-out @@ -1064,7 +881,7 @@ struct ChainSection { /// /// A policy is split across as many transactions as its size requires, because /// a single restore is one bounded netlink transaction (see -/// [`RESTORE_PAYLOAD_BUDGET`]). Splitting is what keeps a large host list from +/// [`RESTORE_PAYLOAD_BUDGET`]). Splitting is what keeps a large rule set from /// failing the whole table, and it preserves the two properties that matter: /// /// * **A hook is never live over a partially built chain.** Both `-A OUTPUT` / @@ -1186,200 +1003,29 @@ mod tests { assert_eq!(payload_file_name(RuleFamily::V6, 7, 12), "rules.v6.007"); } - fn request(default: NetworkPolicy, allowed: &[&str], blocked: &[&str]) -> ExecutionRequest { + fn request(default: NetworkAction, allow: &[&str], deny: &[&str]) -> ExecutionRequest { + let to_rule = |address: &&str| { + let cidr = if address.contains('/') { + (*address).to_string() + } else { + format!("{address}/{}", if address.contains(':') { 128 } else { 32 }) + }; + rule(vec![peer(&cidr, &[])], Vec::new()) + }; ExecutionRequest { script_code: "echo hello".into(), policy: ContainerPolicy { - default_network_policy: default, - allowed_hosts: allowed.iter().map(|host| host.to_string()).collect(), - blocked_hosts: blocked.iter().map(|host| host.to_string()).collect(), + network_egress: Some(NetworkEgressPolicy { + default, + allow: allow.iter().map(to_rule).collect(), + deny: deny.iter().map(to_rule).collect(), + }), ..Default::default() }, ..Default::default() } } - #[test] - fn an_ipv4_literal_is_accepted_as_a_host_rule() { - let address = RuleAddress::parse("203.0.113.4").expect("a literal is a valid rule address"); - assert_eq!(address.family, RuleFamily::V4); - assert_eq!(address.text, "203.0.113.4"); - } - - #[test] - fn an_ipv6_literal_is_accepted_and_canonicalized() { - let address = RuleAddress::parse("2001:DB8:0:0:0:0:0:1").expect("a v6 literal is valid"); - assert_eq!(address.family, RuleFamily::V6); - // Rendered from the parsed value, so the file never carries the - // caller's spelling. - assert_eq!(address.text, "2001:db8::1"); - } - - #[test] - fn an_ipv4_mapped_literal_is_programmed_as_ipv4() { - // Linux emits a genuine IPv4 packet for a mapped destination, so an - // ip6tables rule naming one never matches: under defaultPolicy 'allow' - // a mapped blockedHosts entry would fail open. - let address = RuleAddress::parse("::ffff:203.0.113.5").expect("a mapped literal is valid"); - assert_eq!(address.family, RuleFamily::V4); - assert_eq!(address.text, "203.0.113.5"); - } - - #[test] - fn an_ipv4_mapped_cidr_is_translated_to_its_ipv4_prefix() { - // The mapped range is the last 32 bits of ::ffff:0:0/96, so a v6 - // prefix of 96 + n is exactly a v4 prefix of n. - let address = RuleAddress::parse("::ffff:192.0.2.0/120").expect("a mapped CIDR is valid"); - assert_eq!(address.family, RuleFamily::V4); - assert_eq!(address.text, "192.0.2.0/24"); - - let host = RuleAddress::parse("::ffff:198.51.100.42/128").expect("a mapped /128 is valid"); - assert_eq!(host.family, RuleFamily::V4); - assert_eq!(host.text, "198.51.100.42/32"); - } - - #[test] - fn a_prefix_shorter_than_the_mapped_range_is_rejected() { - // A /95 covers all of ::ffff:0:0/96, whose members travel as IPv4 - // packets an ip6tables rule never sees. Keeping it on ip6tables would - // silently unenforce the mapped half (a fail-open for a deny rule under - // defaultPolicy 'allow'), and projecting it onto IPv4 would widen it to - // every IPv4 address. Neither is safe to guess, so it is refused. - let error = RuleAddress::parse("::ffff:0:0/95").expect_err("a straddling block is refused"); - assert!( - error.contains("shorter than /96") && error.contains("::ffff:0:0/96"), - "the message should name the mapped range and the boundary: {error}" - ); - } - - #[test] - fn the_whole_ipv6_space_is_rejected_for_the_same_reason() { - // ::/0 is the case most likely to be written by hand, and the one where - // silently projecting onto IPv4 would be most surprising: "block all - // IPv6" would become "and all IPv4 too". - let error = RuleAddress::parse("::/0").expect_err("::/0 contains the mapped range"); - assert!( - error.contains("shorter than /96"), - "unexpected message: {error}" - ); - } - - #[test] - fn a_sub_96_block_clear_of_the_mapped_range_stays_ipv6() { - // The rejection is scoped to blocks that actually contain the mapped - // range. Ordinary IPv6 CIDRs are unaffected, so native IPv6 filtering - // keeps working. - let address = RuleAddress::parse("2001:db8::/32").expect("a native v6 CIDR is valid"); - assert_eq!(address.family, RuleFamily::V6); - assert_eq!(address.text, "2001:db8::/32"); - - // Immediately below the mapped range and disjoint from it: a /95 pairs - // ::fffc/::fffd, while the mapped range sits in the ::fffe/::ffff pair. - let neighbour = RuleAddress::parse("::fffc:0:0/95").expect("a neighbouring block is valid"); - assert_eq!(neighbour.family, RuleFamily::V6); - } - - #[test] - fn the_mapped_range_itself_is_still_translated_not_rejected() { - // Exactly /96 is the boundary: it maps to 0.0.0.0/0 with no ambiguity, - // so it is translated rather than refused. - let address = RuleAddress::parse("::ffff:0:0/96").expect("the mapped range is valid"); - assert_eq!(address.family, RuleFamily::V4); - assert_eq!(address.text, "0.0.0.0/0"); - } - - #[test] - fn a_mapped_prefix_is_validated_against_the_notation_the_caller_wrote() { - // The caller wrote v6, so the width they are held to is 128 — the - // rewrite happens only once their own notation checks out. - let error = RuleAddress::parse("::ffff:192.0.2.0/129") - .expect_err("a prefix past the v6 width is malformed"); - assert!( - error.contains("wider than its address family allows"), - "{error}" - ); - } - - #[test] - fn a_non_mapped_v6_address_is_left_on_ipv6() { - // NAT64 traffic really is emitted as IPv6, so it belongs in ip6tables. - let address = RuleAddress::parse("64:ff9b::/96").expect("a NAT64 block is valid"); - assert_eq!(address.family, RuleFamily::V6); - assert_eq!(address.text, "64:ff9b::/96"); - } - - #[test] - fn a_cidr_block_keeps_its_prefix() { - let address = RuleAddress::parse("10.0.0.0/8").expect("a CIDR is a valid rule address"); - assert_eq!(address.family, RuleFamily::V4); - assert_eq!(address.text, "10.0.0.0/8"); - } - - #[test] - fn the_widest_prefix_of_each_family_is_allowed() { - assert_eq!( - RuleAddress::parse("192.0.2.1/32") - .expect("a /32 is a host route") - .text, - "192.0.2.1/32" - ); - assert_eq!( - RuleAddress::parse("2001:db8::1/128") - .expect("a /128 is a host route") - .text, - "2001:db8::1/128" - ); - } - - #[test] - fn a_prefix_wider_than_the_family_is_rejected() { - let error = RuleAddress::parse("10.0.0.0/33").expect_err("v4 has 32 bits"); - assert!(error.contains("wider than its address family"), "{error}"); - - let error = RuleAddress::parse("2001:db8::/129").expect_err("v6 has 128 bits"); - assert!(error.contains("wider than its address family"), "{error}"); - } - - #[test] - fn a_non_numeric_prefix_is_rejected() { - let error = RuleAddress::parse("10.0.0.0/eight").expect_err("a prefix must be a number"); - assert!(error.contains("malformed CIDR prefix"), "{error}"); - } - - #[test] - fn a_dns_name_is_rejected_with_the_alternatives() { - let error = RuleAddress::parse("api.github.com").expect_err("names are not enforceable"); - assert!(error.contains("not an IP address or CIDR"), "{error}"); - assert!(error.contains("network.proxy"), "{error}"); - } - - #[test] - fn an_empty_entry_is_rejected() { - let error = RuleAddress::parse(" ").expect_err("an empty entry names nothing"); - assert!(error.contains("empty network rule address"), "{error}"); - } - - /// The rules file is interpolated into a shell loop, so anything that could - /// change the record's shape has to be rejected before it gets there. - #[test] - fn shell_and_field_metacharacters_cannot_reach_the_rules_file() { - for hostile in [ - "10.0.0.1; rm -rf /", - "10.0.0.1 -j ACCEPT", - "$(id)", - "10.0.0.1\n4 ACCEPT 0.0.0.0/0 - -", - "`id`", - "10.0.0.1|id", - ] { - let error = RuleAddress::parse(hostile) - .expect_err("only literals and CIDRs may reach the supervisor"); - assert!( - error.contains("not an IP address or CIDR") || error.contains("CIDR prefix"), - "unexpected rejection for {hostile:?}: {error}" - ); - } - } - const EGRESS: &str = "EG"; const INGRESS: &str = "IN"; @@ -1440,20 +1086,26 @@ mod tests { const HOST_LOOPBACK_DROP: &str = "-d 10.0.2.2/32 -j DROP"; #[test] - fn a_block_policy_closes_both_families_and_accepts_its_allowlist() { - let plan = EgressPlan::for_policy(&request(NetworkPolicy::Block, &["10.0.0.0/8"], &[])) + fn a_deny_policy_closes_both_families_and_accepts_its_allow_rules() { + let plan = EgressPlan::for_request(&request(NetworkAction::Deny, &["10.0.0.0/8"], &[])) .expect("a CIDR allowlist is enforceable"); - assert_eq!(v4(&plan), egress(&["-d 10.0.0.0/8 -j ACCEPT"], "DROP")); + assert_eq!( + v4(&plan), + directional_egress(&["-d 10.0.0.0/8 -j ACCEPT"], "DROP") + ); assert_eq!(v6(&plan), egress(&[], "DROP")); } #[test] - fn an_allow_policy_keeps_both_families_open_and_drops_its_denylist() { - let plan = EgressPlan::for_policy(&request(NetworkPolicy::Allow, &[], &["192.0.2.0/24"])) + fn an_allow_policy_keeps_both_families_open_and_drops_its_deny_rules() { + let plan = EgressPlan::for_request(&request(NetworkAction::Allow, &[], &["192.0.2.0/24"])) .expect("a CIDR denylist is enforceable"); - assert_eq!(v4(&plan), egress(&["-d 192.0.2.0/24 -j DROP"], "ACCEPT")); + assert_eq!( + v4(&plan), + directional_egress(&["-d 192.0.2.0/24 -j DROP"], "ACCEPT") + ); assert_eq!(v6(&plan), egress(&[], "ACCEPT")); } @@ -1585,17 +1237,6 @@ mod tests { ); } - /// The two shapes are mutually exclusive, so once the directional section is - /// authoritative a legacy `allowLocalNetwork` left alongside it must not - /// re-open inbound. Merging the two would fail open on exactly this input. - #[test] - fn a_directional_policy_ignores_the_legacy_inbound_field() { - let mut policy = directional_ingress_policy(NetworkAction::Deny, NetworkAction::Deny); - policy.allow_local_network = true; - - assert_eq!(IngressPlan::for_policy(&policy), denied_ingress()); - } - /// `ingress.default: allow` is refused before it reaches the plan, but the /// mapping still carries it so lifting that rejection is a change at the /// rejection rather than a silent no-op here. @@ -1648,34 +1289,6 @@ mod tests { assert_eq!(v6(&plan), egress(&[], "ACCEPT")); } - /// The two shapes are mutually exclusive, so a legacy host list left - /// alongside an authoritative directional section must not be programmed. - /// Merging them would reopen exactly what the directional deny closed. - #[test] - fn a_directional_egress_policy_ignores_the_legacy_host_lists() { - let mut req = directional_egress_request(NetworkAction::Deny); - req.policy.default_network_policy = NetworkPolicy::Allow; - req.policy.allowed_hosts = vec!["10.0.0.0/8".to_string()]; - - let plan = EgressPlan::for_request(&req).expect("the directional posture is enforceable"); - - assert_eq!(v4(&plan), directional_egress(&[], "DROP")); - assert_eq!(v6(&plan), egress(&[], "DROP")); - } - - /// The legacy request keeps reaching the legacy builder through the - /// dispatcher, unchanged. - #[test] - fn a_legacy_request_still_routes_to_the_legacy_builder() { - let req = request(NetworkPolicy::Allow, &[], &["192.0.2.0/24"]); - - let dispatched = EgressPlan::for_request(&req).expect("the legacy denylist is enforceable"); - let direct = EgressPlan::for_policy(&req).expect("the legacy denylist is enforceable"); - - assert_eq!(v4(&dispatched), v4(&direct)); - assert_eq!(v6(&dispatched), v6(&direct)); - } - /// Build a directional request carrying `allow`/`deny` rules. fn directional_rules_request( default: NetworkAction, @@ -2039,15 +1652,8 @@ mod tests { ); } - /// The directional shape must refuse the same straddling block the legacy - /// string path refuses. - /// - /// This was a real bypass: `Block::from_cidr` classified `::/0` as V6 and - /// installed it into `ip6tables` alone. Linux emits IPv4-mapped - /// destinations as IPv4 packets, so under `default: allow` a `deny ::/0` - /// rule — which reads as "deny everything" — never matched IPv4 traffic - /// and failed open. `RuleAddress::parse` had guarded this since before the - /// directional shape existed; the new entry point simply skipped it. + /// A straddling IPv6 block cannot be silently installed only in ip6tables: + /// IPv4-mapped destinations travel as IPv4 and a deny would fail open. #[test] fn a_directional_block_straddling_the_mapped_range_is_refused() { for straddling in ["::/0", "::/64", "::ffff:0:0/95"] { @@ -2248,19 +1854,19 @@ mod tests { ); } - /// The reported failure: a mapped `blockedHosts` entry under - /// `defaultPolicy: allow`. Classified as IPv6 it would be programmed into + /// A mapped directional deny under an open default must be programmed on + /// the family that carries it. Classified as IPv6 it would be programmed into /// `ip6tables`, where it never matches the IPv4 packet Linux actually /// emits, and the v4 chain's terminal ACCEPT would let the traffic through. #[test] - fn a_mapped_denylist_entry_is_programmed_on_the_family_that_carries_it() { + fn a_mapped_deny_rule_is_programmed_on_the_family_that_carries_it() { let plan = - EgressPlan::for_policy(&request(NetworkPolicy::Allow, &[], &["::ffff:192.0.2.5"])) + EgressPlan::for_request(&request(NetworkAction::Allow, &[], &["::ffff:192.0.2.5"])) .expect("a mapped denylist entry is enforceable"); assert_eq!( v4(&plan), - egress(&["-d 192.0.2.5 -j DROP"], "ACCEPT"), + directional_egress(&["-d 192.0.2.5/32 -j DROP"], "ACCEPT"), "the mapped entry must be programmed on the family that carries it" ); assert_eq!( @@ -2274,8 +1880,8 @@ mod tests { /// chain means the deny has to be appended first. #[test] fn denies_precede_allows_so_the_narrower_deny_wins() { - let plan = EgressPlan::for_policy(&request( - NetworkPolicy::Block, + let plan = EgressPlan::for_request(&request( + NetworkAction::Deny, &["0.0.0.0/0"], &["192.0.2.1"], )) @@ -2283,39 +1889,42 @@ mod tests { assert_eq!( v4(&plan), - egress(&["-d 192.0.2.1 -j DROP", "-d 0.0.0.0/0 -j ACCEPT"], "DROP") + directional_egress( + &["-d 192.0.2.1/32 -j DROP", "-d 0.0.0.0/0 -j ACCEPT"], + "DROP" + ) ); } #[test] fn a_v6_rule_lands_only_in_the_ip6tables_payload() { - let plan = EgressPlan::for_policy(&request(NetworkPolicy::Block, &["2001:db8::/32"], &[])) + let plan = EgressPlan::for_request(&request(NetworkAction::Deny, &["2001:db8::/32"], &[])) .expect("a v6 CIDR is enforceable"); assert_eq!(v6(&plan), egress(&["-d 2001:db8::/32 -j ACCEPT"], "DROP")); - assert_eq!(v4(&plan), egress(&[], "DROP")); + assert_eq!(v4(&plan), directional_egress(&[], "DROP")); } #[test] fn only_reachable_looking_v6_allows_are_reported_as_unreachable() { let allowed = - EgressPlan::for_policy(&request(NetworkPolicy::Block, &["2001:db8::/32"], &[])) + EgressPlan::for_request(&request(NetworkAction::Deny, &["2001:db8::/32"], &[])) .expect("a v6 CIDR is enforceable"); assert_eq!(allowed.allowed_v6_targets(), vec!["2001:db8::/32"]); // A v6 deny is already the posture the caller asked for. let denied = - EgressPlan::for_policy(&request(NetworkPolicy::Allow, &[], &["2001:db8::/32"])) + EgressPlan::for_request(&request(NetworkAction::Allow, &[], &["2001:db8::/32"])) .expect("a v6 CIDR is enforceable"); assert!(denied.allowed_v6_targets().is_empty()); - let v4_only = EgressPlan::for_policy(&request(NetworkPolicy::Block, &["203.0.113.5"], &[])) + let v4_only = EgressPlan::for_request(&request(NetworkAction::Deny, &["203.0.113.5"], &[])) .expect("a v4 address is enforceable"); assert!(v4_only.allowed_v6_targets().is_empty()); // An IPv4-mapped address is programmed as IPv4, so it is reachable. let mapped = - EgressPlan::for_policy(&request(NetworkPolicy::Block, &["::ffff:203.0.113.5"], &[])) + EgressPlan::for_request(&request(NetworkAction::Deny, &["::ffff:203.0.113.5"], &[])) .expect("a mapped address is enforceable"); assert!(mapped.allowed_v6_targets().is_empty()); } @@ -2343,17 +1952,6 @@ mod tests { assert_eq!(plan.allowed_v6_targets(), vec!["2001:db8::1/128"]); } - #[test] - fn a_named_host_fails_the_whole_plan() { - let error = EgressPlan::for_policy(&request( - NetworkPolicy::Block, - &["10.0.0.1", "api.github.com"], - &[], - )) - .expect_err("one unenforceable entry invalidates the policy"); - assert!(error.contains("api.github.com"), "{error}"); - } - #[test] fn the_proxy_plan_opens_exactly_one_endpoint() { let plan = EgressPlan::for_proxy(Ipv4Addr::new(10, 0, 2, 2), 3128); @@ -2386,11 +1984,11 @@ mod tests { /// A family with no rules of its own still gets its terminal verdict, so /// IPv6 under an IPv4-only policy fails closed rather than being left open. #[test] - fn a_policy_with_no_host_rules_still_closes_both_families() { - let plan = EgressPlan::for_policy(&request(NetworkPolicy::Block, &[], &[])) - .expect("a bare block policy is enforceable"); + fn a_ruleless_deny_plan_closes_both_families() { + let plan = EgressPlan::for_request(&request(NetworkAction::Deny, &[], &[])) + .expect("a bare deny policy is enforceable"); - assert_eq!(v4(&plan), egress(&[], "DROP")); + assert_eq!(v4(&plan), directional_egress(&[], "DROP")); assert_eq!(v6(&plan), egress(&[], "DROP")); } @@ -2444,11 +2042,11 @@ mod tests { assert!(established < first_drop, "{body:?}"); } - /// `defaultPolicy` governs egress. An open outbound posture must not open + /// `egress.default` governs outbound. An open posture must not open /// inbound as a side effect. #[test] fn an_allow_egress_posture_does_not_open_inbound() { - let plan = EgressPlan::for_policy(&request(NetworkPolicy::Allow, &[], &[])) + let plan = EgressPlan::for_request(&request(NetworkAction::Allow, &[], &[])) .expect("a bare allow policy is enforceable"); let rendered = render_filter_payloads(&plan, &denied_ingress(), RuleFamily::V4, EGRESS, INGRESS) @@ -2458,12 +2056,14 @@ mod tests { assert_eq!(chain_body(&rendered, INGRESS).last().unwrap(), "-j DROP"); } - /// The mapping the rejection at `bwrap_command` currently guards. Asserted - /// so lifting that rejection cannot silently produce the wrong posture. + /// The mapping the directional allow rejection currently guards. #[test] - fn allowing_local_network_accepts_new_inbound_instead() { + fn allowing_directional_ingress_changes_the_inbound_chain() { let mut request = ExecutionRequest::default(); - request.policy.allow_local_network = true; + request.policy.network_ingress = Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Deny, + }); let plan = EgressPlan::for_proxy(Ipv4Addr::new(10, 0, 2, 2), 3128); let rendered = render_filter_payloads( &plan, @@ -2497,7 +2097,7 @@ mod tests { .map(|index| format!("198.51.{}.{}/32", index / 256, index % 256)) .collect(); let refs: Vec<&str> = blocked.iter().map(String::as_str).collect(); - EgressPlan::for_policy(&request(NetworkPolicy::Block, &[], &refs)) + EgressPlan::for_request(&request(NetworkAction::Deny, &[], &refs)) .expect("literal CIDRs are enforceable") } @@ -2743,7 +2343,7 @@ mod tests { ); } - /// `ingress.hostLoopback` is bidirectional per the 0.8 contract, so a deny + /// `ingress.hostLoopback` is bidirectional, so a deny /// has to close container-to-host too -- under slirp, the gateway. #[test] fn a_directional_posture_closes_the_host_loopback_path() { @@ -2812,20 +2412,6 @@ mod tests { ); } - /// The legacy shape is untouched: no `ingress` section, and adding a drop - /// there would silently change behavior for 0.6/0.7 callers. - #[test] - fn the_legacy_shape_gains_no_host_loopback_rule() { - let plan = EgressPlan::for_policy(&request(NetworkPolicy::Allow, &["10.0.2.2/32"], &[])) - .expect("a legacy allowlist is enforceable"); - - assert_eq!( - v4(&plan), - egress(&["-d 10.0.2.2/32 -j ACCEPT"], "ACCEPT"), - "pre-0.8 configs keep reaching the host exactly as before" - ); - } - /// `NetworkCidr` has public fields and only its `FromStr` validates, so a /// programmatic caller reaches the renderer with any prefix. Unchecked, /// `width - prefix` underflowed and panicked. diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs b/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs index 97b48e04f..a9b6717ef 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs @@ -52,7 +52,7 @@ const XTABLES_LOCK_PATH: &str = "/run/xtables.lock"; /// A large policy needs more than one transaction per family, so this scales /// with the rendered payload count -- but only up to [`RULE_INSTALL_CEILING`]. /// Scaling without a ceiling is what made the previous per-rule budget -/// unusable: a long host list could push startup past any sane bound. The +/// unusable: a large rule set could push startup past any sane bound. The /// ceiling keeps a wedged host bounded, and it is generous enough that a /// policy reaching it is contending for the lock rather than merely large. /// @@ -184,7 +184,7 @@ ns="/proc/$child_pid/ns/net" # the *last* transaction of a family, so a hook is never live over a partially # built chain and the rules cannot be observed half-installed. Splitting is # forced by the kernel: one restore is one bounded netlink transaction, and a -# large host list would otherwise exceed it and install nothing. The payloads +# large directional rule set would otherwise exceed it and install nothing. The payloads # are rendered in Rust from parsed addresses, so nothing here echoes caller # text. # @@ -1467,13 +1467,8 @@ fn insert_hosts_bind(args: &mut Vec, hosts_path: &str) -> Result &'static str { match self { - Self::ProxyOnlyEgress => "network.proxy", - Self::FirewallEnforcement => "network.enforcementMode='firewall'", + Self::ProxyOnlyEgress => "runtimeConfig.networkProxy", + Self::FirewallEnforcement => "network.egress", } } /// What the caller can drop to stop needing these dependencies. fn remedy(self) -> &'static str { match self { - Self::ProxyOnlyEgress => "omit network.proxy", - Self::FirewallEnforcement => "select a different network.enforcementMode", + Self::ProxyOnlyEgress => "omit runtimeConfig.networkProxy", + Self::FirewallEnforcement => "use ruleless network.egress.default='deny'", } } @@ -1509,7 +1504,7 @@ impl PrivateNetworkUse { /// Whether this host can enforce proxy-only egress, and why not when it cannot. /// /// Pre-flight counterpart to the launch-path check in -/// [`crate::bwrap_runner`]: schema 0.8 proxy policy has no fallback, so a +/// [`crate::bwrap_runner`]: supported runtime proxy policy has no fallback, so a /// caller that cannot ask this ahead of time only learns the answer when the /// run fails. Advisory — the runner still probes before it launches. /// @@ -1729,7 +1724,7 @@ enum SupervisorCommandCoverage { /// Digest of the reviewed [`SUPERVISOR_SCRIPT`], line-ending independent. /// Bumping it acknowledges that the command list below was re-checked. #[cfg(test)] -const EXPECTED_SUPERVISOR_SCRIPT_DIGEST: u64 = 0x5855_540d_dc42_997c; +const EXPECTED_SUPERVISOR_SCRIPT_DIGEST: u64 = 0xaaf9_be3f_4367_b7d8; /// Every external command [`SUPERVISOR_SCRIPT`] runs, and how the pre-flight /// walk accounts for it. @@ -2066,7 +2061,6 @@ fn terminate_child(child: &mut Child) { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::NetworkEnforcementCompatibility; /// The reported concern: a hostname proxy resolves through the host's /// resolver during setup, before the script timeout applies, so an @@ -3002,17 +2996,38 @@ mod tests { assert!(rule_install_timeout(2, 0) > RULE_INSTALL_FLOOR); } - /// A plan whose rule count is what the test cares about. Sized through the - /// public constructor so the count matches what the supervisor installs. - fn plan_with_rule_count(count: usize) -> EgressPlan { - let mut request = crate::mxc_common::models::ExecutionRequest { - network_enforcement_compatibility: NetworkEnforcementCompatibility::Strict, - ..Default::default() + fn directional_plan( + default: crate::mxc_common::models::NetworkAction, + allowed: &[&str], + denied: &[&str], + ) -> EgressPlan { + use crate::mxc_common::models::{NetworkEgressPolicy, NetworkPeer, NetworkRule}; + let rule = |cidr: &&str| NetworkRule { + to: vec![NetworkPeer { + cidr: cidr.parse().expect("test CIDR"), + except: vec![], + }], + ports: vec![], }; - request.policy.allowed_hosts = (0..count) - .map(|n| format!("10.0.{}.{}", n / 256, n % 256)) + let mut request = crate::mxc_common::models::ExecutionRequest::default(); + request.policy.network_egress = Some(NetworkEgressPolicy { + default, + allow: allowed.iter().map(rule).collect(), + deny: denied.iter().map(rule).collect(), + }); + EgressPlan::for_request(&request).expect("directional rules must build a plan") + } + + /// A plan whose rule count crosses restore transaction boundaries. + fn plan_with_rule_count(count: usize) -> EgressPlan { + let addresses: Vec = (0..count) + .map(|n| format!("10.0.{}.{}/32", n / 256, n % 256)) .collect(); - EgressPlan::for_policy(&request).expect("literal addresses must build a plan") + directional_plan( + crate::mxc_common::models::NetworkAction::Deny, + &addresses.iter().map(String::as_str).collect::>(), + &[], + ) } /// A rejected transaction installs nothing, so the supervisor must fail @@ -3585,14 +3600,14 @@ mod tests { "a missing '{tool}' must be named, got: {error}" ); assert!( - error.contains("network.proxy"), + error.contains("runtimeConfig.networkProxy"), "the refusal must name the config that required it, got: {error}" ); } } - /// The wording differs by use case, so firewall mode must not be told to - /// drop `network.proxy`. + /// The wording differs by use case, so direct egress must not be told to + /// drop the runtime proxy. #[test] fn a_refusal_names_the_use_case_that_required_the_tool() { let error = probe_dependencies_with( @@ -3602,8 +3617,11 @@ mod tests { ) .expect_err("a missing tool must fail the walk"); - assert!(error.contains("enforcementMode"), "got: {error}"); - assert!(!error.contains("omit network.proxy"), "got: {error}"); + assert!(error.contains("network.egress"), "got: {error}"); + assert!( + !error.contains("omit runtimeConfig.networkProxy"), + "got: {error}" + ); } /// Present but broken is a different diagnosis from absent, and the walk @@ -4041,10 +4059,8 @@ mod tests { ); } - /// Firewall enforcement shares this probe with proxy-only egress, so a - /// missing dependency used to advise a caller to "omit network.proxy" -- - /// a field a firewall-only request never set. The advice must name what - /// the caller actually configured. + /// Directional enforcement shares this probe with runtime proxy egress; + /// the advice must name the policy the caller actually configured. #[test] fn a_firewall_request_is_never_advised_about_the_proxy() { let dir = tempfile::tempdir().expect("tempdir"); @@ -4058,11 +4074,11 @@ mod tests { .expect_err("a legacy backend with an unreachable lock cannot install rules"); assert!( - !error.contains("network.proxy"), + !error.contains("runtimeConfig.networkProxy"), "a firewall-only request must not be told about a field it never set: {error}" ); assert!( - error.contains("network.enforcementMode='firewall'"), + error.contains("network.egress"), "the error must name what the caller configured: {error}" ); } @@ -4081,7 +4097,7 @@ mod tests { .expect_err("a legacy backend with an unreachable lock cannot install rules"); assert!( - error.contains("network.proxy") && !error.contains("enforcementMode"), + error.contains("runtimeConfig.networkProxy") && !error.contains("network.egress"), "a proxy request must keep the proxy wording: {error}" ); } @@ -4639,7 +4655,7 @@ exec sleep 30 } /// Batching is the point: an ordinary policy costs two transactions however - /// many host rules it carries. + /// many directional rules it carries. #[test] fn a_policy_plan_installs_a_fixed_number_of_commands() { for rule_count in [0, 1, 5, 50] { @@ -4662,8 +4678,9 @@ exec sleep 30 .iter() .filter(|rule| rule.contains(" -d ")) .count(), - rule_count, - "a {rule_count}-rule policy reached iptables with the wrong rule count" + rule_count + 1, + "a {rule_count}-rule policy plus the host-loopback drop reached iptables \ + with the wrong rule count" ); } } @@ -4689,7 +4706,7 @@ exec sleep 30 let installed: Vec = supervisor .chain_rules() .into_iter() - .filter(|rule| rule.contains(" -d ")) + .filter(|rule| rule.contains(" -d ") && rule.ends_with(" -j ACCEPT")) .collect(); assert_eq!( installed.len(), @@ -4699,11 +4716,12 @@ exec sleep 30 ); // Order is the policy, so the split must preserve it end to end. for (index, rule) in installed.iter().enumerate() { - let expected = format!(" -d 10.0.{}.{} ", index / 256, index % 256); - assert!( - rule.contains(&expected), - "rule {index} arrived out of order: {rule}" + let expected = format!( + "iptables -d 10.0.{}.{}/32 -j ACCEPT", + index / 256, + index % 256 ); + assert_eq!(rule, &expected, "rule {index} arrived out of order"); } } @@ -4747,11 +4765,12 @@ exec sleep 30 /// because in a first-match chain a correct set of rules in the wrong order /// is a different policy. #[test] - fn a_block_policy_accepts_only_its_allowlist_and_closes_both_families() { - let mut request = crate::mxc_common::models::ExecutionRequest::default(); - request.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Block; - request.policy.allowed_hosts = vec!["203.0.113.7".into(), "2001:db8::/32".into()]; - let plan = EgressPlan::for_policy(&request).expect("literals must build a plan"); + fn a_deny_policy_accepts_only_its_allow_rules_and_closes_both_families() { + let plan = directional_plan( + crate::mxc_common::models::NetworkAction::Deny, + &["203.0.113.7/32", "2001:db8::/32"], + &[], + ); let mut supervisor = spawn_fake_supervisor_with_plan(&plan, &denied_ingress(), None, false); supervisor.publish_sandbox_pid(); @@ -4761,7 +4780,8 @@ exec sleep 30 supervisor.chain_rules(), vec![ "iptables -o lo -j ACCEPT".to_string(), - "iptables -d 203.0.113.7 -j ACCEPT".to_string(), + "iptables -d 10.0.2.2/32 -j DROP".to_string(), + "iptables -d 203.0.113.7/32 -j ACCEPT".to_string(), "iptables -j DROP".to_string(), "ip6tables -o lo -j ACCEPT".to_string(), "ip6tables -d 2001:db8::/32 -j ACCEPT".to_string(), @@ -4776,12 +4796,12 @@ exec sleep 30 /// purely a question of which rule is appended first, so it is asserted /// against what reached iptables rather than against the plan alone. #[test] - fn an_allow_policy_denies_its_blocklist_before_the_open_terminal() { - let mut request = crate::mxc_common::models::ExecutionRequest::default(); - request.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Allow; - request.policy.blocked_hosts = vec!["198.51.100.0/24".into()]; - request.policy.allowed_hosts = vec!["198.51.100.9".into()]; - let plan = EgressPlan::for_policy(&request).expect("literals must build a plan"); + fn an_allow_policy_denies_its_deny_rules_before_the_open_terminal() { + let plan = directional_plan( + crate::mxc_common::models::NetworkAction::Allow, + &["198.51.100.9/32"], + &["198.51.100.0/24"], + ); let mut supervisor = spawn_fake_supervisor_with_plan(&plan, &denied_ingress(), None, false); supervisor.publish_sandbox_pid(); @@ -4794,7 +4814,7 @@ exec sleep 30 .expect("the deny must be installed"); let allow = rules .iter() - .position(|rule| rule == "iptables -d 198.51.100.9 -j ACCEPT") + .position(|rule| rule == "iptables -d 198.51.100.9/32 -j ACCEPT") .expect("the allow must be installed"); let terminal = rules .iter() @@ -4838,10 +4858,11 @@ exec sleep 30 /// silent v6 exit that no rule in the config mentions. #[test] fn a_v4_only_policy_still_closes_ipv6() { - let mut request = crate::mxc_common::models::ExecutionRequest::default(); - request.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Block; - request.policy.allowed_hosts = vec!["203.0.113.7".into()]; - let plan = EgressPlan::for_policy(&request).expect("literals must build a plan"); + let plan = directional_plan( + crate::mxc_common::models::NetworkAction::Deny, + &["203.0.113.7/32"], + &[], + ); let mut supervisor = spawn_fake_supervisor_with_plan(&plan, &denied_ingress(), None, false); supervisor.publish_sandbox_pid(); diff --git a/src/mxc-sdk/src/backends/hyperlight/common/mod.rs b/src/mxc-sdk/src/backends/hyperlight/common/mod.rs index 73b9559f3..d6f4d3bb3 100644 --- a/src/mxc-sdk/src/backends/hyperlight/common/mod.rs +++ b/src/mxc-sdk/src/backends/hyperlight/common/mod.rs @@ -22,7 +22,7 @@ //! | Script delivery | `AppSandbox::run`, or `submit` + `step` under a deadline | //! | Cold start | Snapshot restore (~50–60 ms) | //! | Filesystem | Host dir mounts via `Mount` | -//! | Networking | Host-proxied sockets via `NetworkPolicy` | +//! | Networking | Disabled for supported requests | //! | Script I/O | Host's stdout/stderr (HostPrint) | //! | stdlib coverage | Full CPython + preloaded ML stack (numpy, pandas, etc.) | //! @@ -100,15 +100,15 @@ use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ - ExecutionRequest, HyperlightRuntime, NetworkPolicy, ScriptResponse, + ExecutionRequest, HyperlightRuntime, NetworkEnforcementMode, NetworkPolicy, ScriptResponse, }; use crate::mxc_common::script_runner::ScriptRunner; -use crate::mxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; +use crate::mxc_common::validator::{ + validate_common, validate_network_policy_support, NetworkPolicySupport, +}; use hyperlight_unikraft::hyperlight_host::HyperlightError; -use hyperlight_unikraft::{ - AllowList, AppSandbox, BlockList, Mount, SandboxBuilder, Snapshot, Yield, -}; +use hyperlight_unikraft::{AppSandbox, Mount, SandboxBuilder, Snapshot, Yield}; // -- Availability probe ------------------------------------------------------- @@ -259,34 +259,6 @@ pub struct HyperlightScriptRunner { rewind: Option>, active_home: Option, active_mounts: Vec, - active_policy: Option, - active_network: NetworkKey, -} - -/// The request's network policy as the runner keys a booted guest on it. -/// Both host lists are kept, so an allow list and a block list of the -/// same hosts key differently. -#[derive(Clone, Debug, PartialEq, Default)] -struct NetworkKey { - allowed: Vec, - blocked: Vec, - default: NetworkPolicy, -} - -impl NetworkKey { - fn from_request(request: &ExecutionRequest) -> Self { - let sorted = |hosts: &[String]| { - let mut hosts = hosts.to_vec(); - hosts.sort(); - hosts.dedup(); - hosts - }; - Self { - allowed: sorted(&request.policy.allowed_hosts), - blocked: sorted(&request.policy.blocked_hosts), - default: request.policy.default_network_policy.clone(), - } - } } /// A booted guest, parked at a boundary between calls. @@ -405,8 +377,6 @@ impl HyperlightScriptRunner { rewind: None, active_home: None, active_mounts: Vec::new(), - active_policy: None, - active_network: NetworkKey::default(), } } @@ -473,8 +443,7 @@ impl HyperlightScriptRunner { os_data_home().join(DEFAULT_HOME_LEAF) } - /// Reject only policies that the hyperlight backend genuinely cannot honor. - /// Filesystem mounts and network policies ARE supported. + /// Reject policies the guest cannot enforce before any sandbox is booted. fn validate_policies(request: &ExecutionRequest) -> Result<(), RunnerError> { if request.policy.network_proxy.is_enabled() { return Err(RunnerError::Preflight(ERR_PROXY_POLICY.to_string())); @@ -482,12 +451,15 @@ impl HyperlightScriptRunner { if !request.working_directory.is_empty() { return Err(RunnerError::Preflight(ERR_WORKDIR.to_string())); } - if request.policy.default_network_policy == NetworkPolicy::Block - && !request.policy.allowed_hosts.is_empty() - && !request.policy.blocked_hosts.is_empty() + if request.policy.default_network_policy != NetworkPolicy::Block + || request.policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities + || request.policy.allow_local_network + || !request.policy.allowed_hosts.is_empty() + || !request.policy.blocked_hosts.is_empty() { return Err(RunnerError::Preflight( - "allowedHosts and blockedHosts are mutually exclusive".to_string(), + "retired network fields are not supported by Hyperlight; the guest runs without networking" + .to_string(), )); } @@ -524,35 +496,6 @@ impl HyperlightScriptRunner { Ok(()) } - /// Translate MXC's network policy fields into a guest `NetworkPolicy`. - /// - /// - `allowed_hosts` non-empty → `AllowList` (only listed hosts reachable) - /// - `blocked_hosts` non-empty → `BlockList` (listed hosts denied, rest allowed) - /// - `default_network_policy == Block`, no host lists → `None` (networking disabled) - /// - `default_network_policy == Allow`, no host lists → `AllowAll` - fn network_policy_from_key( - key: &NetworkKey, - ) -> Result, RunnerError> { - if !key.allowed.is_empty() { - let allow_list = AllowList::from_hosts(&key.allowed) - .map_err(|e| RunnerError::Preflight(format!("resolve allowed_hosts: {e}")))?; - return Ok(Some(hyperlight_unikraft::NetworkPolicy::AllowList( - allow_list, - ))); - } - if !key.blocked.is_empty() { - let block_list = BlockList::from_hosts(&key.blocked) - .map_err(|e| RunnerError::Preflight(format!("resolve blocked_hosts: {e}")))?; - return Ok(Some(hyperlight_unikraft::NetworkPolicy::BlockList( - block_list, - ))); - } - if key.default == NetworkPolicy::Block { - return Ok(None); - } - Ok(Some(hyperlight_unikraft::NetworkPolicy::AllowAll)) - } - /// Translate `ContainerPolicy.{readwrite,readonly}Paths` into /// [`Mount`] entries. Each host path is exposed inside the guest at /// `/host/` so scripts can find mounts predictably. @@ -636,28 +579,22 @@ impl HyperlightScriptRunner { /// /// The guest restores the persisted snapshot (warming and persisting /// one first if only the rootfs is present, a cold boot once per - /// image) with the request's mounts and network policy; the kernel - /// builds its mount table from them on resume. Later calls on the + /// image) with the request's mounts; the kernel builds its mount table + /// from them on resume. Later calls on the /// same runner rewind rather than boot. /// - /// The mount set and network policy are fixed at boot; a change in - /// either boots another guest from the same image. + /// The mount set is fixed at boot; changing it boots another guest from + /// the same image. fn ensure_runtime( &mut self, home: &Path, runtime: HyperlightRuntime, mounts: Vec, - network: NetworkKey, logger: &mut Logger, ) -> Result<(&mut Guest, Arc), RunnerError> { - let same_config = self.active_home.as_deref() == Some(home) - && mounts_equal(&self.active_mounts, &mounts) - && self.active_network == network; + let same_config = + self.active_home.as_deref() == Some(home) && mounts_equal(&self.active_mounts, &mounts); if !same_config { - // Host lists resolve names here, once per configuration, so a - // guest that is already up is not held to the resolver on - // every call. - let policy = Self::network_policy_from_key(&network)?; // Nothing booted so far applies to the new configuration; the // image still does, unless the home changed. self.guest = None; @@ -666,8 +603,6 @@ impl HyperlightScriptRunner { } self.active_home = Some(home.to_path_buf()); self.active_mounts = mounts; - self.active_policy = policy; - self.active_network = network; } let rewind = match self.rewind.clone() { Some(rewind) => rewind, @@ -682,7 +617,7 @@ impl HyperlightScriptRunner { Some(guest) => guest, None => { configure_surrogates(); - Self::boot_from_snapshot(rewind.clone(), &self.active_mounts, &self.active_policy)? + Self::boot_from_snapshot(rewind.clone(), &self.active_mounts)? } }; Ok((self.guest.insert(guest), rewind)) @@ -691,13 +626,9 @@ impl HyperlightScriptRunner { /// Restore the warm image into a new sandbox with `mounts` and /// `policy`: the kernel builds its mount table from them on resume, /// and the host serves them. - fn boot_from_snapshot( - rewind: Arc, - mounts: &[Mount], - policy: &Option, - ) -> Result { + fn boot_from_snapshot(rewind: Arc, mounts: &[Mount]) -> Result { let builder = SandboxBuilder::from_snapshot(rewind).mounts(mounts.iter().cloned()); - let sandbox = with_network(builder, policy) + let sandbox = builder .boot() .map_err(|e| RunnerError::Runtime(format!("restore hyperlight snapshot: {e}")))?; Ok(Guest { @@ -750,16 +681,12 @@ impl HyperlightScriptRunner { } Ok(timing) } -} - -impl ScriptRunner for HyperlightScriptRunner { - fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - Self::validate_policies(request).map_err(|e| e.to_response())?; - validate_network_policy_support(request, NetworkPolicySupport::LEGACY)?; - Ok(()) - } - fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + fn execute_validated( + &mut self, + request: &ExecutionRequest, + logger: &mut Logger, + ) -> ScriptResponse { let runtime = request .hyperlight .as_ref() @@ -779,13 +706,7 @@ impl ScriptRunner for HyperlightScriptRunner { return e.to_response(); } }; - let (guest, rewind) = match self.ensure_runtime( - &home, - runtime, - mounts, - NetworkKey::from_request(request), - logger, - ) { + let (guest, rewind) = match self.ensure_runtime(&home, runtime, mounts, logger) { Ok(pair) => pair, Err(e) => { logger.log_line(&e.to_string()); @@ -830,6 +751,37 @@ impl ScriptRunner for HyperlightScriptRunner { } } +impl ScriptRunner for HyperlightScriptRunner { + fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + Self::validate_policies(request).map_err(|e| e.to_response())?; + validate_network_policy_support(request, NetworkPolicySupport::default())?; + Ok(()) + } + + fn run(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + if let Err(response) = validate_common(request) { + return response; + } + if let Err(response) = self.validate_runner(request) { + return response; + } + if request.dry_run { + return ScriptResponse { + exit_code: 0, + ..Default::default() + }; + } + self.execute_validated(request, logger) + } + + fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + if let Err(response) = self.validate_runner(request) { + return response; + } + self.execute_validated(request, logger) + } +} + // -- Guest driving ----------------------------------------------------------- /// Run `code` and wait for it, giving up at `timeout`. @@ -1064,16 +1016,6 @@ fn rootfs_builder(home: &Path, runtime: HyperlightRuntime) -> SandboxBuilder { .scratch_mb(runtime_image(runtime).scratch_mb) } -fn with_network( - builder: SandboxBuilder, - policy: &Option, -) -> SandboxBuilder { - match policy { - Some(policy) => builder.network(policy.clone()), - None => builder, - } -} - /// Pull the rootfs CPIO out of the published image into `dst`, straight /// from the registry's distribution API: no container runtime needed. /// Staged beside `dst` and renamed into place so a failed pull leaves no @@ -2177,152 +2119,105 @@ mod tests { } #[test] - fn network_key_tells_an_allow_list_from_a_block_list() { - let allow = ExecutionRequest { - policy: ContainerPolicy { - allowed_hosts: vec!["a.example".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let block = ExecutionRequest { - policy: ContainerPolicy { - blocked_hosts: vec!["a.example".to_string()], - ..Default::default() - }, - ..Default::default() - }; - assert_ne!( - NetworkKey::from_request(&allow), - NetworkKey::from_request(&block) - ); - assert_eq!( - NetworkKey::from_request(&allow), - NetworkKey::from_request(&allow) - ); + fn omitted_network_policy_is_valid_and_keeps_guest_disconnected() { + runner() + .validate_runner(&ExecutionRequest::default()) + .unwrap(); } #[test] - fn network_policy_allow_all_when_default_allow() { - let request = ExecutionRequest { - policy: ContainerPolicy { + fn retired_network_fields_are_rejected_before_boot() { + for policy in [ + ContainerPolicy { default_network_policy: NetworkPolicy::Allow, ..Default::default() }, - ..Default::default() - }; - let policy = - HyperlightScriptRunner::network_policy_from_key(&NetworkKey::from_request(&request)) - .unwrap(); - assert!(matches!( - policy, - Some(hyperlight_unikraft::NetworkPolicy::AllowAll) - )); - } - - #[test] - fn network_policy_allowlist_from_allowed_hosts() { - let request = ExecutionRequest { - policy: ContainerPolicy { - allowed_hosts: vec!["127.0.0.1".to_string()], + ContainerPolicy { + allowed_hosts: vec!["a.example".to_string()], ..Default::default() }, - ..Default::default() - }; - let policy = - HyperlightScriptRunner::network_policy_from_key(&NetworkKey::from_request(&request)) - .unwrap(); - assert!(matches!( - policy, - Some(hyperlight_unikraft::NetworkPolicy::AllowList(_)) - )); - } - - #[test] - fn network_policy_none_when_blocked() { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Block, + ContainerPolicy { + blocked_hosts: vec!["b.example".to_string()], ..Default::default() }, - ..Default::default() - }; - let policy = - HyperlightScriptRunner::network_policy_from_key(&NetworkKey::from_request(&request)) - .unwrap(); - assert!(policy.is_none()); + ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + ..Default::default() + }, + ContainerPolicy { + allow_local_network: true, + ..Default::default() + }, + ] { + let request = ExecutionRequest { + policy, + ..Default::default() + }; + let error = runner().validate_runner(&request).unwrap_err(); + assert!(error.error_message.contains("retired network fields")); + } } #[test] - fn network_policy_blocklist_from_blocked_hosts() { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - blocked_hosts: vec!["127.0.0.1".to_string()], - ..Default::default() - }, + fn explicit_directional_allow_is_rejected_before_boot() { + let mut request = ExecutionRequest::default(); + request.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: crate::mxc_common::models::NetworkAction::Allow, ..Default::default() - }; - let policy = - HyperlightScriptRunner::network_policy_from_key(&NetworkKey::from_request(&request)) - .unwrap(); - assert!(matches!( - policy, - Some(hyperlight_unikraft::NetworkPolicy::BlockList(_)) - )); + }); + let error = runner().validate_runner(&request).unwrap_err(); + assert!(error.error_message.contains("network.egress.default")); } #[test] - fn policy_rejects_blocklist_without_allowlist_under_block_default() { + fn direct_execution_rejects_retired_host_lists_before_boot() { + let mut r = runner(); let request = ExecutionRequest { policy: ContainerPolicy { - blocked_hosts: vec!["127.0.0.1".to_string()], + allowed_hosts: vec!["a.example".to_string()], ..Default::default() }, ..Default::default() }; - - let error = runner().validate_runner(&request).unwrap_err(); - assert_eq!( - error.error_message, - "blockedHosts requires allowedHosts when network.defaultPolicy='block'" - ); + let mut logger = Logger::new(Mode::Buffer); + let resp = r.execute(&request, &mut logger); + assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert!(resp.error_message.contains("retired network fields")); } #[test] - fn policy_rejects_allowlist_under_allow_default() { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - allowed_hosts: vec!["127.0.0.1".to_string()], - ..Default::default() - }, + fn direct_execute_rejects_network_policy_before_boot() { + let mut request = ExecutionRequest { + script_code: "print('x')".to_string(), ..Default::default() }; - - let error = runner().validate_runner(&request).unwrap_err(); - assert_eq!( - error.error_message, - "allowedHosts requires network.defaultPolicy='block'" - ); + request.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: crate::mxc_common::models::NetworkAction::Allow, + ..Default::default() + }); + let mut r = runner(); + let mut logger = Logger::new(Mode::Buffer); + let response = r.execute(&request, &mut logger); + assert!(response.error_message.contains("network.egress.default")); } #[test] - fn policy_rejects_allowed_and_blocked_hosts() { - let mut r = runner(); - let request = ExecutionRequest { + fn dry_run_validates_without_booting() { + let mut request = ExecutionRequest { script_code: "print('x')".to_string(), - policy: ContainerPolicy { - allowed_hosts: vec!["a.com".to_string()], - blocked_hosts: vec!["b.com".to_string()], - ..Default::default() - }, + dry_run: true, ..Default::default() }; + let mut r = runner(); let mut logger = Logger::new(Mode::Buffer); - let resp = r.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!(resp.error_message.contains("mutually exclusive")); + assert_eq!(r.run(&request, &mut logger).exit_code, 0); + + request.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: crate::mxc_common::models::NetworkAction::Allow, + ..Default::default() + }); + let response = r.run(&request, &mut logger); + assert!(response.error_message.contains("network.egress.default")); } #[test] diff --git a/src/mxc-sdk/src/backends/isolation_session/common/policy.rs b/src/mxc-sdk/src/backends/isolation_session/common/policy.rs index acfa8e52f..23911f243 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/policy.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/policy.rs @@ -168,8 +168,8 @@ fn validate_provision_network_policy( mod tests { use super::*; use crate::mxc_common::models::{ - ContainerPolicy, DefaultEnvCompatibility, NetworkEgressPolicy, NetworkIngressPolicy, - ProxyAddress, ProxyConfig, UiPolicy, + ContainerPolicy, NetworkEgressPolicy, NetworkIngressPolicy, ProxyAddress, ProxyConfig, + UiPolicy, }; use crate::mxc_common::mxc_error::MxcErrorCode; @@ -746,13 +746,8 @@ mod tests { // ====== process.env (refused when it would replace the default) ====== - fn request_with_env( - compatibility: DefaultEnvCompatibility, - env: Option>, - inherit_default_env: bool, - ) -> ExecutionRequest { + fn request_with_env(env: Option>, inherit_default_env: bool) -> ExecutionRequest { ExecutionRequest { - default_env_compatibility: compatibility, env: env.map(|e| e.into_iter().map(String::from).collect()), inherit_default_env, ..Default::default() @@ -761,21 +756,18 @@ mod tests { #[test] fn only_an_environment_that_would_replace_the_default_is_refused() { - use DefaultEnvCompatibility::DefaultBlock; - let cases = [ - (None, false, DefaultBlock, true), - (None, true, DefaultBlock, true), - (Some(vec![]), false, DefaultBlock, false), - (Some(vec![]), true, DefaultBlock, true), - (Some(vec!["FOO=bar"]), false, DefaultBlock, false), - (Some(vec!["FOO=bar"]), true, DefaultBlock, true), + (None, false, true), + (None, true, true), + (Some(vec![]), false, false), + (Some(vec![]), true, true), + (Some(vec!["FOO=bar"]), false, false), + (Some(vec!["FOO=bar"]), true, true), ]; - for (env, inherit_default_env, compatibility, accepted) in cases { - let state = - format!("{env:?}, inherit_default_env={inherit_default_env}, {compatibility:?}"); - let request = request_with_env(compatibility, env, inherit_default_env); + for (env, inherit_default_env, accepted) in cases { + let state = format!("{env:?}, inherit_default_env={inherit_default_env}"); + let request = request_with_env(env, inherit_default_env); match (reject_unhonorable_environment(&request), accepted) { (Ok(()), true) => {} (Err(err), false) => assert_policy_err_contains(err, ERR_ENVIRONMENT_POLICY), diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs index ef4aba6aa..93ff101a4 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs @@ -1429,13 +1429,11 @@ mod tests { } #[test] - fn proxy_disabled_keeps_caller_proxy_env_and_still_clears_inherited_env() { - use crate::mxc_common::{models::ProxyConfig, proxy_env::apply_proxy_env}; - let mut env = vec![ + fn caller_proxy_env_is_preserved_and_host_env_is_cleared() { + let env = vec![ "HTTP_PROXY=http://caller-proxy.example:9999".to_string(), "PATH=/usr/bin".to_string(), ]; - apply_proxy_env(&mut env, &ProxyConfig::default()); let args = build_attach_args_with_env_control(&env, "", "cmd", true); assert!( args.iter().any(|a| a == "--clear-env"), @@ -1452,34 +1450,6 @@ mod tests { ); } - #[test] - fn proxy_enabled_emits_clear_env_and_proxy_keys_in_attach_args() { - use crate::mxc_common::{ - models::{ProxyAddress, ProxyConfig}, - proxy_env::apply_proxy_env, - }; - let proxy = ProxyConfig { - address: Some(ProxyAddress::new("10.0.0.5".to_string(), 3128)), - builtin_test_server: false, - }; - let mut env = vec!["PATH=/usr/bin".to_string()]; - apply_proxy_env(&mut env, &proxy); - let args = build_attach_args_with_env_control(&env, "", "cmd", true); - assert!( - args.iter().any(|a| a == "--clear-env"), - "proxy enabled must emit --clear-env; got {args:?}" - ); - assert!( - args.iter() - .any(|a| a.starts_with("--set-var=HTTP_PROXY=http://") && a.contains(":3128")), - "proxy enabled must set HTTP_PROXY (with port 3128); got {args:?}" - ); - assert!( - args.iter().any(|a| a == "--set-var=PATH=/usr/bin"), - "PATH must survive the proxy-env merge; got {args:?}" - ); - } - // The attach paths drop this capability only when chains are installed: the // drop is privileged, so applying it to every run costs an unprivileged // caller the whole execution. diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index 68e59f186..132e8e6c2 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ ContainerPolicy, ExecutionRequest, LifecycleConfig, LxcConfig, NetworkEnforcementMode, - ScriptResponse, + NetworkPolicy, ScriptResponse, }; use crate::mxc_common::sandbox_process::{SandboxBackend, SandboxProcess, StdioMode}; use crate::mxc_common::script_runner::ScriptRunner; @@ -34,7 +34,7 @@ use crate::lxc_common::filesystem_mounts; use crate::lxc_common::lxc_bindings::{ContainerFirewall, LxcContainer, StartNetwork}; use crate::lxc_common::network_ingress::IngressManager; use crate::lxc_common::network_iptables::{ - needs_network, plan_network, uses_directional_keys, EgressHookPoint, NetworkIptablesManager, + needs_network, plan_network, EgressHookPoint, NetworkIptablesManager, }; use crate::lxc_common::signal_cleanup; @@ -59,8 +59,7 @@ const DEFAULT_TERM: &str = "xterm-256color"; /// different directories — `lxc-attach` starts at the container root, so a /// relative `process.cwd` would otherwise leave `HOME` naming a different /// directory than the one the child landed in. Normalizing against the -/// container root is what makes them agree, so it is gated on the schema that -/// introduced `HOME`; below 0.9 the caller's spelling reaches `cd` untouched. +/// container root is what makes them agree for every supported contract. /// /// A policy grant is deliberately *not* consulted: with `process.cwd` omitted /// the child starts at the container root, so treating a grant as the start @@ -68,13 +67,7 @@ const DEFAULT_TERM: &str = "xterm-256color"; fn start_directory(request: &ExecutionRequest) -> Option { Some(request.working_directory.as_str()) .filter(|dir| !dir.is_empty()) - .map(|dir| { - if request.supplies_default_env() { - crate::mxc_common::models::sandbox_absolute_path(dir) - } else { - dir.to_string() - } - }) + .map(crate::mxc_common::models::sandbox_absolute_path) } /// The default environment, from schema 0.9: `PATH`, `TERM`, and -- when one @@ -313,8 +306,7 @@ impl LxcScriptRunner { /// reporting whatever it could not release. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn abandon_prepared(&self, prepared: &mut PreparedSandbox, logger: &mut Logger) -> Vec { - // The pin and the chains are both reached through the container, so - // they come out before it does. + // Namespace-scoped chains come out before the container does. let mut failures = prepared.remove_in_container_state(self.cleanup_policy, logger); let release = self.release_kind(prepared.container_created); @@ -340,7 +332,7 @@ impl LxcScriptRunner { failures } - fn set_up_network_rules_for_v07( + fn set_up_network_rules( &self, container_name: &str, hook_point: EgressHookPoint, @@ -349,36 +341,13 @@ impl LxcScriptRunner { logger: &mut Logger, ) -> Result<(NetworkIptablesManager, Option), String> { let mut fw_manager = NetworkIptablesManager::new(container_name, hook_point); - Self::egress_apply_outcome(fw_manager.apply_legacy_rules(policy, logger))?; + Self::egress_apply_outcome(fw_manager.apply_firewall_rules(policy, logger))?; fw_manager.set_preserve_policy(!self.cleanup_policy); let mut ingress_manager = None; if let Some(pid) = netns_pid { let mut mgr = IngressManager::new(container_name, pid); - Self::ingress_apply_outcome(mgr.apply_legacy_rules(policy, logger))?; - mgr.set_preserve_policy(!self.cleanup_policy); - ingress_manager = Some(mgr); - } - - Ok((fw_manager, ingress_manager)) - } - - fn set_up_network_rules_for_v08( - &self, - container_name: &str, - hook_point: EgressHookPoint, - netns_pid: Option, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result<(NetworkIptablesManager, Option), String> { - let mut fw_manager = NetworkIptablesManager::new(container_name, hook_point); - Self::egress_apply_outcome(fw_manager.apply_directional_rules(policy, logger))?; - fw_manager.set_preserve_policy(!self.cleanup_policy); - - let mut ingress_manager = None; - if let Some(pid) = netns_pid { - let mut mgr = IngressManager::new(container_name, pid); - Self::ingress_apply_outcome(mgr.apply_directional_rules(policy, logger))?; + Self::ingress_apply_outcome(mgr.apply_firewall_rules(policy, logger))?; mgr.set_preserve_policy(!self.cleanup_policy); ingress_manager = Some(mgr); } @@ -463,6 +432,7 @@ impl LxcScriptRunner { request: &ExecutionRequest, logger: &mut Logger, ) -> Result { + self.validate_runner(request)?; let normalized; let request = match crate::mxc_common::filesystem_object::normalize_object_conflicts( &request.policy, @@ -492,35 +462,9 @@ impl LxcScriptRunner { let name_claim = self.claim_container_name()?; let container_name = name_claim.name().to_owned(); - // A process's argv is world-readable through /proc//cmdline. - // Refuse credential-bearing proxy URLs before they become lxc-attach - // `--set-var` arguments. - if let Some(url) = request - .policy - .network_proxy - .address - .as_ref() - .map(|address| address.to_url()) - { - if crate::mxc_common::proxy_env::proxy_url_has_credentials(&url) { - return Err(ScriptResponse::error(&format!( - "LXC: network.proxy.url must not carry credentials ('{}'). LXC passes the \ - proxy URL to lxc-attach as a --set-var command-line argument, and process \ - arguments are world-readable through /proc//cmdline, so the password \ - would be visible to every local user while the command runs. Use a proxy \ - that does not require inline credentials, or supply them to the proxy \ - itself rather than through the URL.", - crate::mxc_common::proxy_env::redact_proxy_url(&url) - ))); - } - } - // The policy lowers to the same rules with or without a container, so // one that cannot be programmed is refused before a container exists. - if let Err(msg) = NetworkIptablesManager::validate_egress_lowering( - &request.policy, - uses_directional_keys(&request.policy), - ) { + if let Err(msg) = NetworkIptablesManager::validate_egress_lowering(&request.policy) { return Err(ScriptResponse::error(&msg)); } @@ -657,23 +601,13 @@ impl LxcScriptRunner { } } - let setup = if uses_directional_keys(&request.policy) { - self.set_up_network_rules_for_v08( - &container_name, - hook_point, - netns_pid, - &request.policy, - logger, - ) - } else { - self.set_up_network_rules_for_v07( - &container_name, - hook_point, - netns_pid, - &request.policy, - logger, - ) - }; + let setup = self.set_up_network_rules( + &container_name, + hook_point, + netns_pid, + &request.policy, + logger, + ); let (fw_manager, ingress_manager) = match setup { Ok(managers) => managers, @@ -683,7 +617,6 @@ impl LxcScriptRunner { } }; - let mut pinned = false; let firewall = container_firewall( fw_manager.rules_applied(), ingress_manager @@ -691,67 +624,27 @@ impl LxcScriptRunner { .is_some_and(|mgr| mgr.rules_applied()), ); - // A proxied chain opens no port 53; without this pin the container has - // no resolver to reach its proxy. - if let Some(pin) = fw_manager.proxy_host_pin() { - let command = Self::build_hosts_pin_command(&pin.hosts_line()); - let _ = writeln!( - logger, - "Pinning proxy host {} to {} in the container's /etc/hosts.", - pin.hostname(), - pin.ip() - ); - let pin_outcome = container.attach_capture( - &command, - "/", - &[], - true, - Some(HOSTS_COMMAND_TIMEOUT), - firewall, - ); - let pin_error = match pin_outcome { - Ok((0, _, _)) => None, - - Ok((code, _, stderr)) => { - Some(Self::hosts_command_failure("writing", code, &stderr)) - } - Err(e) => Some(e.to_string()), - }; - if let Some(reason) = pin_error { - self.release_after_failure(&container, container_created, logger); - return Err(ScriptResponse::error(&format!( - "Failed to pin the network proxy host inside the container: {}. \ - The proxy would be unreachable, so the script was not run.", - reason - ))); - } - pinned = true; - } else if !container_created { - let clear_stale_pin_command = Self::build_hosts_unpin_command(); - let stale_pin_error = match container.attach_capture( - &clear_stale_pin_command, - "/", - &[], - true, - Some(HOSTS_COMMAND_TIMEOUT), - firewall, - ) { - Ok((0, _, _)) => None, - Ok((code, _, stderr)) => { - Some(Self::hosts_command_failure("clearing", code, &stderr)) - } - Err(e) => Some(e.to_string()), - }; - - if let Some(reason) = stale_pin_error { - self.release_after_failure(&container, container_created, logger); - return Err(ScriptResponse::error(&format!( - "Failed to clear a stale network proxy pin from the container's \ - /etc/hosts: {}. The script was not run, because it could have resolved \ - the pinned hostname to an address this policy did not authorize.", - reason - ))); - } + // A reused container can carry a pin left by an older run. + let stale_pin_error = Self::clear_stale_hosts_pin(container_created, |command| { + container + .attach_capture( + command, + "/", + &[], + true, + Some(HOSTS_COMMAND_TIMEOUT), + firewall, + ) + .map_err(|e| e.to_string()) + }); + if let Err(reason) = stale_pin_error { + self.release_after_failure(&container, container_created, logger); + return Err(ScriptResponse::error(&format!( + "Failed to clear a stale network proxy pin from the container's \ + /etc/hosts: {}. The script was not run, because it could have resolved \ + the pinned hostname to an address this policy did not authorize.", + reason + ))); } // `script_timeout == 0` means "no timeout" per the SDK contract. @@ -760,8 +653,7 @@ impl LxcScriptRunner { } else { Some(Duration::from_millis(u64::from(request.script_timeout))) }; - let mut exec_env = resolved_env(request); - crate::mxc_common::proxy_env::apply_proxy_env(&mut exec_env, &request.policy.network_proxy); + let exec_env = resolved_env(request); Ok(PreparedSandbox { fw_manager, @@ -769,7 +661,6 @@ impl LxcScriptRunner { container, container_created, firewall, - pinned, released: false, force_stopped: false, stop_failed: false, @@ -816,25 +707,10 @@ impl LxcScriptRunner { response } - fn build_hosts_pin_command(hosts_line: &str) -> String { - // LXC may bind-mount `/etc/hosts`; rewrite it in place. BusyBox - // images have `grep` and `printf`, and kept lines stay in a shell - // variable to avoid following a predictable scratch-file symlink. - // Shell command substitution strips trailing newlines; `printf` writes - // one back only when content survived the filter. - format!( - "{}{{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; \ - printf '%s {marker}\\n' '{hosts_line}'; }} > /etc/hosts", - Self::hosts_read_prologue(), - marker = HOSTS_PIN_MARKER, - hosts_line = hosts_line - ) - } - fn hosts_read_prologue() -> String { - // Read `/etc/hosts` before the redirect opens and truncates it. `grep` - // status 1 means no line was selected, missing `grep` reports 127, and - // `grep` reports absent and unreadable files as status 2. + // Check for a marker before opening the redirect. A marker-free file + // must not be rewritten. `grep` status 1 means no match, while a + // missing tool or unreadable file must fail before launching the script. format!( "if [ -h /etc/hosts ]; then \ printf 'mxc: refusing to rewrite /etc/hosts: it is a symbolic link\\n' >&2; \ @@ -842,6 +718,16 @@ impl LxcScriptRunner { fi; \ kept=''; \ if [ -e /etc/hosts ]; then \ + if grep -q '{marker}' /etc/hosts 2>/dev/null; then :; \ + else \ + status=$?; \ + if [ \"$status\" -gt 1 ]; then \ + printf 'mxc: refusing to rewrite /etc/hosts: reading it exited %s\\n' \ + \"$status\" >&2; \ + exit \"$status\"; \ + fi; \ + exit 0; \ + fi; \ kept=$(grep -v '{marker}' /etc/hosts 2>/dev/null); \ status=$?; \ if [ \"$status\" -gt 1 ]; then \ @@ -849,6 +735,7 @@ impl LxcScriptRunner { \"$status\" >&2; \ exit \"$status\"; \ fi; \ + else exit 0; \ fi; ", marker = HOSTS_PIN_MARKER ) @@ -861,12 +748,26 @@ impl LxcScriptRunner { ) } - fn hosts_command_failure(verb: &str, exit_code: i32, stderr: &str) -> String { + fn clear_stale_hosts_pin( + container_created: bool, + attach: impl FnOnce(&str) -> Result<(i32, String, String), String>, + ) -> Result<(), String> { + if container_created { + return Ok(()); + } + let (code, _, stderr) = attach(&Self::build_hosts_unpin_command())?; + if code != 0 { + return Err(Self::hosts_command_failure(code, &stderr)); + } + Ok(()) + } + + fn hosts_command_failure(exit_code: i32, stderr: &str) -> String { let detail = stderr.trim(); if detail.is_empty() { - return format!("{} /etc/hosts exited with {}", verb, exit_code); + return format!("clearing /etc/hosts exited with {}", exit_code); } - format!("{} /etc/hosts exited with {}: {}", verb, exit_code, detail) + format!("clearing /etc/hosts exited with {}: {}", exit_code, detail) } } @@ -881,7 +782,6 @@ struct PreparedSandbox { container_created: bool, firewall: ContainerFirewall, - pinned: bool, released: bool, #[cfg_attr(not(target_os = "linux"), allow(dead_code))] @@ -901,9 +801,7 @@ struct PreparedSandbox { } impl PreparedSandbox { - /// Remove the state that can only come out while the container is up: the - /// pin is rewritten by a command inside it, and the chains are addressed - /// through its init PID. + /// Remove chains addressed through the container's init PID while it is up. fn remove_in_container_state( &mut self, cleanup_policy: bool, @@ -932,33 +830,6 @@ impl PreparedSandbox { } } - if self.pinned && cleanup_policy { - let clear_run_pin_command = LxcScriptRunner::build_hosts_unpin_command(); - let unpin_error = match self.container.attach_capture( - &clear_run_pin_command, - "/", - &[], - true, - Some(HOSTS_COMMAND_TIMEOUT), - self.firewall, - ) { - Ok((0, _, _)) => None, - Ok((code, _, stderr)) => Some(LxcScriptRunner::hosts_command_failure( - "clearing", code, &stderr, - )), - Err(e) => Some(e.to_string()), - }; - - match unpin_error { - Some(reason) => { - let failure = format!("failed to clear the proxy host pin: {}", reason); - let _ = writeln!(logger, "Warning: {}", failure); - failures.push(failure); - } - None => self.pinned = false, - } - } - // These chains are addressed by the container's init PID, so they have // to come out before the container does. if self.fw_manager.rules_applied() && cleanup_policy { @@ -989,7 +860,7 @@ impl PreparedSandbox { failures } - /// Run the completion-path release — pin, then rules, then container — + /// Run the completion-path release — rules, then container — /// repeating only the steps an earlier call failed to complete. fn tear_down( &mut self, @@ -999,10 +870,8 @@ impl PreparedSandbox { ) -> Vec { let mut failures = Vec::new(); - // Destroying takes the network namespace and the rootfs down with it, - // so the chains in one and the pin in the other are already going. - // Chains that were never hooked live on the host and still have to - // come out below. + // Destroying takes the network namespace down with it. Chains that + // were never hooked live on the host and still have to come out below. let destroying = destroy_on_exit && !self.released; let skipped_in_container = destroying && self.fw_manager.is_hooked(); if !skipped_in_container { @@ -1063,11 +932,6 @@ impl PreparedSandbox { /// Give up the state that needed the container, now that it is gone. fn note_namespace_gone(&mut self) { - // Rewriting the pin takes a command inside a running container, and - // there is no longer one; a container kept for reuse has the stale pin - // cleared by the next run. - self.pinned = false; - // These chains went down with the network namespace, and the init PID // that addressed them can be recycled into another namespace, where the // same chain name may belong to a later run. @@ -1080,12 +944,6 @@ impl PreparedSandbox { } } -pub const LXC_CAPABILITIES_MODE_UNSUPPORTED: &str = - "LXC: network.enforcementMode='capabilities' (the default) selects Windows AppContainer \ - capability SIDs, which LXC has no mechanism for. Accepting it would enforce the policy by \ - some means other than the one named. Use the supported network.egress / network.ingress \ - fields instead; no registered LXC contract accepts network.enforcementMode."; - pub const LXC_RUNTIME_PROXY_UNSUPPORTED: &str = "LXC: runtimeConfig.networkProxy is not supported. It must name a loopback endpoint, which \ inside the container's own network namespace is the container rather than the host. LXC \ @@ -1101,13 +959,22 @@ pub const LXC_INHERIT_STDIO_UNSUPPORTED: &str = pub const LXC_STREAMING_LINUX_ONLY: &str = "LXC: sandboxes can only be launched on Linux."; -fn asks_for_capabilities_enforcement(request: &ExecutionRequest) -> bool { - !uses_directional_keys(&request.policy) - && request.policy.network_mode_specified - && matches!( - request.policy.network_enforcement_mode, - NetworkEnforcementMode::Capabilities - ) +fn retired_network_field(policy: &ContainerPolicy) -> Option<&'static str> { + if policy.default_network_policy != NetworkPolicy::Block { + Some("network.defaultPolicy") + } else if policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities { + Some("network.enforcementMode") + } else if policy.allow_local_network { + Some("network.allowLocalNetwork") + } else if !policy.allowed_hosts.is_empty() { + Some("network.allowedHosts") + } else if !policy.blocked_hosts.is_empty() { + Some("network.blockedHosts") + } else if policy.network_proxy.is_enabled() { + Some("network.proxy") + } else { + None + } } fn lxc_network_policy_support() -> NetworkPolicySupport { @@ -1122,10 +989,13 @@ impl ScriptRunner for LxcScriptRunner { if request.policy.runtime_network_proxy_specified { return Err(ScriptResponse::error(LXC_RUNTIME_PROXY_UNSUPPORTED)); } - validate_network_policy_support(request, lxc_network_policy_support())?; - if asks_for_capabilities_enforcement(request) { - return Err(ScriptResponse::error(LXC_CAPABILITIES_MODE_UNSUPPORTED)); + if let Some(field) = retired_network_field(&request.policy) { + return Err(ScriptResponse::error(&format!( + "LXC: {field} is retired and cannot be enforced; use network.egress / \ + network.ingress instead." + ))); } + validate_network_policy_support(request, lxc_network_policy_support())?; Ok(()) } @@ -1724,6 +1594,22 @@ mod tests { } } + fn egress_policy_for_tests() -> ContainerPolicy { + ContainerPolicy { + network_egress: Some(NetworkEgressPolicy { + allow: vec![crate::mxc_common::models::NetworkRule { + to: vec![crate::mxc_common::models::NetworkPeer { + cidr: "192.0.2.10/32".parse().expect("test CIDR"), + except: Vec::new(), + }], + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + } + } + #[test] fn a_failed_egress_apply_tears_down_its_debris_even_under_preserve_policy() { let fake = crate::lxc_common::network_iptables::test_firewall::install(); @@ -1750,14 +1636,10 @@ mod tests { ..LifecycleConfig::default() }, ); - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: vec!["192.0.2.10".to_string()], - ..Default::default() - }; + let policy = egress_policy_for_tests(); let mut logger = Logger::new(Mode::Buffer); - let outcome = runner.set_up_network_rules_for_v07( + let outcome = runner.set_up_network_rules( "preserve-rollback-test", EgressHookPoint::Unhooked, None, @@ -1812,10 +1694,8 @@ mod tests { ); } - /// A password in the URL would reach `lxc-attach` as an argument, and - /// process arguments are world-readable through `/proc//cmdline`. #[test] - fn prepare_refuses_a_proxy_url_carrying_credentials() { + fn prepare_refuses_a_retired_proxy_url_carrying_credentials() { let runner = LxcScriptRunner::new( &LxcConfig { distribution: "alpine".to_string(), @@ -1840,11 +1720,11 @@ mod tests { let refusal = runner .prepare(&request, &mut logger) .err() - .expect("a proxy URL carrying a password must not become an lxc-attach argument"); + .expect("retired proxy URLs must not reach lxc-attach"); assert!( - refusal.error_message.contains("must not carry credentials"), - "the refusal must name the cause; got: {:?}", + refusal.error_message.contains("network.proxy"), + "the refusal must name the retired field; got: {:?}", refusal.error_message ); assert!( @@ -1883,7 +1763,6 @@ mod tests { fn tear_down_repeats_only_the_steps_that_failed() { let _fake = crate::lxc_common::network_iptables::test_firewall::install(); let mut prepared = prepared_with_applied_rules("tear-down-idempotent"); - prepared.pinned = true; let mut first = Logger::new(Mode::Buffer); prepared.tear_down(true, true, &mut first); @@ -1902,15 +1781,8 @@ mod tests { second.get_buffer() ); - // The fixture names a container that was never created, so the unpin and - // the destroy both fail here and both must be attempted again. - assert!( - first.get_buffer().contains("proxy host pin") - && second.get_buffer().contains("proxy host pin"), - "an unpin that failed must be retried; logs: {:?} / {:?}", - first.get_buffer(), - second.get_buffer() - ); + // The fixture names a container that was never created, so destroying + // it fails on both attempts. assert!( second.get_buffer().contains("Destroying container"), "a destroy that failed must be retried; log: {:?}", @@ -1923,7 +1795,6 @@ mod tests { let fake = crate::lxc_common::network_iptables::test_firewall::install(); let mut prepared = prepared_with_applied_rules("stop-refused"); apply_hooked_egress_rules(&mut prepared, 4242); - prepared.pinned = true; // The fixture names a container that was never created, so `lxc-stop` // has nothing to stop and reports the failure this test needs. @@ -1957,11 +1828,7 @@ mod tests { /// manager drives `nsenter` through a runner the iptables fake does not /// intercept, so its own module owns that coverage. fn apply_hooked_egress_rules(prepared: &mut PreparedSandbox, netns_pid: u32) { - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: vec!["192.0.2.10".to_string()], - ..Default::default() - }; + let policy = egress_policy_for_tests(); let mut logger = Logger::new(Mode::Buffer); prepared.fw_manager = NetworkIptablesManager::new( @@ -1970,7 +1837,7 @@ mod tests { ); prepared .fw_manager - .apply_legacy_rules(&policy, &mut logger) + .apply_firewall_rules(&policy, &mut logger) .expect("the fake firewall accepts every command"); } @@ -1979,7 +1846,6 @@ mod tests { let _fake = crate::lxc_common::network_iptables::test_firewall::install(); let mut prepared = prepared_with_applied_rules("namespace-gone"); apply_hooked_egress_rules(&mut prepared, 4242); - prepared.pinned = true; assert!( prepared.fw_manager.rules_applied(), @@ -1992,10 +1858,6 @@ mod tests { !prepared.fw_manager.rules_applied(), "the netns took the chain with it, so its dead init PID must never be nsentered" ); - assert!( - !prepared.pinned, - "the pin cannot be rewritten without a running container" - ); } #[test] @@ -2177,21 +2039,11 @@ mod tests { let name = format!("mxc-{}-{}-{}", tag, std::process::id(), uuid_simple()); let runner = LxcScriptRunner::new(&LxcConfig::default(), &name, &LifecycleConfig::default()); - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: vec!["192.0.2.10".to_string()], - ..Default::default() - }; + let policy = egress_policy_for_tests(); let mut logger = Logger::new(Mode::Buffer); let (fw_manager, ingress_manager) = runner - .set_up_network_rules_for_v07( - &name, - EgressHookPoint::Unhooked, - None, - &policy, - &mut logger, - ) + .set_up_network_rules(&name, EgressHookPoint::Unhooked, None, &policy, &mut logger) .expect("the fake firewall accepts every command"); assert!( fw_manager.rules_applied(), @@ -2204,7 +2056,6 @@ mod tests { container: LxcContainer::new(&name, Some("/var/lib/lxc")), container_created: false, firewall: ContainerFirewall::Absent, - pinned: false, released: false, force_stopped: false, stop_failed: false, @@ -2218,29 +2069,55 @@ mod tests { } #[test] - fn a_07_network_section_left_on_capabilities_is_refused() { + fn a_retired_enforcement_mode_is_refused() { let policy = ContainerPolicy { - network_mode_specified: true, + network_enforcement_mode: NetworkEnforcementMode::Firewall, ..Default::default() }; let refusal = validating_runner() .validate_runner(&request_with_policy(policy)) - .expect_err("LXC has no capability-SID mechanism to enforce through"); - - assert_eq!(refusal.error_message, LXC_CAPABILITIES_MODE_UNSUPPORTED); - } - #[test] - fn a_07_network_section_naming_the_firewall_is_accepted() { - let policy = ContainerPolicy { - network_mode_specified: true, - network_enforcement_mode: NetworkEnforcementMode::Firewall, - ..Default::default() - }; + .expect_err("a retired enforcement mode cannot configure LXC"); + + assert!(refusal.error_message.contains("network.enforcementMode")); + } + #[test] + fn retired_fields_are_refused_before_container_creation() { + type RetiredFieldCase = (&'static str, fn(&mut ContainerPolicy)); + let cases: &[RetiredFieldCase] = &[ + ("network.defaultPolicy", |p| { + p.default_network_policy = NetworkPolicy::Allow + }), + ("network.enforcementMode", |p| { + p.network_enforcement_mode = NetworkEnforcementMode::Both + }), + ("network.allowLocalNetwork", |p| { + p.allow_local_network = true + }), + ("network.allowedHosts", |p| { + p.allowed_hosts.push("192.0.2.10".into()) + }), + ("network.blockedHosts", |p| { + p.blocked_hosts.push("192.0.2.10".into()) + }), + ("network.proxy", |p| { + p.network_proxy.builtin_test_server = true + }), + ]; - assert!(validating_runner() - .validate_runner(&request_with_policy(policy)) - .is_ok()); + for &(field, set_field) in cases { + let mut policy = egress_policy_for_tests(); + set_field(&mut policy); + let runner = runner_for_guard_tests(field); + let mut logger = Logger::new(Mode::Buffer); + let response = runner.run_internal(&request_with_policy(policy), &mut logger); + assert!( + response.error_message.contains(field), + "{field} must fail before creating a container: {}", + response.error_message + ); + assert!(!logger.get_buffer().contains("Creating LXC container")); + } } #[test] @@ -2251,7 +2128,7 @@ mod tests { } #[test] - fn a_08_directional_network_section_is_accepted() { + fn a_directional_network_section_is_accepted() { let policy = ContainerPolicy { network_egress: Some(NetworkEgressPolicy::default()), ..Default::default() @@ -2330,13 +2207,8 @@ mod tests { /// `process.env` resolution, which schema 0.9 gave a default block. mod env { use super::*; - use crate::mxc_common::models::DefaultEnvCompatibility; - - fn request(compatibility: DefaultEnvCompatibility) -> ExecutionRequest { - ExecutionRequest { - default_env_compatibility: compatibility, - ..Default::default() - } + fn request() -> ExecutionRequest { + ExecutionRequest::default() } fn value<'a>(entries: &'a [String], key: &str) -> Option<&'a str> { @@ -2355,7 +2227,7 @@ mod tests { #[test] fn an_omitted_env_gets_the_default_block() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = "/workspace".into(); let entries = resolved_env(&r); @@ -2376,21 +2248,21 @@ mod tests { #[test] fn an_explicitly_empty_env_stays_empty() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec![]); assert!(resolved_env(&r).is_empty()); } #[test] fn a_supplied_env_is_used_verbatim() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FOO=bar".into()]); assert_eq!(resolved_env(&r), vec!["FOO=bar".to_string()]); } #[test] fn inherit_default_env_layers_over_the_default_block() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FOO=bar".into(), "PATH=/only/mine".into()]); r.inherit_default_env = true; let entries = resolved_env(&r); @@ -2408,7 +2280,7 @@ mod tests { #[test] fn home_follows_the_directory_the_child_starts_in() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = "/workspace".into(); assert_eq!(value(&resolved_env(&r), "HOME"), Some("/workspace")); @@ -2422,7 +2294,7 @@ mod tests { ("a/../b", "/b"), ("/x/../y/./z", "/y/z"), ] { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = cwd.into(); assert_eq!(value(&resolved_env(&r), "HOME"), Some(expected)); @@ -2436,7 +2308,7 @@ mod tests { /// `HOME`. #[test] fn a_policy_grant_alone_does_not_become_home() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = String::new(); // A real directory, so the shared resolver's `is_dir` probe would @@ -2452,7 +2324,7 @@ mod tests { #[test] fn home_and_the_attach_directory_agree() { for cwd in ["", "/workspace", "work", "./work", "a/../b", "/x/../y/./z"] { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; r.working_directory = cwd.into(); assert_eq!( @@ -2465,7 +2337,7 @@ mod tests { #[test] fn a_caller_entry_without_a_value_is_dropped_by_the_merge() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FEATURE_FLAG".into(), "FOO=bar".into()]); // Verbatim: carried through, dropped when `lxc-attach` args build. @@ -2635,52 +2507,14 @@ mod tests { } #[test] - fn the_hosts_pin_command_writes_the_requested_mapping() { - let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); - - assert!( - command.contains("'10.0.0.5 proxy.example.com'"), - "the command must carry the mapping verbatim, got: {command}" - ); - assert!( - command.contains("/etc/hosts"), - "the command must target /etc/hosts, got: {command}" - ); - } - - #[test] - fn the_hosts_pin_command_strips_its_own_previous_entries_first() { - let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); - - assert!( - command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), - "the command must remove prior pins before writing; got: {command}" - ); + fn a_failed_hosts_command_reports_a_reason_it_can_actually_supply() { + let reason = LxcScriptRunner::hosts_command_failure(1, ""); assert!( - command.matches(HOSTS_PIN_MARKER).count() >= 2, - "the written line must carry the marker that the strip looks for; got: {command}" + reason.contains('1'), + "the reason must name the exit code: {reason}" ); - } - - #[test] - fn a_failed_hosts_command_reports_a_reason_it_can_actually_supply() { - for verb in ["writing", "clearing"] { - let reason = LxcScriptRunner::hosts_command_failure(verb, 1, ""); - - assert!( - reason.contains('1'), - "the reason must name the exit code; got: {reason}" - ); - assert!( - !reason.trim_end().ends_with(':'), - "the reason must not trail a separator promising more; got: {reason:?}" - ); - assert_eq!( - reason.trim_end(), - reason, - "the reason must not trail whitespace where a stderr used to go; got: {reason:?}" - ); - } + assert!(!reason.trim_end().ends_with(':')); + assert_eq!(reason.trim_end(), reason); } /// A symlinked `/etc/hosts` and an unreadable one differ only in what the @@ -2688,7 +2522,6 @@ mod tests { #[test] fn a_failed_hosts_command_carries_the_helpers_own_diagnosis() { let reason = LxcScriptRunner::hosts_command_failure( - "writing", 4, "mxc: refusing to rewrite /etc/hosts: it is a symbolic link\n", ); @@ -2709,14 +2542,44 @@ mod tests { } #[test] - fn a_failed_hosts_command_distinguishes_the_step_that_failed() { - let pinning = LxcScriptRunner::hosts_command_failure("writing", 2, ""); - let clearing = LxcScriptRunner::hosts_command_failure("clearing", 2, ""); + fn new_containers_do_not_attach_to_check_for_stale_hosts_pins() { + LxcScriptRunner::clear_stale_hosts_pin(true, |_| { + panic!("a newly created container has no previous MXC pin") + }) + .expect("new containers need no migration"); + } - assert_ne!( - pinning, clearing, - "the two steps must not produce the same reason; got: {pinning:?}" - ); + #[test] + fn reused_containers_attach_to_check_for_stale_hosts_pins() { + let mut attached = false; + LxcScriptRunner::clear_stale_hosts_pin(false, |command| { + attached = true; + assert!(command.contains(HOSTS_PIN_MARKER)); + Ok((0, String::new(), String::new())) + }) + .expect("a successful attach check should clear the pin"); + assert!(attached); + } + + #[test] + fn reused_containers_refuse_failed_unpins() { + let mut calls = 0; + for result in [ + Ok((4, String::new(), "symbolic link".to_string())), + Err("attach failed".to_string()), + ] { + let outcome = LxcScriptRunner::clear_stale_hosts_pin(false, |command| { + calls += 1; + assert!(command.contains(HOSTS_PIN_MARKER)); + result + }); + match calls { + 1 => assert!(outcome.unwrap_err().contains("symbolic link")), + 2 => assert_eq!(outcome, Err("attach failed".to_string())), + _ => unreachable!(), + } + } + assert_eq!(calls, 2, "failed unpins must reach the container"); } #[test] @@ -2724,14 +2587,15 @@ mod tests { let command = LxcScriptRunner::build_hosts_unpin_command(); assert!( - command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), + command.contains(&format!("grep -q '{}'", HOSTS_PIN_MARKER)) + && command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), "the command must filter out every marked line; got: {command}" ); assert_eq!( command.matches(HOSTS_PIN_MARKER).count(), - 1, - "the marker should appear only in the filter; got: {command}" + 2, + "the marker should appear only in the probe and filter; got: {command}" ); } @@ -2749,78 +2613,29 @@ mod tests { ); } - #[test] - fn the_hosts_pin_command_rewrites_the_file_in_place_rather_than_replacing_it() { - let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); - - assert!( - command.contains("> /etc/hosts"), - "the command must redirect into the existing file, got: {command}" - ); - assert!( - !command.contains("mv "), - "the command must not replace the inode, got: {command}" - ); - } - - #[test] - fn the_hosts_pin_command_uses_only_busybox_available_tools() { - let command = LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"); - - for forbidden in ["sed ", "awk ", "tee ", "sponge "] { - assert!( - !command.contains(forbidden), - "the command must not depend on {forbidden:?}, got: {command}" - ); - } - } - #[test] fn the_hosts_commands_stage_nothing_in_a_container_writable_directory() { - let commands = [ - LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"), - LxcScriptRunner::build_hosts_unpin_command(), - ]; - - for command in commands { - for scratch in ["/tmp/", "/var/tmp/", "/dev/shm/", "/run/"] { - assert!( - !command.contains(scratch), - "the command must not stage under {scratch:?}, got: {command}" - ); - } - assert_eq!( - command.matches("> /etc/hosts").count(), - 1, - "/etc/hosts must be the only redirect target; got: {command}" + let command = LxcScriptRunner::build_hosts_unpin_command(); + for scratch in ["/tmp/", "/var/tmp/", "/dev/shm/", "/run/"] { + assert!( + !command.contains(scratch), + "the command must not stage under {scratch:?}, got: {command}" ); } + assert_eq!(command.matches("> /etc/hosts").count(), 1); } #[test] fn the_hosts_commands_build_their_content_before_truncating_the_target() { - let commands = [ - LxcScriptRunner::build_hosts_pin_command("10.0.0.5 proxy.example.com"), - LxcScriptRunner::build_hosts_unpin_command(), - ]; - - for command in commands { - let capture = command.find("kept=$(").unwrap_or_else(|| { - panic!("the command must stage into a variable; got: {command}") - }); - let redirect = command - .find("> /etc/hosts") - .unwrap_or_else(|| panic!("the command must target /etc/hosts; got: {command}")); - - assert!( - capture < redirect, - "the content must be captured before /etc/hosts is truncated; got: {command}" - ); - assert!( - !command[redirect..].contains("grep"), - "no file-reading command may run after the target is truncated; got: {command}" - ); - } + let command = LxcScriptRunner::build_hosts_unpin_command(); + let capture = command + .find("kept=$(") + .unwrap_or_else(|| panic!("the command must stage into a variable; got: {command}")); + let redirect = command + .find("> /etc/hosts") + .unwrap_or_else(|| panic!("the command must target /etc/hosts; got: {command}")); + assert!(capture < redirect); + assert!(!command[redirect..].contains("grep")); } use crate::mxc_common::models::{ @@ -2918,7 +2733,7 @@ mod tests { runner .validate_runner(&egress_only_directional_request()) .is_ok(), - "a 0.8 config stating only network.egress must reach the backend; rejecting it \ + "a directional config stating only network.egress must reach the backend; rejecting it \ here would make every directional egress policy unusable on LXC" ); } @@ -2989,10 +2804,8 @@ mod tests { let response = runner.run_internal(&request, &mut logger); assert!( - response - .error_message - .contains("must not carry credentials"), - "the runner must refuse a credential-bearing proxy URL even when the parser \ + response.error_message.contains("network.proxy"), + "the runner must refuse a retired proxy URL even when the parser \ never saw the request, got: {}", response.error_message ); @@ -3023,7 +2836,7 @@ mod tests { } #[test] - fn a_credential_free_proxy_url_is_not_refused_by_the_credential_guard() { + fn a_credential_free_legacy_proxy_is_still_refused() { let runner = runner_for_guard_tests("credential-free"); let request = request_with_proxy_url("http://proxy.example.com:8080"); let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); @@ -3031,16 +2844,14 @@ mod tests { let response = runner.run_internal(&request, &mut logger); assert!( - !response - .error_message - .contains("must not carry credentials"), - "a proxy URL without userinfo must clear the credential guard, got: {}", + response.error_message.contains("network.proxy"), + "a proxy URL without userinfo is still retired, got: {}", response.error_message ); } #[test] - fn the_credential_refusal_happens_before_any_container_work() { + fn the_legacy_proxy_refusal_happens_before_any_container_work() { let runner = runner_for_guard_tests("refusal-ordering"); let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); @@ -3364,8 +3175,6 @@ mod hosts_command_execution { const ORIGINAL: &str = "127.0.0.1 localhost\n::1 ip6-localhost\n10.0.0.9 build.internal\n"; - const PIN_LINE: &str = "10.0.0.5 proxy.example.com"; - struct Scratch { dir: PathBuf, } @@ -3415,6 +3224,27 @@ mod hosts_command_execution { ) } + fn path_with_failing_filter(&self) -> String { + use std::os::unix::fs::PermissionsExt; + + let bin = self.dir.join("bin"); + std::fs::create_dir_all(&bin).expect("the shim directory should be creatable"); + let grep = bin.join("grep"); + std::fs::write( + &grep, + "#!/bin/sh\nif [ \"$1\" = -q ]; then exit 0; fi\nexit 2\n", + ) + .expect("the shim should be writable"); + std::fs::set_permissions(&grep, std::fs::Permissions::from_mode(0o755)) + .expect("the shim should be executable"); + + format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ) + } + fn path_without_grep(&self) -> String { let empty = self.dir.join("empty"); std::fs::create_dir_all(&empty).expect("the empty directory should be creatable"); @@ -3449,81 +3279,48 @@ mod hosts_command_execution { .expect("the shell should exit rather than be signalled") } - fn pin(hosts: &Path) -> String { - retarget(&LxcScriptRunner::build_hosts_pin_command(PIN_LINE), hosts) - } - fn unpin(hosts: &Path) -> String { retarget(&LxcScriptRunner::build_hosts_unpin_command(), hosts) } #[test] - fn pinning_adds_the_mapping_and_keeps_every_line_the_image_shipped() { - let scratch = Scratch::new("keeps"); + fn marker_free_hosts_is_not_rewritten_on_reuse() { + let scratch = Scratch::new("nomarker"); scratch.write_hosts(ORIGINAL); - - let code = run(&pin(&scratch.hosts()), None); - let after = scratch.read_hosts(); - - assert_eq!(code, 0, "pinning a readable file should succeed"); - for line in ORIGINAL.lines() { - assert!( - after.contains(line), - "the pin dropped {line:?}; file is now:\n{after}" - ); - } - assert!( - after.contains(&format!("{PIN_LINE} {HOSTS_PIN_MARKER}")), - "the pin never landed; file is now:\n{after}" - ); - } - - #[test] - fn re_pinning_replaces_the_previous_entry_instead_of_stacking_on_it() { - let scratch = Scratch::new("repin"); - scratch.write_hosts(ORIGINAL); - - assert_eq!(run(&pin(&scratch.hosts()), None), 0); - assert_eq!(run(&pin(&scratch.hosts()), None), 0); - let after = scratch.read_hosts(); - + let file = std::fs::File::options() + .write(true) + .open(scratch.hosts()) + .expect("fixture file should open"); + let original_time = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(10); + file.set_modified(original_time) + .expect("fixture timestamp should be settable"); + + assert_eq!(run(&unpin(&scratch.hosts()), None), 0); + assert_eq!(scratch.read_hosts(), ORIGINAL); assert_eq!( - after.matches(HOSTS_PIN_MARKER).count(), - 1, - "a second pin should replace the first, not stack; file is now:\n{after}" - ); - assert!( - after.contains("10.0.0.9 build.internal"), - "re-pinning dropped an unrelated entry; file is now:\n{after}" + std::fs::metadata(scratch.hosts()) + .expect("fixture should still exist") + .modified() + .expect("timestamp should be readable"), + original_time, + "a marker-free file must not be rewritten" ); } #[test] - fn a_failed_read_leaves_the_file_byte_for_byte_as_it_was() { - let scratch = Scratch::new("failread"); - scratch.write_hosts(ORIGINAL); - let path = scratch.path_with_failing_grep(2); - - let code = run(&pin(&scratch.hosts()), Some(&path)); - - assert_eq!( - scratch.read_hosts(), - ORIGINAL, - "a failed read truncated the file it could not read" - ); - assert_ne!( - code, 0, - "a failed read must fail the command, not report a pin it never made" - ); + fn missing_hosts_file_is_not_created_on_reuse() { + let scratch = Scratch::new("missing"); + assert_eq!(run(&unpin(&scratch.hosts()), None), 0); + assert!(!scratch.hosts().exists()); } #[test] - fn a_missing_grep_leaves_the_file_byte_for_byte_as_it_was() { + fn a_missing_grep_while_unpinning_leaves_the_file_byte_for_byte_as_it_was() { let scratch = Scratch::new("nogrep"); scratch.write_hosts(ORIGINAL); let path = scratch.path_without_grep(); - let code = run(&pin(&scratch.hosts()), Some(&path)); + let code = run(&unpin(&scratch.hosts()), Some(&path)); assert_eq!( scratch.read_hosts(), @@ -3550,45 +3347,37 @@ mod hosts_command_execution { } #[test] - fn a_file_of_nothing_but_previous_pins_is_rewritten_rather_than_refused() { - let scratch = Scratch::new("allmarked"); - scratch.write_hosts(&format!("10.0.0.4 proxy.example.com {HOSTS_PIN_MARKER}\n")); - - let code = run(&pin(&scratch.hosts()), None); - let after = scratch.read_hosts(); + fn a_failed_filter_after_a_matching_probe_does_not_truncate_hosts() { + let scratch = Scratch::new("failedfilter"); + let original = format!("{ORIGINAL}10.0.0.4 proxy.example.com {HOSTS_PIN_MARKER}\n"); + scratch.write_hosts(&original); + let path = scratch.path_with_failing_filter(); - assert_eq!( - code, 0, - "a file of only stale pins should still be pinnable" - ); - assert_eq!( - after.trim(), - format!("{PIN_LINE} {HOSTS_PIN_MARKER}"), - "the stale pin should be gone and the new one present" - ); + assert_ne!(run(&unpin(&scratch.hosts()), Some(&path)), 0); + assert_eq!(scratch.read_hosts(), original); } #[test] - fn an_image_with_no_hosts_file_is_pinned_rather_than_refused() { - let scratch = Scratch::new("nofile"); + fn unpinning_a_file_of_nothing_but_previous_pins_leaves_it_empty() { + let scratch = Scratch::new("allmarked"); + scratch.write_hosts(&format!("10.0.0.4 proxy.example.com {HOSTS_PIN_MARKER}\n")); - let code = run(&pin(&scratch.hosts()), None); + let code = run(&unpin(&scratch.hosts()), None); + let after = scratch.read_hosts(); assert_eq!( code, 0, - "a missing hosts file should be created, not refused" - ); - assert_eq!( - scratch.read_hosts().trim(), - format!("{PIN_LINE} {HOSTS_PIN_MARKER}") + "a file of only stale pins should still be clearable" ); + assert_eq!(after.trim(), "", "the stale pin should be removed"); } #[test] fn unpinning_removes_the_pin_and_keeps_everything_else() { let scratch = Scratch::new("unpin"); - scratch.write_hosts(ORIGINAL); - assert_eq!(run(&pin(&scratch.hosts()), None), 0); + scratch.write_hosts(&format!( + "{ORIGINAL}10.0.0.5 proxy.example.com {HOSTS_PIN_MARKER}\n" + )); let code = run(&unpin(&scratch.hosts()), None); let after = scratch.read_hosts(); @@ -3607,40 +3396,22 @@ mod hosts_command_execution { } #[test] - fn pinning_refuses_a_dangling_symlink_instead_of_creating_its_target() { + fn unpinning_refuses_a_dangling_symlink_instead_of_creating_its_target() { let scratch = Scratch::new("dangling"); let target = scratch.dir.join("attacker-named"); std::os::unix::fs::symlink(&target, scratch.hosts()) .expect("the scratch symlink should be creatable"); - let code = run(&pin(&scratch.hosts()), None); + let code = run(&unpin(&scratch.hosts()), None); assert_ne!(code, 0, "writing through a symlink should be refused"); assert!( !target.exists(), - "the refused pin still created {}", + "the refused unpin still created {}", target.display() ); } - #[test] - fn pinning_refuses_a_symlink_rather_than_writing_through_it() { - let scratch = Scratch::new("symlink"); - let target = scratch.dir.join("elsewhere"); - std::fs::write(&target, ORIGINAL).expect("the target should be writable"); - std::os::unix::fs::symlink(&target, scratch.hosts()) - .expect("the scratch symlink should be creatable"); - - let code = run(&pin(&scratch.hosts()), None); - - assert_ne!(code, 0, "writing through a symlink should be refused"); - assert_eq!( - std::fs::read_to_string(&target).expect("the target should still be readable"), - ORIGINAL, - "the refused pin still rewrote the symlink target" - ); - } - #[test] fn unpinning_refuses_a_symlink_rather_than_emptying_its_target() { let scratch = Scratch::new("unpinsymlink"); @@ -3668,7 +3439,7 @@ mod hosts_command_execution { let output = std::process::Command::new("/bin/sh") .arg("-c") - .arg(pin(&scratch.hosts())) + .arg(unpin(&scratch.hosts())) .output() .expect("/bin/sh should be executable"); diff --git a/src/mxc-sdk/src/backends/lxc/common/network_ingress.rs b/src/mxc-sdk/src/backends/lxc/common/network_ingress.rs index 4905fd9df..23da80707 100644 --- a/src/mxc-sdk/src/backends/lxc/common/network_ingress.rs +++ b/src/mxc-sdk/src/backends/lxc/common/network_ingress.rs @@ -8,11 +8,10 @@ use std::process::Command; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ContainerPolicy, NetworkAction, NetworkIngressPolicy}; +use crate::mxc_common::models::{ContainerPolicy, NetworkAction}; use crate::lxc_common::network_iptables::{ - ingress_chain_name_for, plan_network, uses_directional_keys, HostIpv6State, Ip6tablesStatus, - NetworkIptablesManager, + ingress_chain_name_for, plan_network, HostIpv6State, Ip6tablesStatus, NetworkIptablesManager, }; #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -302,22 +301,8 @@ impl IngressManager { } } - fn stated_ingress( - policy: &ContainerPolicy, - uses_directional_keys: bool, - ) -> Option<&NetworkIngressPolicy> { - if uses_directional_keys { - policy.network_ingress.as_ref() - } else { - None - } - } - - fn permissive_inbound_field( - policy: &ContainerPolicy, - uses_directional_keys: bool, - ) -> Option<&'static str> { - if let Some(ingress) = Self::stated_ingress(policy, uses_directional_keys) { + fn permissive_inbound_field(policy: &ContainerPolicy) -> Option<&'static str> { + if let Some(ingress) = policy.network_ingress.as_ref() { if ingress.default == NetworkAction::Allow { return Some("network.ingress.default"); } @@ -327,13 +312,12 @@ impl IngressManager { return None; } - policy.allow_local_network.then_some("allowLocalNetwork") + None } - fn apply_rules_in_dialect( + pub fn apply_firewall_rules( &mut self, policy: &ContainerPolicy, - uses_directional_keys: bool, logger: &mut Logger, ) -> Result { if !plan_network(policy).installs_firewall() { @@ -341,7 +325,7 @@ impl IngressManager { return Ok(true); } - if let Some(field) = Self::permissive_inbound_field(policy, uses_directional_keys) { + if let Some(field) = Self::permissive_inbound_field(policy) { return Err(format!( "{field} asks for permissive inbound, which is not yet implemented for \ the LXC firewall path. LXC has a single inbound chain and the policy \ @@ -357,19 +341,9 @@ impl IngressManager { "Creating inbound iptables chain: {}", self.chain_name )); - let inbound_field = if uses_directional_keys { - "network.ingress" - } else { - "allowLocalNetwork" - }; - logger.log_line(&format!( - "Inbound ({inbound_field}) policy: {}", - if policy.allow_local_network { - "ACCEPT new inbound connections" - } else { - "DROP new inbound connections (default-deny)" - } - )); + logger.log_line( + "Inbound (network.ingress) policy: DROP new inbound connections (default-deny)", + ); let mut runner = NsenterRunner; @@ -393,52 +367,17 @@ impl IngressManager { } }; - let ipv4_rules = Self::build_ingress_rules( - &self.chain_name, - policy, - uses_directional_keys, - IpFamily::V4, - ); + let ipv4_rules = Self::build_ingress_rules(&self.chain_name, IpFamily::V4); self.install_family(IpFamily::V4, &ipv4_rules, &mut runner, logger)?; if ipv6_enabled { - let ipv6_rules = Self::build_ingress_rules( - &self.chain_name, - policy, - uses_directional_keys, - IpFamily::V6, - ); + let ipv6_rules = Self::build_ingress_rules(&self.chain_name, IpFamily::V6); self.install_family(IpFamily::V6, &ipv6_rules, &mut runner, logger)?; } Ok(true) } - pub fn apply_legacy_rules( - &mut self, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result { - self.apply_rules_in_dialect(policy, false, logger) - } - - pub fn apply_directional_rules( - &mut self, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result { - self.apply_rules_in_dialect(policy, true, logger) - } - - pub fn apply_firewall_rules( - &mut self, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result { - let dialect = uses_directional_keys(policy); - self.apply_rules_in_dialect(policy, dialect, logger) - } - fn install_family( &mut self, family: IpFamily, @@ -531,12 +470,7 @@ impl IngressManager { result } - fn build_ingress_rules( - chain: &str, - policy: &ContainerPolicy, - uses_directional_keys: bool, - family: IpFamily, - ) -> IngressRules { + fn build_ingress_rules(chain: &str, family: IpFamily) -> IngressRules { fn argv(args: &[&str]) -> Vec { args.iter().map(|s| s.to_string()).collect() } @@ -573,21 +507,8 @@ impl IngressManager { } } - let inbound_verb = - if Self::permissive_inbound_field(policy, uses_directional_keys).is_some() { - accept - } else { - drop - }; body.push(argv(&[ - "-A", - chain, - "-m", - "state", - "--state", - "NEW", - "-j", - inbound_verb, + "-A", chain, "-m", "state", "--state", "NEW", "-j", drop, ])); body.push(argv(&["-A", chain, "-j", drop])); @@ -817,17 +738,11 @@ impl Drop for IngressManager { } } -#[cfg(test)] -#[path = "network_ingress_permissive_spec_tests.rs"] -mod permissive_spec_tests; - #[cfg(test)] mod tests { use super::*; use crate::lxc_common::network_iptables::plan_network; - use crate::mxc_common::models::{ - NetworkAction, NetworkEnforcementMode, NetworkIngressPolicy, NetworkPolicy, - }; + use crate::mxc_common::models::{NetworkAction, NetworkIngressPolicy}; fn directional_ingress( default: NetworkAction, @@ -845,10 +760,10 @@ mod tests { #[test] fn a_deny_deny_directional_ingress_asks_for_nothing_permissive() { assert_eq!( - IngressManager::permissive_inbound_field( - &directional_ingress(NetworkAction::Deny, NetworkAction::Deny), - true - ), + IngressManager::permissive_inbound_field(&directional_ingress( + NetworkAction::Deny, + NetworkAction::Deny + )), None, "the GA ingress defaults are what the existing default-deny chain already \ enforces, so they must not be refused" @@ -858,10 +773,10 @@ mod tests { #[test] fn a_permissive_directional_ingress_default_is_named_in_the_refusal() { assert_eq!( - IngressManager::permissive_inbound_field( - &directional_ingress(NetworkAction::Allow, NetworkAction::Deny), - true - ), + IngressManager::permissive_inbound_field(&directional_ingress( + NetworkAction::Allow, + NetworkAction::Deny + )), Some("network.ingress.default"), "an unenforceable value must be refused by the name the operator wrote" ); @@ -870,29 +785,15 @@ mod tests { #[test] fn a_permissive_directional_host_loopback_is_named_in_the_refusal() { assert_eq!( - IngressManager::permissive_inbound_field( - &directional_ingress(NetworkAction::Deny, NetworkAction::Allow), - true - ), + IngressManager::permissive_inbound_field(&directional_ingress( + NetworkAction::Deny, + NetworkAction::Allow + )), Some("network.ingress.hostLoopback"), "an unenforceable value must be refused by the name the operator wrote" ); } - #[test] - fn a_legacy_permissive_request_is_still_named_allow_local_network() { - let policy = ContainerPolicy { - allow_local_network: true, - ..Default::default() - }; - - assert_eq!( - IngressManager::permissive_inbound_field(&policy, false), - Some("allowLocalNetwork"), - "the 0.7 refusal must keep naming the 0.7 field" - ); - } - #[test] fn a_bare_directional_config_asks_for_nothing_permissive() { let policy = ContainerPolicy { @@ -901,54 +802,25 @@ mod tests { }; assert_eq!( - IngressManager::permissive_inbound_field(&policy, true), + IngressManager::permissive_inbound_field(&policy), None, "the parser's fill-in denies inbound, which the default chain already enforces" ); } #[test] - fn the_inbound_log_line_names_the_schema_that_asked_for_the_posture() { - for (directional, expected, absent) in [ - (true, "network.ingress", "allowLocalNetwork"), - (false, "allowLocalNetwork", "network.ingress"), - ] { - let mut policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - network_enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: vec!["example.com".to_string()], - ..Default::default() - }; - if directional { - policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { - default: NetworkAction::Allow, - ..Default::default() - }); - policy.network_ingress = Some(NetworkIngressPolicy::default()); - } - - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let mut manager = IngressManager::new("log-field-test", 1); - let _ = manager.apply_firewall_rules(&policy, &mut logger); - let logged = logger.get_buffer().to_string(); - - assert!( - logged.contains(expected), - "directional={directional}: inbound log must name {expected}, got: {logged}" - ); - assert!( - !logged.contains(absent), - "directional={directional}: inbound log must not name {absent}, got: {logged}" - ); - } - } - - fn policy_with(allow_local: bool, default: NetworkPolicy) -> ContainerPolicy { - ContainerPolicy { - allow_local_network: allow_local, - default_network_policy: default, + fn the_inbound_log_line_names_directional_policy() { + let mut policy = directional_ingress(NetworkAction::Deny, NetworkAction::Deny); + policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: NetworkAction::Allow, ..Default::default() - } + }); + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + let mut manager = IngressManager::new("log-field-test", 1); + let _ = manager.apply_firewall_rules(&policy, &mut logger); + assert!(logger + .get_buffer() + .contains("Inbound (network.ingress) policy: DROP")); } fn is(rule: &[String], want: &[&str]) -> bool { @@ -1052,25 +924,16 @@ mod tests { ); } - fn full_sequence( - policy: &ContainerPolicy, - uses_directional_keys: bool, - family: IpFamily, - ) -> Vec> { - let rules = - IngressManager::build_ingress_rules(TEST_CHAIN, policy, uses_directional_keys, family); + fn full_sequence(family: IpFamily) -> Vec> { + let rules = IngressManager::build_ingress_rules(TEST_CHAIN, family); let mut seq = vec![vec!["-N".to_string(), TEST_CHAIN.to_string()]]; seq.extend(rules.body.iter().cloned()); seq.push(rules.hook.clone()); seq } - fn build(allow_local: bool) -> Vec> { - full_sequence( - &policy_with(allow_local, NetworkPolicy::Block), - false, - IpFamily::V4, - ) + fn build() -> Vec> { + full_sequence(IpFamily::V4) } struct FakeRunner Result<(), RunError>> { @@ -1206,36 +1069,15 @@ mod tests { } #[test] - fn loopback_always_accepts_regardless_of_allow_local() { - for allow in [true, false] { - let rules = build(allow); - assert!( - has(&rules, &["-A", "MXC-t", "-i", "lo", "-j", "ACCEPT"]), - "loopback must be an unconditional ACCEPT (allow_local={allow})" - ); - assert!( - !has(&rules, &["-A", "MXC-t", "-i", "lo", "-j", "DROP"]), - "loopback must never be DROP (allow_local={allow})" - ); - } - } - - #[test] - fn allow_local_true_accepts_new_inbound() { - let rules = build(true); - assert!(has( - &rules, - &["-A", "MXC-t", "-m", "state", "--state", "NEW", "-j", "ACCEPT"] - )); - assert!(!has( - &rules, - &["-A", "MXC-t", "-m", "state", "--state", "NEW", "-j", "DROP"] - )); + fn loopback_always_accepts() { + let rules = build(); + assert!(has(&rules, &["-A", "MXC-t", "-i", "lo", "-j", "ACCEPT"])); + assert!(!has(&rules, &["-A", "MXC-t", "-i", "lo", "-j", "DROP"])); } #[test] fn allow_local_false_drops_new_inbound() { - let rules = build(false); + let rules = build(); assert!(has( &rules, &["-A", "MXC-t", "-m", "state", "--state", "NEW", "-j", "DROP"] @@ -1248,7 +1090,7 @@ mod tests { #[test] fn established_precedes_new_inbound_decision() { - let rules = build(false); + let rules = build(); let est = pos( &rules, &[ @@ -1275,47 +1117,21 @@ mod tests { } #[test] - fn new_inbound_precedes_terminal_default() { - let rules = build(true); - let new = pos( - &rules, - &[ - "-A", "MXC-t", "-m", "state", "--state", "NEW", "-j", "ACCEPT", - ], - ) - .expect("NEW rule must be emitted"); - let def = - pos(&rules, &["-A", "MXC-t", "-j", "DROP"]).expect("terminal default must be emitted"); - assert!( - new < def, - "NEW-inbound accept must precede the terminal default DROP" - ); - } - - #[test] - fn terminal_default_is_always_drop_regardless_of_egress_policy() { - for default in [NetworkPolicy::Block, NetworkPolicy::Allow] { - let rules = full_sequence(&policy_with(false, default.clone()), false, IpFamily::V4); - assert!( - has(&rules, &["-A", "MXC-t", "-j", "DROP"]), - "terminal must be DROP (egress default={default:?})" - ); - assert!( - !has(&rules, &["-A", "MXC-t", "-j", "ACCEPT"]), - "terminal must never be a bare ACCEPT (egress default={default:?})" - ); - } + fn terminal_default_is_always_drop() { + let rules = full_sequence(IpFamily::V4); + assert!(has(&rules, &["-A", "MXC-t", "-j", "DROP"])); + assert!(!has(&rules, &["-A", "MXC-t", "-j", "ACCEPT"])); } #[test] fn input_hook_present_with_netns() { - let rules = build(true); + let rules = build(); assert!(has(&rules, &["-I", "INPUT", "-j", "MXC-t"])); } #[test] fn no_egress_dest_or_dns_rules_in_ingress_chain() { - let rules = build(false); + let rules = build(); assert!( !rules.iter().any(|r| r.iter().any(|a| a == "-d")), "ingress chain must not emit -d destination rules" @@ -1328,24 +1144,20 @@ mod tests { #[test] fn chain_is_created_first() { - let rules = build(false); + let rules = build(); assert!( is(&rules[0], &["-N", "MXC-t"]), "chain must be created first" ); } - fn build_family(allow_local: bool, family: IpFamily) -> Vec> { - full_sequence( - &policy_with(allow_local, NetworkPolicy::Block), - false, - family, - ) + fn build_family(family: IpFamily) -> Vec> { + full_sequence(family) } #[test] fn ipv4_full_sequence_is_pinned_exactly() { - let rules = build_family(false, IpFamily::V4); + let rules = build_family(IpFamily::V4); let want: &[&[&str]] = &[ &["-N", "MXC-t"], &["-A", "MXC-t", "-i", "lo", "-j", "ACCEPT"], @@ -1380,20 +1192,15 @@ mod tests { #[test] fn ipv4_emits_no_icmpv6_rules() { - for allow in [true, false] { - let rules = build_family(allow, IpFamily::V4); - assert!( - !rules - .iter() - .any(|r| r.iter().any(|a| a == "--icmpv6-type" || a == "icmpv6")), - "IPv4 chain must not emit any ICMPv6 rule (allow_local={allow})" - ); - } + let rules = build_family(IpFamily::V4); + assert!(!rules + .iter() + .any(|r| r.iter().any(|a| a == "--icmpv6-type" || a == "icmpv6"))); } #[test] fn ipv6_permits_neighbor_discovery_types() { - let rules = build_family(false, IpFamily::V6); + let rules = build_family(IpFamily::V6); for (num, name) in [ ("133", "router-solicitation"), ("134", "router-advertisement"), @@ -1421,7 +1228,7 @@ mod tests { #[test] fn ipv6_permits_mld_and_essential_error_types() { - let rules = build_family(false, IpFamily::V6); + let rules = build_family(IpFamily::V6); for (num, name) in [ ("130", "multicast-listener-query"), ("131", "multicast-listener-report"), @@ -1453,7 +1260,7 @@ mod tests { #[test] fn ipv6_icmpv6_accepts_precede_new_and_terminal_drop() { - let rules = build_family(false, IpFamily::V6); + let rules = build_family(IpFamily::V6); let new = pos( &rules, &["-A", "MXC-t", "-m", "state", "--state", "NEW", "-j", "DROP"], @@ -1491,7 +1298,7 @@ mod tests { #[test] fn ipv6_does_not_blanket_accept_icmpv6_or_new_inbound() { - let rules = build_family(false, IpFamily::V6); + let rules = build_family(IpFamily::V6); assert!( !has(&rules, &["-A", "MXC-t", "-p", "icmpv6", "-j", "ACCEPT"]), "IPv6 chain must not blanket-accept all ICMPv6" @@ -1527,20 +1334,6 @@ mod tests { ); } - #[test] - fn ipv6_new_decision_follows_allow_local_toggle() { - let allow = build_family(true, IpFamily::V6); - assert!(has( - &allow, - &["-A", "MXC-t", "-m", "state", "--state", "NEW", "-j", "ACCEPT"] - )); - let deny = build_family(false, IpFamily::V6); - assert!(has( - &deny, - &["-A", "MXC-t", "-m", "state", "--state", "NEW", "-j", "DROP"] - )); - } - #[test] fn ingress_chain_name_is_distinct_from_egress() { let name = "my-container"; @@ -1560,12 +1353,7 @@ mod tests { } fn permissive_firewall_policy() -> ContainerPolicy { - ContainerPolicy { - allow_local_network: true, - default_network_policy: NetworkPolicy::Block, - network_enforcement_mode: NetworkEnforcementMode::Firewall, - ..Default::default() - } + directional_ingress(NetworkAction::Allow, NetworkAction::Deny) } #[test] @@ -1577,7 +1365,7 @@ mod tests { let result = mgr.apply_firewall_rules(&permissive_firewall_policy(), &mut logger); assert!( result.is_err(), - "allowLocalNetwork: true must be refused (pid={pid})" + "network.ingress.default: allow must be refused (pid={pid})" ); let msg = result.unwrap_err(); assert!( @@ -1604,7 +1392,7 @@ mod tests { directional_ingress(NetworkAction::Allow, NetworkAction::Deny), "network.ingress.default", ), - (permissive_firewall_policy(), "allowLocalNetwork"), + (permissive_firewall_policy(), "network.ingress.default"), ] { let mut mgr = IngressManager::new("lan-inbound", 42); let msg = mgr @@ -1819,12 +1607,7 @@ mod tests { #[test] fn builder_returns_body_and_hook_separately() { for family in [IpFamily::V4, IpFamily::V6] { - let rules = IngressManager::build_ingress_rules( - TEST_CHAIN, - &policy_with(false, NetworkPolicy::Block), - false, - family, - ); + let rules = IngressManager::build_ingress_rules(TEST_CHAIN, family); assert_eq!( rules.hook, vec![ @@ -1865,12 +1648,7 @@ mod tests { let container = "install-order-container"; let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); let mut mgr = IngressManager::new(container, pid); - let rules = IngressManager::build_ingress_rules( - mgr.chain_name(), - &policy_with(false, NetworkPolicy::Block), - false, - IpFamily::V4, - ); + let rules = IngressManager::build_ingress_rules(mgr.chain_name(), IpFamily::V4); let mut runner = FakeRunner { calls: Vec::new(), @@ -2141,12 +1919,7 @@ mod tests { let pid = 7u32; let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); let mut mgr = IngressManager::new("partial-container", pid); - let rules = IngressManager::build_ingress_rules( - mgr.chain_name(), - &policy_with(false, NetworkPolicy::Block), - false, - IpFamily::V4, - ); + let rules = IngressManager::build_ingress_rules(mgr.chain_name(), IpFamily::V4); let mut runner = FakeRunner { calls: Vec::new(), @@ -2197,12 +1970,7 @@ mod tests { let pid = 7u32; let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); let mut mgr = IngressManager::new("reset-spawn-fail-container", pid); - let rules = IngressManager::build_ingress_rules( - mgr.chain_name(), - &policy_with(false, NetworkPolicy::Block), - false, - IpFamily::V4, - ); + let rules = IngressManager::build_ingress_rules(mgr.chain_name(), IpFamily::V4); let mut runner = FakeRunner { calls: Vec::new(), @@ -2237,12 +2005,7 @@ mod tests { let pid = 7u32; let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); let mut mgr = IngressManager::new("reset-exhaustion-container", pid); - let rules = IngressManager::build_ingress_rules( - mgr.chain_name(), - &policy_with(false, NetworkPolicy::Block), - false, - IpFamily::V4, - ); + let rules = IngressManager::build_ingress_rules(mgr.chain_name(), IpFamily::V4); let mut runner = FakeRunner { calls: Vec::new(), @@ -2283,12 +2046,7 @@ mod tests { #[test] fn ipv6_full_body_sequence_is_pinned_exactly() { - let rules = IngressManager::build_ingress_rules( - TEST_CHAIN, - &policy_with(false, NetworkPolicy::Block), - false, - IpFamily::V6, - ); + let rules = IngressManager::build_ingress_rules(TEST_CHAIN, IpFamily::V6); let want: Vec> = vec![ vec!["-A", "MXC-t", "-i", "lo", "-j", "ACCEPT"], @@ -2486,37 +2244,9 @@ mod tests { } } - fn legacy_firewall_mode_with_permissive_egress( - mode: NetworkEnforcementMode, - ) -> ContainerPolicy { - ContainerPolicy { - network_enforcement_mode: mode, - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - } - } - - #[test] - fn a_firewall_mode_config_with_nothing_to_restrict_outbound_still_installs_the_inbound_chain() { - for mode in [ - NetworkEnforcementMode::Firewall, - NetworkEnforcementMode::Both, - ] { - let label = format!("{mode:?}"); - let policy = legacy_firewall_mode_with_permissive_egress(mode); - - assert!( - plan_network(&policy).installs_firewall(), - "{label}: a config naming a firewall enforcement mode is owed the inbound \ - deny chain even with nothing to restrict outbound" - ); - } - } - #[test] fn a_stated_directional_posture_installs_the_inbound_chain() { let mut policy = directional_ingress(NetworkAction::Allow, NetworkAction::Deny); - policy.default_network_policy = NetworkPolicy::Allow; policy.network_egress = Some(Default::default()); assert!(plan_network(&policy).installs_firewall()); diff --git a/src/mxc-sdk/src/backends/lxc/common/network_ingress_permissive_spec_tests.rs b/src/mxc-sdk/src/backends/lxc/common/network_ingress_permissive_spec_tests.rs deleted file mode 100644 index 17504549e..000000000 --- a/src/mxc-sdk/src/backends/lxc/common/network_ingress_permissive_spec_tests.rs +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use super::*; -use crate::mxc_common::logger::Mode; -use crate::mxc_common::models::NetworkEnforcementMode; - -const UNOCCUPIABLE_NETNS_PID: u32 = u32::MAX; - -/// Linux rejects PIDs at or above 2^22. -const PID_MAX_LIMIT: u32 = 1 << 22; - -const _: () = assert!( - UNOCCUPIABLE_NETNS_PID > PID_MAX_LIMIT, - "the netns PID these tests use must exceed the kernel's PID_MAX_LIMIT; otherwise a \ - privileged Linux run can enter a real process's network namespace and reset its \ - iptables chains" -); - -fn make_logger() -> Logger { - Logger::new(Mode::Buffer) -} - -fn firewall_policy(allow_local: bool) -> ContainerPolicy { - ContainerPolicy { - allow_local_network: allow_local, - network_enforcement_mode: NetworkEnforcementMode::Firewall, - ..Default::default() - } -} - -#[test] -fn permissive_inbound_in_a_container_netns_is_refused_not_installed() { - let policy = firewall_policy(true); - let mut mgr = IngressManager::new("test-container-refused", UNOCCUPIABLE_NETNS_PID); - let mut logger = make_logger(); - - let result = mgr.apply_firewall_rules(&policy, &mut logger); - - assert!( - result.is_err(), - "allow_local_network=true: expected Err (not-yet-implemented refusal), got {:?}", - result - ); - - let msg = result.unwrap_err(); - - assert!( - msg.contains("not yet implemented"), - "error message must contain \"not yet implemented\", got: {:?}", - msg - ); - assert!( - msg.contains("allowLocalNetwork"), - "error message must contain \"allowLocalNetwork\", got: {:?}", - msg - ); - assert!( - msg.contains("over-broad accept"), - "error message must contain \"over-broad accept\", got: {:?}", - msg - ); -} - -#[test] -fn permissive_inbound_in_both_mode_is_refused_not_installed() { - let policy = ContainerPolicy { - allow_local_network: true, - network_enforcement_mode: NetworkEnforcementMode::Both, - ..Default::default() - }; - let mut mgr = IngressManager::new("test-container-refused-both", UNOCCUPIABLE_NETNS_PID); - let mut logger = make_logger(); - - let result = mgr.apply_firewall_rules(&policy, &mut logger); - - assert!( - result.is_err(), - "allow_local_network=true, mode=Both: expected Err, got {:?}", - result - ); - let msg = result.unwrap_err(); - assert!( - msg.contains("not yet implemented"), - "mode=Both: message must contain \"not yet implemented\", got: {:?}", - msg - ); - assert!( - msg.contains("allowLocalNetwork"), - "mode=Both: message must contain \"allowLocalNetwork\", got: {:?}", - msg - ); - assert!( - msg.contains("over-broad accept"), - "mode=Both: message must contain \"over-broad accept\", got: {:?}", - msg - ); -} - -#[test] -fn permissive_inbound_refusal_does_not_set_rules_applied() { - let policy = firewall_policy(true); - let mut mgr = IngressManager::new("test-container-refused-state", UNOCCUPIABLE_NETNS_PID); - let mut logger = make_logger(); - - let result = mgr.apply_firewall_rules(&policy, &mut logger); - - assert!( - result.is_err(), - "allow_local_network=true: expected Err (refusal), got {:?}", - result - ); - - assert!( - !mgr.rules_applied(), - "rules_applied() must be false after a refusal — no rules were installed so cleanup \ - must not run, got rules_applied()=true" - ); -} - -#[test] -fn default_deny_with_netns_is_not_the_permissive_refusal() { - let policy = firewall_policy(false); - let mut mgr = IngressManager::new("test-container-deny-with-netns", UNOCCUPIABLE_NETNS_PID); - let mut logger = make_logger(); - - let result = mgr.apply_firewall_rules(&policy, &mut logger); - - if let Err(ref msg) = result { - assert!( - !msg.contains("not yet implemented"), - "allow_local_network=false: must not return the not-yet-implemented refusal, got: {:?}", - msg - ); - assert!( - !msg.contains("over-broad accept"), - "allow_local_network=false: must not return the over-broad-accept refusal, got: {:?}", - msg - ); - eprintln!( - "WARNING: default_deny_with_netns_is_not_the_permissive_refusal got Err \ - (expected off Linux — no iptables binary / no /proc). The non-error branch was \ - not exercised. Re-run on Linux to verify the happy path." - ); - } -} - -#[test] -fn permissive_inbound_is_refused_under_every_accepted_mode() { - for mode in [ - NetworkEnforcementMode::Firewall, - NetworkEnforcementMode::Both, - ] { - let policy = ContainerPolicy { - allow_local_network: true, - network_enforcement_mode: mode.clone(), - ..Default::default() - }; - let mut mgr = IngressManager::new("test-container-perm-modes", UNOCCUPIABLE_NETNS_PID); - let mut logger = make_logger(); - - let result = mgr.apply_firewall_rules(&policy, &mut logger); - - assert!( - result.is_err(), - "allow_local_network=true, mode={mode:?}: expected refusal, got {result:?}" - ); - } -} diff --git a/src/mxc-sdk/src/backends/lxc/common/network_iptables.rs b/src/mxc-sdk/src/backends/lxc/common/network_iptables.rs index 307911b72..983452633 100644 --- a/src/mxc-sdk/src/backends/lxc/common/network_iptables.rs +++ b/src/mxc-sdk/src/backends/lxc/common/network_iptables.rs @@ -1,16 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use std::net::{IpAddr, Ipv6Addr, ToSocketAddrs}; +use std::net::IpAddr; use std::process::Command; use crate::mxc_common::hashing::sha256; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ - ContainerPolicy, NetworkAction, NetworkCidr, NetworkEgressPolicy, NetworkPeer, NetworkPolicy, - NetworkPort, NetworkProtocol, NetworkRule, ProxyAddress, ProxyHostPin, + ContainerPolicy, NetworkAction, NetworkCidr, NetworkEgressPolicy, NetworkPeer, NetworkPort, + NetworkProtocol, NetworkRule, }; -use crate::mxc_common::network_blocks::{self, AddressBlock, IpFamily, MAX_EGRESS_ENTRIES}; +use crate::mxc_common::network_blocks::{self, IpFamily, MAX_EGRESS_ENTRIES}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NetworkPlan { @@ -29,37 +29,17 @@ impl NetworkPlan { } } -pub(crate) fn uses_directional_keys(policy: &ContainerPolicy) -> bool { - policy.network_egress.is_some() || policy.network_ingress.is_some() -} - pub(crate) fn plan_network(policy: &ContainerPolicy) -> NetworkPlan { - if uses_directional_keys(policy) { - plan_directional(policy) - } else { - plan_legacy(policy) - } -} - -fn plan_directional(policy: &ContainerPolicy) -> NetworkPlan { - if policy.network_proxy.is_enabled() { - return NetworkPlan::Filtered; - } - - if !policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty() { - return NetworkPlan::Filtered; - } - let egress_permits_nothing = match policy.network_egress.as_ref() { Some(egress) => egress.default == NetworkAction::Deny && egress.allow.is_empty(), - None => matches!(policy.default_network_policy, NetworkPolicy::Block), + None => true, }; let ingress_permits_nothing = match policy.network_ingress.as_ref() { Some(ingress) => { ingress.default == NetworkAction::Deny && ingress.host_loopback == NetworkAction::Deny } - None => !policy.allow_local_network, + None => true, }; if egress_permits_nothing && ingress_permits_nothing { @@ -69,33 +49,10 @@ fn plan_directional(policy: &ContainerPolicy) -> NetworkPlan { } } -fn plan_legacy(policy: &ContainerPolicy) -> NetworkPlan { - if policy.network_proxy.is_enabled() { - return NetworkPlan::Filtered; - } - - if !policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty() { - return NetworkPlan::Filtered; - } - - if matches!(policy.default_network_policy, NetworkPolicy::Block) && !policy.allow_local_network - { - return NetworkPlan::Isolated; - } - - NetworkPlan::Filtered -} - pub(crate) fn needs_network(policy: &ContainerPolicy) -> bool { !plan_network(policy).omits_interface() } -#[derive(Debug, Clone, PartialEq, Eq)] -struct ProxyEndpoint { - ip: String, - port: u16, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RuleAction { Allow, @@ -105,7 +62,7 @@ enum RuleAction { // iptables applies first-match-wins; entry order is precedence. #[derive(Debug, Clone, PartialEq, Eq)] struct EgressEntry { - destination: String, + destination: NetworkCidr, action: RuleAction, matching: RuleMatch, } @@ -144,18 +101,6 @@ struct PortRange { end: u16, } -#[derive(Debug, Default, Clone, PartialEq, Eq)] -struct ResolvedDestinations { - ipv4: Vec, - ipv6: Vec, -} - -impl ResolvedDestinations { - fn is_empty(&self) -> bool { - self.ipv4.is_empty() && self.ipv6.is_empty() - } -} - #[derive(Debug, Default, Clone, PartialEq, Eq)] struct FirewallRuleArgs { ipv4: Vec>, @@ -272,7 +217,6 @@ pub struct NetworkIptablesManager { preserve_policy: bool, hook_point: EgressHookPoint, created: CreatedResources, - proxy_pin: Option, } // iptables rejects chain names of 29 characters or more. @@ -348,7 +292,6 @@ impl NetworkIptablesManager { preserve_policy: false, hook_point, created: CreatedResources::default(), - proxy_pin: None, } } @@ -375,135 +318,6 @@ impl NetworkIptablesManager { self.rules_applied = false; } - // The hosts-file pin a proxied container needs before it runs. - // DNS round-robin can answer the same name differently on a later lookup. - pub fn proxy_host_pin(&self) -> Option<&ProxyHostPin> { - self.proxy_pin.as_ref() - } - - fn covers_every_address(destination: &str) -> bool { - destination - .split_once('/') - .and_then(|(_, prefix)| prefix.trim().parse::().ok()) - .is_some_and(|prefix| prefix == 0) - } - - fn resolve_host(host: &str) -> ResolvedDestinations { - // Winsock resolves an empty entry formatted as `:0` to every local - // interface address. glibc rejects `:0`. - if host.trim().is_empty() { - return ResolvedDestinations::default(); - } - - // Linux emits IPv4-mapped destinations as IPv4 packets. - // They must be programmed with iptables. - let rewritten = Self::ipv4_mapped_destination(host); - let host = rewritten.as_deref().unwrap_or(host); - - if host.contains('/') { - return match Self::destination_family(host) { - Some(IpFamily::V4) => ResolvedDestinations { - ipv4: vec![host.to_string()], - ipv6: Vec::new(), - }, - Some(IpFamily::V6) => ResolvedDestinations { - ipv4: Vec::new(), - ipv6: vec![host.to_string()], - }, - None => ResolvedDestinations::default(), - }; - } - - if let Ok(addr) = host.parse::() { - return match addr { - IpAddr::V4(_) => ResolvedDestinations { - ipv4: vec![host.to_string()], - ipv6: Vec::new(), - }, - IpAddr::V6(_) => ResolvedDestinations { - ipv4: Vec::new(), - ipv6: vec![host.to_string()], - }, - }; - } - - if let Ok(addrs) = format!("{}:0", host).to_socket_addrs() { - return Self::bucket_resolved_addrs(addrs.map(|addr| addr.ip())); - } - ResolvedDestinations::default() - } - - // Split A records, AAAA records, and IPv4-mapped AAAA records into the - // table that can match each packet. - fn bucket_resolved_addrs>(addrs: I) -> ResolvedDestinations { - let mut resolved = ResolvedDestinations::default(); - for ip in addrs { - match ip { - IpAddr::V4(ip) => resolved.ipv4.push(ip.to_string()), - - // A resolver can return a AAAA record in mapped form. - // It travels as IPv4 on the wire. - IpAddr::V6(ip) => match ip.to_ipv4_mapped() { - Some(v4) => resolved.ipv4.push(v4.to_string()), - None => resolved.ipv6.push(ip.to_string()), - }, - } - } - resolved - } - - // Rewrite an IPv4-mapped IPv6 destination to its embedded IPv4 form. - // Linux puts a genuine IPv4 packet on the wire for a mapped destination. - // - // CIDRs inside `::ffff:0:0/96` are handled too: the mapped range is the - // final 32 bits of that /96. A prefix shorter than 96 covers addresses - // outside the mapped range and stays IPv6. - fn ipv4_mapped_destination(destination: &str) -> Option { - let Some((network, prefix)) = destination.split_once('/') else { - return destination - .parse::() - .ok()? - .to_ipv4_mapped() - .map(|v4| v4.to_string()); - }; - - if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - let mapped = network.parse::().ok()?.to_ipv4_mapped()?; - let v4_prefix = prefix.parse::().ok()?.checked_sub(96)?; - if v4_prefix > 32 { - return None; - } - Some(format!("{}/{}", mapped, v4_prefix)) - } - - fn destination_family(destination: &str) -> Option { - if let Some((network, prefix)) = destination.split_once('/') { - // `u8::from_str` accepts a leading `+`. iptables canonicalizes - // `10.0.0.0/+24` to `10.0.0.0/24`. - if network.is_empty() - || prefix.is_empty() - || !prefix.bytes().all(|b| b.is_ascii_digit()) - { - return None; - } - - let addr = network.parse::().ok()?; - let prefix = prefix.parse::().ok()?; - return match addr { - IpAddr::V4(_) if prefix <= 32 => Some(IpFamily::V4), - IpAddr::V6(_) if prefix <= 128 => Some(IpFamily::V6), - _ => None, - }; - } - - match destination.parse::().ok()? { - IpAddr::V4(_) => Some(IpFamily::V4), - IpAddr::V6(_) => Some(IpFamily::V6), - } - } - fn rule_action_arg(action: &RuleAction) -> &'static str { match action { RuleAction::Allow => "ACCEPT", @@ -539,216 +353,36 @@ impl NetworkIptablesManager { ] } - // The resolver address is not known when a closed chain's allow list is - // written. Port 53 is opened until the container can resolve names. - fn build_dns_resolution_rule_args(chain_name: &str) -> Vec> { - vec![ - vec![ - "-A", chain_name, "-p", "udp", "--dport", "53", "-j", "ACCEPT", - ], - vec![ - "-A", chain_name, "-p", "tcp", "--dport", "53", "-j", "ACCEPT", - ], - ] - .into_iter() - .map(|args| args.into_iter().map(String::from).collect()) - .collect() - } - - fn default_policy_action(default_policy: NetworkPolicy, proxy_enabled: bool) -> &'static str { - if proxy_enabled { - return "DROP"; - } + fn default_policy_action(default_policy: NetworkAction) -> &'static str { match default_policy { - NetworkPolicy::Block => "DROP", - NetworkPolicy::Allow => "ACCEPT", + NetworkAction::Deny => "DROP", + NetworkAction::Allow => "ACCEPT", } } - fn build_default_policy_rule_arg( - chain_name: &str, - policy: NetworkPolicy, - proxy_enabled: bool, - ) -> Vec { - let default_action = Self::default_policy_action(policy, proxy_enabled); + fn build_default_policy_rule_arg(chain_name: &str, policy: NetworkAction) -> Vec { + let default_action = Self::default_policy_action(policy); vec!["-A", chain_name, "-j", default_action] .into_iter() .map(String::from) .collect() } - // Proxy rules are IPv4 only and must not run through `ip6tables`. - // A proxied IPv6 chain reaches its closing DROP with only loopback allowed. - fn build_proxy_chain_rule_args( - chain_name: &str, - endpoints: &[ProxyEndpoint], - ) -> Vec> { - endpoints - .iter() - .map(|endpoint| { - vec![ - "-A".to_string(), - chain_name.to_string(), - "-p".to_string(), - "tcp".to_string(), - "-d".to_string(), - endpoint.ip.clone(), - "--dport".to_string(), - endpoint.port.to_string(), - "-j".to_string(), - "ACCEPT".to_string(), - ] - }) - .collect() - } - - fn host_is_ipv6_literal(host: &str) -> bool { - let candidate = crate::mxc_common::models::unbracket_host(host); - matches!(candidate.parse::(), Ok(IpAddr::V6(_))) - } - - fn ipv6_proxy_unsupported(host: &str) -> String { - format!( - "IPv6 network proxy endpoints are not supported: the proxy firewall rule is \ - emitted with IPv4 iptables only, so '{}' cannot be enforced and would be \ - silently dropped. Use an IPv4 proxy address.", - host - ) - } - - // Cap on how many resolved proxy addresses the chain will open. - // A round-robin or CDN answer is unbounded, and every address becomes - // its own ACCEPT rule and `iptables` process on the container-start path. - const MAX_PROXY_ENDPOINTS: usize = 16; - - fn bound_proxy_addresses<'a>( - host: &str, - addresses: &'a [String], - logger: &mut Logger, - ) -> &'a [String] { - if addresses.len() <= Self::MAX_PROXY_ENDPOINTS { - return addresses; - } - - logger.log_line(&format!( - "Warning: proxy host '{}' resolved to {} addresses; opening the first {} only. \ - The container is pinned to the first, so it still reaches the proxy.", - host, - addresses.len(), - Self::MAX_PROXY_ENDPOINTS - )); - - &addresses[..Self::MAX_PROXY_ENDPOINTS] - } - - // Resolve the proxy with one lookup. DNS round-robin can answer one - // name differently on a later lookup. - fn resolve_proxy_endpoints( - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result<(Vec, Option), String> { - if !policy.network_proxy.is_enabled() { - return Ok((Vec::new(), None)); - } - - let address = policy.network_proxy.address.as_ref().ok_or_else(|| { - "Network proxy is enabled but no proxy address is configured".to_string() - })?; - - if address.port() == 0 { - return Err("Network proxy port must be between 1 and 65535".to_string()); - } - - if Self::host_is_ipv6_literal(address.host()) { - return Err(Self::ipv6_proxy_unsupported(address.host())); - } - - let resolved = Self::resolve_host(address.host()); - if resolved.ipv4.is_empty() { - if !resolved.ipv6.is_empty() { - return Err(Self::ipv6_proxy_unsupported(address.host())); - } - return Err(format!( - "Could not resolve network proxy host '{}'", - address.host() - )); - } - - let endpoints: Vec = - Self::bound_proxy_addresses(address.host(), &resolved.ipv4, logger) - .iter() - .map(|ip| { - logger.log_line(&format!( - "Allowing network proxy egress: {}:{} ({})", - address.host(), - address.port(), - ip - )); - ProxyEndpoint { - ip: ip.clone(), - port: address.port(), - } - }) - .collect(); - - let pin = Self::build_proxy_host_pin(address, &endpoints[0].ip, logger)?; - Ok((endpoints, pin)) - } - - // A proxied chain opens no port 53. The hosts-file pin lets the - // container find the proxy without selecting an address the chain never allowed. - fn build_proxy_host_pin( - address: &ProxyAddress, - ip: &str, - logger: &mut Logger, - ) -> Result, String> { - let parsed: IpAddr = ip.parse().map_err(|_| { - format!( - "Network proxy host '{}' resolved to '{}', which is not an IP address", - address.host(), - ip - ) - })?; - - let pin = address - .host_pin(parsed) - .map_err(|e| format!("Cannot pin network proxy host: {}", e))?; - - if let Some(pin) = pin.as_ref() { - logger.log_line(&format!( - "Pinning network proxy '{}' to resolved address {} inside the container.", - pin.hostname(), - pin.ip() - )); - } - - Ok(pin) - } - - fn build_resolved_destination_rule_args( + fn build_destination_rule_args( chain_name: &str, - destinations: &ResolvedDestinations, + destination: &NetworkCidr, action: &RuleAction, matching: RuleMatch, ) -> FirewallRuleArgs { let mut args = FirewallRuleArgs::default(); - for destination in &destinations.ipv4 { - args.ipv4.push(Self::build_single_rule_args( - chain_name, - destination, - action, - matching, - IpFamily::V4, - )); - } - for destination in &destinations.ipv6 { - args.ipv6.push(Self::build_single_rule_args( - chain_name, - destination, - action, - matching, - IpFamily::V6, - )); + if let Some((family, block)) = network_blocks::resolve(destination) { + let destination = format!("{}/{}", block.address(family), block.prefix()); + let rule = + Self::build_single_rule_args(chain_name, &destination, action, matching, family); + match family { + IpFamily::V4 => args.ipv4.push(rule), + IpFamily::V6 => args.ipv6.push(rule), + } } args } @@ -800,96 +434,34 @@ impl NetworkIptablesManager { } #[cfg(test)] - fn build_host_rule_args(chain_name: &str, host: &str, action: &RuleAction) -> FirewallRuleArgs { - let destinations = Self::resolve_host(host); - Self::build_resolved_destination_rule_args( - chain_name, - &destinations, - action, - RuleMatch::AnyTraffic, - ) - } - - #[cfg(test)] - fn build_policy_rule_args( - chain_name: &str, - policy: &ContainerPolicy, - uses_directional_keys: bool, - ) -> FirewallRuleArgs { + fn build_policy_rule_args(chain_name: &str, policy: &ContainerPolicy) -> FirewallRuleArgs { let mut logger = crate::mxc_common::logger::Logger::new(crate::mxc_common::logger::Mode::Buffer); - Self::build_policy_rules_logged(chain_name, policy, uses_directional_keys, &mut logger) - .expect( - "test policy should not pair an accepting default with an unresolvable block entry", - ) - } - - fn stated_egress( - policy: &ContainerPolicy, - uses_directional_keys: bool, - ) -> Option<&NetworkEgressPolicy> { - if uses_directional_keys { - policy.network_egress.as_ref() - } else { - None - } + Self::build_policy_rules_logged(chain_name, policy, &mut logger).expect( + "test policy should not pair an accepting default with an unresolvable block entry", + ) } - fn effective_default_policy( - policy: &ContainerPolicy, - uses_directional_keys: bool, - ) -> NetworkPolicy { - match Self::stated_egress(policy, uses_directional_keys) { - Some(egress) => match egress.default { - NetworkAction::Allow => NetworkPolicy::Allow, - NetworkAction::Deny => NetworkPolicy::Block, - }, - None => policy.default_network_policy.clone(), - } + fn effective_default_policy(policy: &ContainerPolicy) -> NetworkAction { + policy + .network_egress + .as_ref() + .map_or(NetworkAction::Deny, |egress| egress.default) } /// Lower the egress policy for its refusals alone, discarding the rules. - pub(crate) fn validate_egress_lowering( - policy: &ContainerPolicy, - uses_directional_keys: bool, - ) -> Result<(), String> { - Self::lower_egress(policy, uses_directional_keys).map(|_| ()) + pub(crate) fn validate_egress_lowering(policy: &ContainerPolicy) -> Result<(), String> { + Self::lower_egress(policy).map(|_| ()) } - fn lower_egress( - policy: &ContainerPolicy, - uses_directional_keys: bool, - ) -> Result, String> { - match Self::stated_egress(policy, uses_directional_keys) { + fn lower_egress(policy: &ContainerPolicy) -> Result, String> { + match policy.network_egress.as_ref() { Some(egress) => Self::lower_directional_egress(egress), - None => Ok(Self::lower_legacy_hosts(policy)), + None => Ok(Vec::new()), } } - // Block entries come first. Under iptables first-match-wins, exchanging - // the two halves reverses overlapping allow and block lists. - fn lower_legacy_hosts(policy: &ContainerPolicy) -> Vec { - policy - .blocked_hosts - .iter() - .map(|host| (host, RuleAction::Deny)) - .chain( - policy - .allowed_hosts - .iter() - .map(|host| (host, RuleAction::Allow)), - ) - .map(|(host, action)| EgressEntry { - destination: host.clone(), - action, - - matching: RuleMatch::AnyTraffic, - }) - .collect() - } - - // Deny rules precede allow rules for the same first-match-wins reason - // as the legacy lowering. + // Deny rules precede allow rules under iptables first-match-wins. fn lower_directional_egress(egress: &NetworkEgressPolicy) -> Result, String> { let mut entries = Vec::new(); let mut remaining = MAX_EGRESS_ENTRIES; @@ -944,23 +516,22 @@ impl NetworkIptablesManager { /// /// An `iptables` rule matches one destination block and cannot carry an /// exclusion, so the exclusion is subtracted from the peer instead. - fn peer_destinations(peer: &NetworkPeer) -> Result, String> { + fn peer_destinations(peer: &NetworkPeer) -> Result, String> { // Passing an out-of-range prefix through unchanged keeps it on the path // that reports a destination resolving to no address. let Some((family, blocks)) = network_blocks::peer_blocks(peer)? else { - return Ok(vec![Self::cidr_destination(&peer.cidr)]); + return Ok(vec![peer.cidr.clone()]); }; Ok(blocks .into_iter() - .map(|block| Self::block_destination(block, family)) + .map(|block| NetworkCidr { + address: block.address(family), + prefix_length: block.prefix(), + }) .collect()) } - fn block_destination(block: AddressBlock, family: IpFamily) -> String { - format!("{}/{}", block.address(family), block.prefix()) - } - // A v4 chain and a v6 chain are programmed separately. Neither wildcard // covers the other. fn every_destination_peers() -> Vec { @@ -982,10 +553,6 @@ impl NetworkIptablesManager { ] } - fn cidr_destination(cidr: &NetworkCidr) -> String { - network_blocks::cidr_text(cidr) - } - fn lower_port_selectors(ports: &[NetworkPort]) -> Vec { if ports.is_empty() { return vec![RuleMatch::AnyTraffic]; @@ -1028,30 +595,26 @@ impl NetworkIptablesManager { } } - // DNS round-robin can answer one name differently on repeated lookups. - // Each lowered entry is resolved once before logging and rule generation share the result. + // Destinations are typed CIDRs; the shared block arithmetic determines + // their firewall family, including IPv4-mapped IPv6 addresses. fn build_policy_rules_logged( chain_name: &str, policy: &ContainerPolicy, - uses_directional_keys: bool, logger: &mut Logger, ) -> Result { - let default_permits = matches!( - Self::effective_default_policy(policy, uses_directional_keys), - NetworkPolicy::Allow - ); + let default_permits = Self::effective_default_policy(policy) == NetworkAction::Allow; let mut args = FirewallRuleArgs::default(); - let mut unresolved_denies: Vec<&str> = Vec::new(); - let mut catch_all_allows: Vec<&str> = Vec::new(); - let entries = Self::lower_egress(policy, uses_directional_keys)?; + let mut unresolved_denies: Vec = Vec::new(); + let mut catch_all_allows: Vec = Vec::new(); + let entries = Self::lower_egress(policy)?; for entry in &entries { - let host = entry.destination.as_str(); + let host = network_blocks::cidr_text(&entry.destination); let action = entry.action; - let destinations = Self::resolve_host(host); - if destinations.is_empty() { + let family = network_blocks::resolve(&entry.destination); + if family.is_none() { if default_permits && matches!(action, RuleAction::Deny) { return Err(format!( - "blocked host '{}' resolved to no address, so no rule can be \ + "network.egress deny destination '{}' resolved to no address, so no rule can be \ programmed to deny it, and the default network policy accepts \ what no rule matches; refusing to apply a policy that would \ leave it reachable", @@ -1059,22 +622,21 @@ impl NetworkIptablesManager { )); } if matches!(action, RuleAction::Deny) { - unresolved_denies.push(host); + unresolved_denies.push(host.clone()); } - logger.log_line(&format!("Warning: could not resolve host '{}'", host)); + logger.log_line(&format!( + "Warning: could not resolve destination '{}'", + host + )); } else if matches!(action, RuleAction::Allow) && matches!(entry.matching, RuleMatch::AnyTraffic) - && destinations - .ipv4 - .iter() - .chain(destinations.ipv6.iter()) - .any(|dest| Self::covers_every_address(dest)) + && entry.destination.prefix_length == 0 { - catch_all_allows.push(host); + catch_all_allows.push(host.clone()); } - let rule_args = Self::build_resolved_destination_rule_args( + let rule_args = Self::build_destination_rule_args( chain_name, - &destinations, + &entry.destination, &action, entry.matching, ); @@ -1088,8 +650,8 @@ impl NetworkIptablesManager { } if !unresolved_denies.is_empty() && !catch_all_allows.is_empty() { return Err(format!( - "blocked host(s) {} resolved to no address, so no rule can be programmed \ - to deny them, while allowed host(s) {} accept every address and are \ + "network.egress deny destination(s) {} resolved to no address, so no rule can be programmed \ + to deny them, while allow destination(s) {} accept every address and are \ evaluated before the chain's closing DROP; whatever the blocked host \ resolves to for the container is therefore accepted, so deny precedence \ cannot hold. Fix or remove the unresolvable blocked host, or narrow the \ @@ -1354,10 +916,9 @@ impl NetworkIptablesManager { Ok(()) } - fn apply_rules_in_dialect( + pub fn apply_firewall_rules( &mut self, policy: &ContainerPolicy, - uses_directional_keys: bool, logger: &mut Logger, ) -> Result { let plan = plan_network(policy); @@ -1375,43 +936,10 @@ impl NetworkIptablesManager { )); } - let (proxy_endpoints, proxy_pin) = Self::resolve_proxy_endpoints(policy, logger)?; - self.proxy_pin = proxy_pin; - - let outcome = self.apply_firewall_rules_inner( - policy, - uses_directional_keys, - &proxy_endpoints, - logger, - ); + let outcome = self.apply_firewall_rules_inner(policy, logger); self.record_apply_outcome(outcome, logger) } - pub fn apply_legacy_rules( - &mut self, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result { - self.apply_rules_in_dialect(policy, false, logger) - } - - pub fn apply_directional_rules( - &mut self, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result { - self.apply_rules_in_dialect(policy, true, logger) - } - - pub fn apply_firewall_rules( - &mut self, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result { - let dialect = uses_directional_keys(policy); - self.apply_rules_in_dialect(policy, dialect, logger) - } - fn record_apply_outcome( &mut self, outcome: Result, @@ -1450,18 +978,10 @@ impl NetworkIptablesManager { fn apply_firewall_rules_inner( &self, policy: &ContainerPolicy, - uses_directional_keys: bool, - proxy_endpoints: &[ProxyEndpoint], logger: &mut Logger, ) -> Result { let mut created = CreatedResources::default(); - match self.install_firewall_rules( - policy, - uses_directional_keys, - proxy_endpoints, - logger, - &mut created, - ) { + match self.install_firewall_rules(policy, logger, &mut created) { Ok(()) => Ok(created), Err(e) => { let residual = self.teardown_created(&self.chain_name, &created, logger); @@ -1473,8 +993,6 @@ impl NetworkIptablesManager { fn install_firewall_rules( &self, policy: &ContainerPolicy, - uses_directional_keys: bool, - proxy_endpoints: &[ProxyEndpoint], logger: &mut Logger, created: &mut CreatedResources, ) -> Result<(), String> { @@ -1483,15 +1001,6 @@ impl NetworkIptablesManager { self.chain_name )); - if !proxy_endpoints.is_empty() && !policy.blocked_hosts.is_empty() { - return Err( - "network.proxy cannot be combined with blockedHosts: the proxy can fetch a \ - blocked destination on the container's behalf, so the block list would not \ - be enforced" - .to_string(), - ); - } - let ipv6_enabled = match self.ip6tables_status(logger) { Ip6tablesStatus::Available => true, Ip6tablesStatus::KernelIpv6Disabled => false, @@ -1514,84 +1023,27 @@ impl NetworkIptablesManager { Self::publish_created(created); } - let proxy_mode = !proxy_endpoints.is_empty(); - - if proxy_mode { - // No port 53 accept: the container resolves the proxy through the - // hosts-file pin, and an unscoped one would be a standing DNS-tunnel - // exfil path. No ESTABLISHED,RELATED accept: every packet to the - // proxy already matches an endpoint ACCEPT. - let loopback_rules = vec![Self::build_loopback_accept_rule_args(&self.chain_name)]; - self.run_iptables_rule_args(&loopback_rules, logger)?; - if ipv6_enabled { - self.run_ip6tables_rule_args(&loopback_rules, logger)?; - } - let proxy_rules = Self::build_proxy_chain_rule_args(&self.chain_name, proxy_endpoints); - self.run_iptables_rule_args(&proxy_rules, logger)?; - for rule in loopback_rules.iter().chain(proxy_rules.iter()) { - logger.log_line(&format!("Programmed iptables rule: {}", rule.join(" "))); - } - if !policy.allowed_hosts.is_empty() { - logger.log_line( - "Warning: network.proxy is configured, so allowedHosts is not programmed; \ - the container may reach the proxy and nothing else.", - ); - } - if ipv6_enabled { - logger.log_line( - "IPv6 egress is denied outright while a proxy is configured: the proxy \ - endpoint is IPv4, so the IPv6 chain carries its loopback accept and \ - its closing DROP.", - ); - } - } else { - let base_rules = Self::build_base_chain_rule_args(&self.chain_name); - - let mut tail_rules: Vec> = Vec::new(); - - // Only a closed chain naming hosts to allow gets DNS. An open chain - // needs no grant, and the accept would sit ahead of the deny rules. - let closed_with_named_hosts = matches!( - Self::effective_default_policy(policy, uses_directional_keys), - NetworkPolicy::Block - ) && !policy.allowed_hosts.is_empty(); - if !uses_directional_keys && closed_with_named_hosts { - tail_rules.extend(Self::build_dns_resolution_rule_args(&self.chain_name)); - } - - self.run_iptables_rule_args(&base_rules, logger)?; - if !tail_rules.is_empty() { - self.run_iptables_rule_args(&tail_rules, logger)?; - } - if ipv6_enabled { - self.run_ip6tables_rule_args(&base_rules, logger)?; - if !tail_rules.is_empty() { - self.run_ip6tables_rule_args(&tail_rules, logger)?; - } - } + let base_rules = Self::build_base_chain_rule_args(&self.chain_name); + self.run_iptables_rule_args(&base_rules, logger)?; + if ipv6_enabled { + self.run_ip6tables_rule_args(&base_rules, logger)?; + } - let policy_rules = Self::build_policy_rules_logged( - &self.chain_name, - policy, - uses_directional_keys, - logger, - )?; - self.run_iptables_rule_args(&policy_rules.ipv4, logger)?; - if ipv6_enabled { - self.run_ip6tables_rule_args(&policy_rules.ipv6, logger)?; - } else if !policy_rules.ipv6.is_empty() { - logger.log_line(&format!( - "Warning: {} IPv6 firewall rule(s) not applied because ip6tables \ - is unavailable; IPv6 egress is unfiltered on this host.", - policy_rules.ipv6.len() - )); - } + let policy_rules = Self::build_policy_rules_logged(&self.chain_name, policy, logger)?; + self.run_iptables_rule_args(&policy_rules.ipv4, logger)?; + if ipv6_enabled { + self.run_ip6tables_rule_args(&policy_rules.ipv6, logger)?; + } else if !policy_rules.ipv6.is_empty() { + logger.log_line(&format!( + "Warning: {} IPv6 firewall rule(s) not applied because ip6tables \ + is unavailable; IPv6 egress is unfiltered on this host.", + policy_rules.ipv6.len() + )); } let default_rule = Self::build_default_policy_rule_arg( &self.chain_name, - Self::effective_default_policy(policy, uses_directional_keys), - proxy_mode, + Self::effective_default_policy(policy), ); let default_args: Vec<&str> = default_rule.iter().map(String::as_str).collect(); let default_action = default_args.last().copied().unwrap_or("ACCEPT"); @@ -1748,14 +1200,6 @@ impl Drop for NetworkIptablesManager { } } -#[cfg(test)] -#[path = "network_iptables_deny_precedence_spec.rs"] -mod deny_precedence_spec; - -#[cfg(test)] -#[path = "network_iptables_proxy_spec.rs"] -mod proxy_spec; - #[cfg(test)] #[path = "network_iptables_ga_egress_spec.rs"] mod ga_egress_spec; @@ -1872,15 +1316,15 @@ pub(crate) mod test_firewall { mod tests { use super::*; use crate::mxc_common::logger::{Logger, Mode}; - use crate::mxc_common::models::{ - ContainerPolicy, NetworkEnforcementMode, ProxyAddress, ProxyConfig, - }; + use crate::mxc_common::models::{ContainerPolicy, NetworkEgressPolicy}; use std::io::{Error, ErrorKind}; - fn policy_requesting_mode(mode: NetworkEnforcementMode) -> ContainerPolicy { + fn filtered_policy() -> ContainerPolicy { ContainerPolicy { - network_enforcement_mode: mode, - allowed_hosts: vec!["203.0.113.7".to_string()], + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), ..Default::default() } } @@ -1889,24 +1333,7 @@ mod tests { fn an_unhooked_caller_is_not_refused_in_firewall_mode() { let _fake = super::test_firewall::install(); let mut manager = NetworkIptablesManager::new("bwrap-nonetns", EgressHookPoint::Unhooked); - let policy = policy_requesting_mode(NetworkEnforcementMode::Firewall); - let mut logger = Logger::new(Mode::Buffer); - - let result = manager.apply_firewall_rules(&policy, &mut logger); - - assert!( - result.is_ok(), - "a caller with no namespace to enforce in must not be failed closed, got {:?}", - result - ); - } - - #[test] - fn an_unhooked_caller_is_not_refused_in_both_mode() { - let _fake = super::test_firewall::install(); - let mut manager = - NetworkIptablesManager::new("bwrap-nonetns-both", EgressHookPoint::Unhooked); - let policy = policy_requesting_mode(NetworkEnforcementMode::Both); + let policy = filtered_policy(); let mut logger = Logger::new(Mode::Buffer); let result = manager.apply_firewall_rules(&policy, &mut logger); @@ -1923,7 +1350,7 @@ mod tests { let fake = super::test_firewall::install(); let mut manager = NetworkIptablesManager::new("lxc-forget", EgressHookPoint::ContainerNetns(4242)); - let policy = policy_requesting_mode(NetworkEnforcementMode::Firewall); + let policy = filtered_policy(); let mut logger = Logger::new(Mode::Buffer); manager @@ -1956,7 +1383,7 @@ mod tests { let fake = super::test_firewall::install(); let mut manager = NetworkIptablesManager::new("lxc-hooked", EgressHookPoint::ContainerNetns(4242)); - let policy = policy_requesting_mode(NetworkEnforcementMode::Firewall); + let policy = filtered_policy(); let mut logger = Logger::new(Mode::Buffer); manager @@ -1988,7 +1415,7 @@ mod tests { fn an_unhooked_manager_installs_no_output_hook() { let fake = super::test_firewall::install(); let mut manager = NetworkIptablesManager::new("bwrap-nohook", EgressHookPoint::Unhooked); - let policy = policy_requesting_mode(NetworkEnforcementMode::Firewall); + let policy = filtered_policy(); let mut logger = Logger::new(Mode::Buffer); manager @@ -2319,7 +1746,7 @@ mod tests { NetworkIptablesManager::new("already-owned", EgressHookPoint::ContainerNetns(4242)); manager.retain_residual_ownership(CreatedResources::for_test(true, false, false, false)); - let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let policy = filtered_policy(); let mut logger = Logger::new(Mode::Buffer); let result = manager.apply_firewall_rules(&policy, &mut logger); @@ -2339,7 +1766,7 @@ mod tests { let fake = test_firewall::install(); let mut manager = NetworkIptablesManager::new("fresh", EgressHookPoint::ContainerNetns(4242)); - let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let policy = filtered_policy(); let mut logger = Logger::new(Mode::Buffer); let result = manager.apply_firewall_rules(&policy, &mut logger); @@ -2390,65 +1817,25 @@ mod tests { assert!(mgr.chain_name.len() <= CHAIN_NAME_MAX_LEN); } - #[test] - fn resolve_ip_address() { - let ips = NetworkIptablesManager::resolve_host("127.0.0.1"); - assert_eq!(ips.ipv4, vec!["127.0.0.1"]); - assert!(ips.ipv6.is_empty()); - } - - #[test] - fn resolve_host_retains_ipv6_literal() { - let ips = NetworkIptablesManager::resolve_host("::1"); - assert!(ips.ipv4.is_empty()); - assert_eq!(ips.ipv6, vec!["::1"]); - } - - #[test] - fn resolve_host_rewrites_ipv4_mapped_ipv6_literal_to_ipv4() { - let ips = NetworkIptablesManager::resolve_host("::ffff:127.0.0.1"); - assert_eq!(ips.ipv4, vec!["127.0.0.1"]); - assert!(ips.ipv6.is_empty()); - } - - #[test] - fn resolve_host_keeps_ipv4_literal_unchanged() { - let ips = NetworkIptablesManager::resolve_host("10.0.0.1"); - assert_eq!(ips.ipv4, vec!["10.0.0.1"]); - assert!(ips.ipv6.is_empty()); - } - - #[test] - fn resolve_host_retains_valid_cidr_by_family() { - let v4 = NetworkIptablesManager::resolve_host("140.82.112.0/20"); - assert_eq!(v4.ipv4, vec!["140.82.112.0/20"]); - assert!(v4.ipv6.is_empty()); - - let v6 = NetworkIptablesManager::resolve_host("2606:50c0::/32"); - assert!(v6.ipv4.is_empty()); - assert_eq!(v6.ipv6, vec!["2606:50c0::/32"]); - } - - #[test] - fn resolve_host_rejects_invalid_cidr_prefix() { - assert!(NetworkIptablesManager::resolve_host("140.82.112.0/33").is_empty()); - assert!(NetworkIptablesManager::resolve_host("2606:50c0::/129").is_empty()); - assert!(NetworkIptablesManager::resolve_host("140.82.112.0/not-a-prefix").is_empty()); - } - - #[test] - fn resolve_host_rejects_malformed_cidr_syntax() { - assert!(NetworkIptablesManager::resolve_host("/20").is_empty()); - assert!(NetworkIptablesManager::resolve_host("140.82.112.0/").is_empty()); - assert!(NetworkIptablesManager::resolve_host("140.82.112.0/20/8").is_empty()); + fn cidr(value: &str) -> NetworkCidr { + let (address, prefix) = value + .split_once('/') + .expect("fixture must include a prefix"); + NetworkCidr { + address: address.parse().expect("fixture must include an IP address"), + prefix_length: prefix + .parse() + .expect("fixture must include a numeric prefix"), + } } #[test] fn host_rule_args_route_ipv4_to_iptables_args() { - let args = NetworkIptablesManager::build_host_rule_args( + let args = NetworkIptablesManager::build_destination_rule_args( "MXC-test", - "140.82.112.4", + &cidr("140.82.112.4/32"), &RuleAction::Allow, + RuleMatch::AnyTraffic, ); assert_eq!( @@ -2457,7 +1844,7 @@ mod tests { "-A", "MXC-test", "-d", - "140.82.112.4", + "140.82.112.4/32", "-j", "ACCEPT", ])] @@ -2467,10 +1854,11 @@ mod tests { #[test] fn host_rule_args_route_ipv6_to_ip6tables_args() { - let args = NetworkIptablesManager::build_host_rule_args( + let args = NetworkIptablesManager::build_destination_rule_args( "MXC-test", - "2606:50c0:8000::64", + &cidr("2606:50c0:8000::64/128"), &RuleAction::Deny, + RuleMatch::AnyTraffic, ); assert!(args.ipv4.is_empty()); @@ -2480,7 +1868,7 @@ mod tests { "-A", "MXC-test", "-d", - "2606:50c0:8000::64", + "2606:50c0:8000::64/128", "-j", "DROP", ])] @@ -2489,10 +1877,11 @@ mod tests { #[test] fn host_rule_args_pass_cidr_through_unchanged() { - let v4 = NetworkIptablesManager::build_host_rule_args( + let v4 = NetworkIptablesManager::build_destination_rule_args( "MXC-test", - "140.82.112.0/20", + &cidr("140.82.112.0/20"), &RuleAction::Allow, + RuleMatch::AnyTraffic, ); assert_eq!( v4.ipv4, @@ -2507,10 +1896,11 @@ mod tests { ); assert!(v4.ipv6.is_empty()); - let v6 = NetworkIptablesManager::build_host_rule_args( + let v6 = NetworkIptablesManager::build_destination_rule_args( "MXC-test", - "2606:50c0::/32", + &cidr("2606:50c0::/32"), &RuleAction::Allow, + RuleMatch::AnyTraffic, ); assert!(v6.ipv4.is_empty()); assert_eq!( @@ -2527,366 +1917,84 @@ mod tests { } #[test] - fn host_rule_args_drop_unresolvable_destination() { - let args = NetworkIptablesManager::build_host_rule_args( + fn direct_mapped_ipv6_cidr_uses_ipv4_destination_in_ipv4_chain() { + let args = NetworkIptablesManager::build_destination_rule_args( "MXC-test", - "140.82.112.0/33", + &cidr("::ffff:192.0.2.0/120"), &RuleAction::Allow, + RuleMatch::AnyTraffic, ); - assert!(args.ipv4.is_empty()); + assert_eq!( + args.ipv4, + vec![strings(&[ + "-A", + "MXC-test", + "-d", + "192.0.2.0/24", + "-j", + "ACCEPT", + ])] + ); assert!(args.ipv6.is_empty()); } #[test] - fn build_policy_rule_args_splits_allow_and_block_lists_by_family() { - let policy = ContainerPolicy { - allowed_hosts: vec!["140.82.112.0/20".to_string(), "2606:50c0::/32".to_string()], - blocked_hosts: vec!["10.0.0.0/8".to_string(), "2001:db8::/32".to_string()], - ..Default::default() - }; - - let args = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, false); - - let expected_v4 = vec![ - strings(&["-A", "MXC-test", "-d", "140.82.112.0/20", "-j", "ACCEPT"]), - strings(&["-A", "MXC-test", "-d", "10.0.0.0/8", "-j", "DROP"]), - ]; - let expected_v6 = vec![ - strings(&["-A", "MXC-test", "-d", "2606:50c0::/32", "-j", "ACCEPT"]), - strings(&["-A", "MXC-test", "-d", "2001:db8::/32", "-j", "DROP"]), - ]; + fn invalid_typed_cidr_does_not_program_a_rule() { + let args = NetworkIptablesManager::build_destination_rule_args( + "MXC-test", + &NetworkCidr { + address: IpAddr::from([140, 82, 112, 0]), + prefix_length: 33, + }, + &RuleAction::Allow, + RuleMatch::AnyTraffic, + ); - assert_eq!(args.ipv4.len(), expected_v4.len()); - for rule in &expected_v4 { - assert!( - args.ipv4.contains(rule), - "IPv4 rules should contain {rule:?}; actual: {:?}", - args.ipv4 - ); - } - assert_eq!(args.ipv6.len(), expected_v6.len()); - for rule in &expected_v6 { - assert!( - args.ipv6.contains(rule), - "IPv6 rules should contain {rule:?}; actual: {:?}", - args.ipv6 - ); - } + assert!(args.ipv4.is_empty()); + assert!(args.ipv6.is_empty()); } #[test] fn base_chain_rule_args_are_family_agnostic() { let base = NetworkIptablesManager::build_base_chain_rule_args("MXC-test"); - let dns = NetworkIptablesManager::build_dns_resolution_rule_args("MXC-test"); assert_eq!(base.len(), 2); - assert_eq!(dns.len(), 2); - for rule in base.iter().chain(dns.iter()) { + for rule in &base { assert!(!rule.iter().any(|arg| arg == "icmp")); assert!(!rule.iter().any(|arg| arg == "icmpv6")); } } - fn assert_resolved_exact(input: &str, expected_ipv4: &[&str], expected_ipv6: &[&str]) { - let resolved = NetworkIptablesManager::resolve_host(input); - let expected_ipv4: Vec = expected_ipv4 - .iter() - .map(|value| value.to_string()) - .collect(); - let expected_ipv6: Vec = expected_ipv6 - .iter() - .map(|value| value.to_string()) - .collect(); - - assert_eq!( - resolved.ipv4, expected_ipv4, - "unexpected IPv4 destinations for {input:?}" - ); - assert_eq!( - resolved.ipv6, expected_ipv6, - "unexpected IPv6 destinations for {input:?}" - ); - } - - fn assert_destination_family(input: &str, expected: Option) { - assert_eq!( - NetworkIptablesManager::destination_family(input), - expected, - "unexpected destination family for {input:?}" - ); - } - - #[test] - fn bare_ip_literals_are_routed_only_to_their_matching_family() { - let cases = [ - ("192.0.2.1", &["192.0.2.1"][..], &[][..]), - ("127.0.0.1", &["127.0.0.1"][..], &[][..]), - ("2606:50c0::153", &[][..], &["2606:50c0::153"][..]), - ( - "2606:50c0:0000:0000:0000:0000:0000:0153", - &[][..], - &["2606:50c0:0000:0000:0000:0000:0000:0153"][..], - ), - ("::1", &[][..], &["::1"][..]), - ]; - - for (input, expected_ipv4, expected_ipv6) in cases { - assert_resolved_exact(input, expected_ipv4, expected_ipv6); - } - } - - #[test] - fn ipv4_mapped_ipv6_literal_is_filed_as_ipv4() { - assert_resolved_exact("::ffff:127.0.0.1", &["127.0.0.1"], &[]); - } - - #[test] - fn ipv4_mapped_cidr_is_translated_to_its_ipv4_prefix() { - assert_resolved_exact("::ffff:192.0.2.0/120", &["192.0.2.0/24"], &[]); - assert_resolved_exact("::ffff:198.51.100.42/128", &["198.51.100.42/32"], &[]); - } - - #[test] - fn an_ipv6_prefix_shorter_than_the_mapped_range_stays_ipv6() { - assert_resolved_exact("::ffff:0:0/95", &[], &["::ffff:0:0/95"]); - } - - #[test] - fn valid_cidrs_are_passed_through_unchanged_in_their_matching_family() { - let cases = [ - ("140.82.112.0/20", &["140.82.112.0/20"][..], &[][..]), - ("2606:50c0::/32", &[][..], &["2606:50c0::/32"][..]), - ]; - - for (input, expected_ipv4, expected_ipv6) in cases { - assert_resolved_exact(input, expected_ipv4, expected_ipv6); - } - } - - #[test] - fn v4_cidr_with_host_bits_set_is_passed_through_unchanged() { - assert_resolved_exact("140.82.112.5/20", &["140.82.112.5/20"], &[]); - } - - #[test] - fn cidr_prefix_lengths_accept_only_family_specific_bounds() { - let cases = [ - ("0.0.0.0/0", Some(IpFamily::V4), &["0.0.0.0/0"][..], &[][..]), - ( - "192.0.2.1/32", - Some(IpFamily::V4), - &["192.0.2.1/32"][..], - &[][..], - ), - ("192.0.2.1/33", None, &[][..], &[][..]), - ("192.0.2.1/129", None, &[][..], &[][..]), - ("::/0", Some(IpFamily::V6), &[][..], &["::/0"][..]), - ( - "2001:db8::1/128", - Some(IpFamily::V6), - &[][..], - &["2001:db8::1/128"][..], - ), - ("2001:db8::1/129", None, &[][..], &[][..]), - ]; - - for (input, expected_family, expected_ipv4, expected_ipv6) in cases { - assert_resolved_exact(input, expected_ipv4, expected_ipv6); - assert_destination_family(input, expected_family); - } - } - - #[test] - fn v6_prefix_length_on_v4_address_is_rejected() { - assert_resolved_exact("10.0.0.0/64", &[], &[]); - assert_destination_family("10.0.0.0/64", None); - } - - #[test] - fn malformed_cidr_syntax_and_garbage_resolve_to_nothing() { - let cases = [ - "/24", - "10.0.0.0/", - "10.0.0.0//24", - "10.0.0.0/abc", - "10.0.0.0/-1", - "10.0.0.0/ 24", - "not-a-valid-firewall-destination", - ]; - - for input in cases { - let resolved = NetworkIptablesManager::resolve_host(input); - assert!( - resolved.is_empty(), - "malformed destination {input:?} should resolve to nothing, got {resolved:?}" - ); - assert_destination_family(input, None); - } - } - - #[test] - fn cidr_prefix_with_plus_sign_resolves_to_nothing() { - let input = "10.0.0.0/+24"; - let resolved = NetworkIptablesManager::resolve_host(input); - assert!( - resolved.is_empty(), - "malformed destination {input:?} should resolve to nothing, got {resolved:?}" - ); - assert_destination_family(input, None); - } - - #[test] - fn leading_plus_does_not_smuggle_an_out_of_range_prefix_past_validation() { - let input = "10.0.0.0/+33"; - let resolved = NetworkIptablesManager::resolve_host(input); - assert!( - resolved.is_empty(), - "a leading `+` must not smuggle an out-of-range prefix past validation, got {resolved:?}" - ); - assert_destination_family(input, None); - } - #[test] - fn empty_input_resolves_to_nothing() { - let resolved = NetworkIptablesManager::resolve_host(""); - assert!( - resolved.is_empty(), - "empty input should resolve to nothing, got {resolved:?}" - ); - assert_destination_family("", None); - } - - fn assert_buckets_are_family_pure(input: &str, resolved: &ResolvedDestinations) { - for destination in &resolved.ipv4 { - assert_eq!( - NetworkIptablesManager::destination_family(destination), - Some(IpFamily::V4), - "{input:?}: {destination:?} is in the ipv4 bucket but is not an IPv4 destination" - ); - } - for destination in &resolved.ipv6 { - assert_eq!( - NetworkIptablesManager::destination_family(destination), - Some(IpFamily::V6), - "{input:?}: {destination:?} is in the ipv6 bucket but is not an IPv6 destination" + fn typed_cidrs_route_to_their_firewall_family() { + for (input, expected, is_v4) in [ + ("192.0.2.0/24", "192.0.2.0/24", true), + ("2001:db8::/32", "2001:db8::/32", false), + ("::ffff:192.0.2.0/120", "192.0.2.0/24", true), + ("::ffff:0:0/95", "::fffe:0:0/95", false), + ] { + let peer = NetworkPeer { + cidr: cidr(input), + except: Vec::new(), + }; + let lowered = + NetworkIptablesManager::peer_destinations(&peer).expect("valid CIDR should lower"); + assert_eq!(lowered.len(), 1); + let args = NetworkIptablesManager::build_destination_rule_args( + "MXC-test", + &lowered[0], + &RuleAction::Allow, + RuleMatch::AnyTraffic, ); - } - } - - #[test] - fn aaaa_records_land_in_the_v6_bucket_and_never_in_the_v4_bucket() { - let injected: Vec = [ - "93.184.216.34", - "2606:2800:220:1:248:1893:25c8:1946", - "8.8.8.8", - "2001:4860:4860::8888", - ] - .iter() - .map(|value| { - value - .parse::() - .expect("injected test address must parse") - }) - .collect(); - - let resolved = NetworkIptablesManager::bucket_resolved_addrs(injected); - - assert_eq!( - resolved.ipv4.len(), - 2, - "both injected A records must land in the v4 bucket, got {:?}", - resolved.ipv4 - ); - assert_eq!( - resolved.ipv6.len(), - 2, - "both injected AAAA records must land in the v6 bucket, got {:?}", - resolved.ipv6 - ); - assert!( - !resolved.ipv6.is_empty(), - "AAAA records must produce at least one v6 destination; an empty v6 \ - bucket means the IPv6 arm was dropped or misrouted into the v4 bucket" - ); - assert_buckets_are_family_pure("injected A/AAAA mix", &resolved); - } - - #[test] - fn live_dual_stack_resolution_keeps_buckets_family_pure() { - for host in ["dns.google", "one.one.one.one", "localhost"] { - let resolved = NetworkIptablesManager::resolve_host(host); - assert_buckets_are_family_pure(host, &resolved); - } - } - - #[test] - fn localhost_resolution_populates_available_loopback_families() { - let resolved = NetworkIptablesManager::resolve_host("localhost"); - - // A minimal host can have a degenerate /etc/hosts. Accept whichever - // localhost family is configured while checking that no other address leaks in. - assert!( - !resolved.is_empty(), - "localhost should resolve to at least one loopback family" - ); - assert!( - resolved - .ipv4 - .iter() - .all(|destination| destination == "127.0.0.1"), - "localhost IPv4 results should all be 127.0.0.1, got {:?}", - resolved.ipv4 - ); - assert!( - resolved.ipv6.iter().all(|destination| destination == "::1"), - "localhost IPv6 results should all be ::1, got {:?}", - resolved.ipv6 - ); - assert_buckets_are_family_pure("localhost", &resolved); - } - - #[test] - fn unresolvable_invalid_tld_hostname_resolves_to_nothing() { - let input = "mxc-resolution-spec-7f3b2d9c4a1e6f80.invalid"; - let resolved = NetworkIptablesManager::resolve_host(input); - - assert!( - resolved.is_empty(), - "reserved .invalid hostname {input:?} should resolve to nothing, got {resolved:?}" - ); - assert_destination_family(input, None); - } - - #[test] - fn destination_family_agrees_with_every_resolved_destination() { - let inputs = [ - "192.0.2.44", - "2606:50c0::153", - "140.82.112.5/20", - "2606:50c0::/32", - "::ffff:127.0.0.1", - "localhost", - ]; - - for input in inputs { - let resolved = NetworkIptablesManager::resolve_host(input); - - for destination in &resolved.ipv4 { - assert_eq!( - NetworkIptablesManager::destination_family(destination), - Some(IpFamily::V4), - "destination_family disagreed with IPv4 filing for input {input:?}, destination {destination:?}" - ); - } - - for destination in &resolved.ipv6 { - assert_eq!( - NetworkIptablesManager::destination_family(destination), - Some(IpFamily::V6), - "destination_family disagreed with IPv6 filing for input {input:?}, destination {destination:?}" - ); - } + let (present, absent) = if is_v4 { + (&args.ipv4, &args.ipv6) + } else { + (&args.ipv6, &args.ipv4) + }; + assert!(absent.is_empty(), "wrong family for {input}: {args:?}"); + assert_eq!(present.len(), 1, "missing {input}: {args:?}"); + assert!(present[0].contains(&expected.to_string())); } } @@ -2908,88 +2016,6 @@ mod tests { ); } - fn policy_with_hosts(allowed_hosts: &[&str], blocked_hosts: &[&str]) -> ContainerPolicy { - ContainerPolicy { - allowed_hosts: strings(allowed_hosts), - blocked_hosts: strings(blocked_hosts), - ..Default::default() - } - } - - // `.invalid` is reserved by RFC 2606 and never resolves. - const UNRESOLVABLE_HOST: &str = "blocked.invalid"; - - #[test] - fn an_unresolvable_deny_under_a_blocking_default_is_fatal_beside_a_catch_all_allow() { - let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - ..policy_with_hosts(&["0.0.0.0/0"], &[UNRESOLVABLE_HOST]) - }; - let mut logger = - crate::mxc_common::logger::Logger::new(crate::mxc_common::logger::Mode::Buffer); - - let err = - NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, false, &mut logger) - .expect_err("a catch-all allow must not be able to accept an unresolvable deny"); - - assert!( - err.contains(UNRESOLVABLE_HOST) && err.contains("deny precedence"), - "error should name the host and the invariant, got: {err}" - ); - } - - #[test] - fn an_ipv6_catch_all_allow_also_arms_the_deny_precedence_failure() { - let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - ..policy_with_hosts(&["::/0"], &[UNRESOLVABLE_HOST]) - }; - let mut logger = - crate::mxc_common::logger::Logger::new(crate::mxc_common::logger::Mode::Buffer); - - NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, false, &mut logger) - .expect_err("a v6 catch-all allow accepts the unresolved deny just as a v4 one does"); - } - - #[test] - fn an_unresolvable_deny_beside_a_bounded_allow_stays_a_warning() { - let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - ..policy_with_hosts(&["192.0.2.10"], &[UNRESOLVABLE_HOST]) - }; - let mut logger = - crate::mxc_common::logger::Logger::new(crate::mxc_common::logger::Mode::Buffer); - - NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, false, &mut logger) - .expect("a bounded allow leaves the closing DROP covering the unresolved deny"); - } - - #[test] - fn a_bounded_cidr_allow_is_not_mistaken_for_a_catch_all() { - let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - ..policy_with_hosts(&["192.0.2.0/24"], &[UNRESOLVABLE_HOST]) - }; - let mut logger = - crate::mxc_common::logger::Logger::new(crate::mxc_common::logger::Mode::Buffer); - - NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, false, &mut logger) - .expect("a /24 allow covers a bounded set, so it proves nothing about the deny"); - } - - #[test] - fn an_unresolvable_allow_does_not_arm_the_deny_precedence_failure() { - let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - ..policy_with_hosts(&["allowed.invalid"], &[UNRESOLVABLE_HOST]) - }; - let mut logger = - crate::mxc_common::logger::Logger::new(crate::mxc_common::logger::Mode::Buffer); - - NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, false, &mut logger) - .expect("an allow that programs no rule cannot accept the unresolved deny"); - } - #[test] fn allow_and_deny_actions_map_to_exact_iptables_jump_targets() { assert_eq!( @@ -3005,19 +2031,25 @@ mod tests { } #[test] - fn destination_literals_and_cidrs_land_only_in_their_address_family_bucket() { + fn cidrs_land_only_in_their_address_family_bucket() { let cases = [ - ("192.0.2.10", "ipv4 bare literal", true), - ("192.0.2.10/24", "ipv4 CIDR", true), - ("2001:db8::10", "ipv6 bare literal", false), - ("2001:db8::10/64", "ipv6 CIDR", false), + ("192.0.2.10/32", "192.0.2.10/32", "ipv4 host CIDR", true), + ("192.0.2.0/24", "192.0.2.0/24", "ipv4 CIDR", true), + ( + "2001:db8::10/128", + "2001:db8::10/128", + "ipv6 host CIDR", + false, + ), + ("2001:db8::10/64", "2001:db8::/64", "ipv6 CIDR", false), ]; - for (destination, label, is_ipv4) in cases { - let rules = NetworkIptablesManager::build_host_rule_args( + for (destination, expected, label, is_ipv4) in cases { + let rules = NetworkIptablesManager::build_destination_rule_args( "MXC-family-split", - destination, + &cidr(destination), &RuleAction::Allow, + RuleMatch::AnyTraffic, ); if is_ipv4 { @@ -3030,7 +2062,7 @@ mod tests { rules.ipv6.is_empty(), "{label} {destination} should leave IPv6 rules empty; actual: {rules:?}" ); - assert_rule_contains(&rules.ipv4[0], destination, destination); + assert_rule_contains(&rules.ipv4[0], expected, destination); } else { assert!( rules.ipv4.is_empty(), @@ -3041,36 +2073,11 @@ mod tests { 1, "{label} {destination} should produce one IPv6 rule; actual: {rules:?}" ); - assert_rule_contains(&rules.ipv6[0], destination, destination); + assert_rule_contains(&rules.ipv6[0], expected, destination); } } } - #[test] - fn mixed_family_host_list_produces_matching_rule_count_in_each_bucket() { - let policy = policy_with_hosts( - &[ - "192.0.2.10", - "198.51.100.0/24", - "2001:db8::10", - "2001:db8:abcd::/48", - ], - &[], - ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-mixed", &policy, false); - - assert_eq!( - rules.ipv4.len(), - 2, - "mixed host list should produce two IPv4 rules; actual: {rules:?}" - ); - assert_eq!( - rules.ipv6.len(), - 2, - "mixed host list should produce two IPv6 rules; actual: {rules:?}" - ); - } - #[test] fn generated_destination_rules_append_to_chain_match_destination_and_jump_target() { let chain_name = "MXC-shape"; @@ -3109,68 +2116,10 @@ mod tests { ); } - #[test] - fn resolved_destinations_are_split_into_ipv4_and_ipv6_rule_args() { - let destinations = ResolvedDestinations { - ipv4: strings(&["192.0.2.10", "198.51.100.0/24"]), - ipv6: strings(&["2001:db8::10", "2001:db8:abcd::/48"]), - }; - let rules = NetworkIptablesManager::build_resolved_destination_rule_args( - "MXC-resolved", - &destinations, - &RuleAction::Allow, - RuleMatch::AnyTraffic, - ); - - assert_eq!( - rules.ipv4.len(), - 2, - "resolved destinations should keep both IPv4 rules in IPv4 bucket; actual: {rules:?}" - ); - assert_eq!( - rules.ipv6.len(), - 2, - "resolved destinations should keep both IPv6 rules in IPv6 bucket; actual: {rules:?}" - ); - for destination in &destinations.ipv4 { - assert!( - rules.ipv4.iter().any(|rule| rule.contains(destination)), - "IPv4 destination {destination} should appear in IPv4 rules; actual: {rules:?}" - ); - assert!( - !rules.ipv6.iter().any(|rule| rule.contains(destination)), - "IPv4 destination {destination} should not appear in IPv6 rules; actual: {rules:?}" - ); - } - for destination in &destinations.ipv6 { - assert!( - rules.ipv6.iter().any(|rule| rule.contains(destination)), - "IPv6 destination {destination} should appear in IPv6 rules; actual: {rules:?}" - ); - assert!( - !rules.ipv4.iter().any(|rule| rule.contains(destination)), - "IPv6 destination {destination} must not appear in IPv4 rules; actual: {rules:?}" - ); - } - } - #[test] fn no_egress_rule_selects_an_incoming_interface() { let chain_name = "MXC-sel"; - let endpoints = vec![ProxyEndpoint { - ip: "10.0.3.1".to_string(), - port: 3128, - }]; - let mut rules = NetworkIptablesManager::build_base_chain_rule_args(chain_name); - rules.extend(NetworkIptablesManager::build_dns_resolution_rule_args( - chain_name, - )); - rules.extend(NetworkIptablesManager::build_proxy_chain_rule_args( - chain_name, &endpoints, - )); - rules.push(NetworkIptablesManager::build_loopback_accept_rule_args( - chain_name, - )); + let rules = NetworkIptablesManager::build_base_chain_rule_args(chain_name); for rule in &rules { assert!( @@ -3229,48 +2178,18 @@ mod tests { } #[test] - fn dns_resolution_is_the_documented_udp_then_tcp_pair() { - let chain_name = "MXC-base"; - let rules = NetworkIptablesManager::build_dns_resolution_rule_args(chain_name); - let expected = vec![ - strings(&[ - "-A", chain_name, "-p", "udp", "--dport", "53", "-j", "ACCEPT", - ]), - strings(&[ - "-A", chain_name, "-p", "tcp", "--dport", "53", "-j", "ACCEPT", - ]), - ]; - - assert_eq!( - rules, expected, - "the DNS resolution grant should be the documented udp/tcp pair" - ); - for (index, rule) in rules.iter().enumerate() { - assert_rule_omits(rule, "-d", &format!("dns rule {index}")); - } - } - - #[test] - fn default_network_policy_maps_to_exact_terminal_rule_vector() { + fn directional_default_maps_to_exact_terminal_rule_vector() { let chain_name = "MXC-default"; assert_eq!( - NetworkIptablesManager::build_default_policy_rule_arg( - chain_name, - NetworkPolicy::Block, - false - ), + NetworkIptablesManager::build_default_policy_rule_arg(chain_name, NetworkAction::Deny), strings(&["-A", chain_name, "-j", "DROP"]), - "NetworkPolicy::Block should produce the exact DROP terminal rule" + "egress.default=deny should produce the exact DROP terminal rule" ); assert_eq!( - NetworkIptablesManager::build_default_policy_rule_arg( - chain_name, - NetworkPolicy::Allow, - false - ), + NetworkIptablesManager::build_default_policy_rule_arg(chain_name, NetworkAction::Allow), strings(&["-A", chain_name, "-j", "ACCEPT"]), - "NetworkPolicy::Allow should produce the exact ACCEPT terminal rule" + "egress.default=allow should produce the exact ACCEPT terminal rule" ); } @@ -3301,8 +2220,8 @@ mod tests { #[test] fn empty_policy_produces_no_destination_rules_in_either_bucket() { - let policy = policy_with_hosts(&[], &[]); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-empty", &policy, false); + let policy = ContainerPolicy::default(); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-empty", &policy); assert!( rules.ipv4.is_empty(), @@ -3314,22 +2233,6 @@ mod tests { ); } - #[test] - fn unresolvable_invalid_hostname_contributes_no_destination_rules() { - let host = "definitely-unresolvable-mxc-rulegen-spec.invalid"; - let rules = - NetworkIptablesManager::build_host_rule_args("MXC-invalid", host, &RuleAction::Allow); - - assert!( - rules.ipv4.is_empty(), - "unresolvable host {host} should produce no IPv4 rules; actual: {rules:?}" - ); - assert!( - rules.ipv6.is_empty(), - "unresolvable host {host} should produce no IPv6 rules; actual: {rules:?}" - ); - } - #[test] fn a_new_manager_reports_no_rules_applied() { let manager = NetworkIptablesManager::new("fresh", EgressHookPoint::ContainerNetns(4242)); @@ -3345,8 +2248,7 @@ mod tests { let mut manager = NetworkIptablesManager::new("skip-noop", EgressHookPoint::ContainerNetns(4242)); let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - default_network_policy: NetworkPolicy::Block, + network_egress: Some(NetworkEgressPolicy::default()), ..Default::default() }; let mut logger = Logger::new(Mode::Buffer); @@ -3365,74 +2267,8 @@ mod tests { } #[test] - fn a_legacy_policy_naming_reachable_hosts_installs_the_firewall() { - for mode in [ - NetworkEnforcementMode::Firewall, - NetworkEnforcementMode::Both, - ] { - let label = format!("{mode:?}"); - let policy = ContainerPolicy { - network_enforcement_mode: mode, - allowed_hosts: vec!["example.com".to_string()], - ..Default::default() - }; - - assert!( - plan_network(&policy).installs_firewall(), - "{label}: a 0.7 policy naming hosts it may reach must install the chain" - ); - } - } - - #[test] - fn a_named_host_list_always_installs_the_chain() { - for (allowed, blocked) in [ - (&["140.82.112.0/20"][..], &[][..]), - (&[][..], &["140.82.112.0/20"][..]), - ] { - for default_policy in [NetworkPolicy::Block, NetworkPolicy::Allow] { - let policy = ContainerPolicy { - default_network_policy: default_policy.clone(), - ..policy_with_hosts(allowed, blocked) - }; - - let plan = plan_network(&policy); - - assert!( - plan.installs_firewall(), - "a policy naming hosts to allow or block states a restriction, and \ - the default enforcement mode must not discard it; \ - allowed={allowed:?} blocked={blocked:?} default={default_policy:?} \ - gave {plan:?}" - ); - } - } - } - - #[test] - fn a_legacy_policy_that_permits_nothing_is_given_no_interface() { - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - network_mode_specified: true, - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - - assert_eq!( - plan_network(&policy), - NetworkPlan::Isolated, - "a policy that blocks outbound, allows no local network, and names no \ - proxy permits nothing, so the container is given no interface rather \ - than an unfiltered one" - ); - } - - #[test] - fn a_directional_policy_installs_the_firewall_under_the_capabilities_default() { + fn a_directional_policy_installs_the_firewall() { let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Capabilities, - network_mode_specified: true, - default_network_policy: NetworkPolicy::Allow, network_egress: Some(NetworkEgressPolicy { default: NetworkAction::Allow, ..Default::default() @@ -3442,13 +2278,12 @@ mod tests { assert!( plan_network(&policy).installs_firewall(), - "a stated 0.8 posture must install the firewall even though enforcementMode \ - is absent from the 0.8 schema and defaults to capabilities" + "a stated directional posture permitting traffic must install the firewall" ); } #[test] - fn a_v08_request_naming_no_network_fields_is_given_no_interface() { + fn a_directional_request_naming_no_network_fields_is_given_no_interface() { let policy = ContainerPolicy { network_egress: Some(NetworkEgressPolicy::default()), network_ingress: Some(crate::mxc_common::models::NetworkIngressPolicy::default()), @@ -3488,24 +2323,10 @@ mod tests { assert!(!plan_network(&policy).omits_interface()); } - #[test] - fn a_legacy_proxy_policy_installs_the_chain_and_needs_the_network() { - let mut policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); - policy.allowed_hosts.clear(); - policy.network_proxy = ProxyConfig { - address: Some(ProxyAddress::new("10.0.0.5".to_string(), 3128)), - builtin_test_server: false, - }; - - assert!(plan_network(&policy).installs_firewall()); - assert!(needs_network(&policy)); - } - // Alpine's DHCP lease arrives around ten seconds after LXC marks the container running. #[test] fn a_plan_that_starts_an_interface_demands_an_address() { - let mut policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); - policy.default_network_policy = NetworkPolicy::Allow; + let policy = filtered_policy(); assert!( !plan_network(&policy).omits_interface(), @@ -3530,53 +2351,35 @@ mod tests { ]; let mut omitted = 0; - for directional in [false, true] { - for egress in egress_options { - for ingress in ingress_options { - for bits in 0u8..32 { - let policy = ContainerPolicy { - network_proxy: ProxyConfig { - builtin_test_server: bits & 1 != 0, - ..Default::default() - }, - allowed_hosts: if bits & 2 != 0 { - vec!["allowed.example".to_string()] + for egress in egress_options { + for ingress in ingress_options { + for allow_entry in [false, true] { + let policy = ContainerPolicy { + network_egress: egress.map(|default| NetworkEgressPolicy { + default, + allow: if allow_entry { + vec![NetworkRule::default()] } else { Vec::new() }, - blocked_hosts: if bits & 4 != 0 { - vec!["blocked.example".to_string()] - } else { - Vec::new() - }, - default_network_policy: if bits & 8 != 0 { - NetworkPolicy::Block - } else { - NetworkPolicy::Allow - }, - allow_local_network: bits & 16 != 0, - network_egress: egress.map(|default| NetworkEgressPolicy { - default, - ..Default::default() - }), - network_ingress: ingress.map(|(default, host_loopback)| { - crate::mxc_common::models::NetworkIngressPolicy { - default, - host_loopback, - } - }), ..Default::default() - }; - - if plan_network(&policy).omits_interface() { - omitted += 1; - assert!( - !needs_network(&policy), - "no interface means no address, yet this policy would treat a \ - missing address as fatal: directional={directional}, \ - egress={egress:?}, ingress={ingress:?}, bits={bits:05b}" - ); - } + }), + network_ingress: ingress.map(|(default, host_loopback)| { + crate::mxc_common::models::NetworkIngressPolicy { + default, + host_loopback, + } + }), + ..Default::default() + }; + + if plan_network(&policy).omits_interface() { + omitted += 1; + assert!( + !needs_network(&policy), + "no interface means no address: egress={egress:?}, \ + ingress={ingress:?}, allow_entry={allow_entry}" + ); } } } @@ -3588,43 +2391,6 @@ mod tests { ); } - #[test] - fn a_proxied_policy_installs_the_chain() { - let mut policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); - policy.allowed_hosts.clear(); - policy.network_proxy = ProxyConfig { - address: Some(ProxyAddress::new("10.0.0.5".to_string(), 3128)), - builtin_test_server: false, - }; - - assert!(plan_network(&policy).installs_firewall()); - } - - #[test] - fn the_builtin_test_server_proxy_installs_the_chain_the_same_way() { - let mut policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); - policy.allowed_hosts.clear(); - policy.network_proxy = ProxyConfig { - address: None, - builtin_test_server: true, - }; - - assert!( - plan_network(&policy).installs_firewall(), - "an address-free proxy is still a proxy and must not go unenforced" - ); - } - - fn policy_with_enforcement_mode( - network_enforcement_mode: NetworkEnforcementMode, - ) -> ContainerPolicy { - ContainerPolicy { - network_enforcement_mode, - allowed_hosts: vec!["203.0.113.7".to_string()], - ..Default::default() - } - } - #[test] fn working_probe_with_active_ipv6_reports_available() { let result = NetworkIptablesManager::classify_ip6tables_status(true, true); @@ -3934,7 +2700,7 @@ mod tests { let mut manager = NetworkIptablesManager::new("stranded", EgressHookPoint::ContainerNetns(4242)); - let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let policy = filtered_policy(); let mut logger = Logger::new(Mode::Buffer); let outcome = manager.apply_firewall_rules(&policy, &mut logger); @@ -3962,7 +2728,7 @@ mod tests { let mut manager = NetworkIptablesManager::new("maybe-applied", EgressHookPoint::ContainerNetns(4242)); - let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let policy = filtered_policy(); let mut logger = Logger::new(Mode::Buffer); let outcome = manager.apply_firewall_rules(&policy, &mut logger); @@ -3991,7 +2757,7 @@ mod tests { let mut manager = NetworkIptablesManager::new("still-hooked", EgressHookPoint::ContainerNetns(4242)); - let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let policy = filtered_policy(); let mut apply_logger = Logger::new(Mode::Buffer); manager .apply_firewall_rules(&policy, &mut apply_logger) diff --git a/src/mxc-sdk/src/backends/lxc/common/network_iptables_deny_precedence_spec.rs b/src/mxc-sdk/src/backends/lxc/common/network_iptables_deny_precedence_spec.rs deleted file mode 100644 index 399991144..000000000 --- a/src/mxc-sdk/src/backends/lxc/common/network_iptables_deny_precedence_spec.rs +++ /dev/null @@ -1,563 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use super::*; -use crate::mxc_common::logger::Mode; - -const CHAIN: &str = "mxc_test_chain"; - -fn as_str_slice(rule: &[String]) -> Vec<&str> { - rule.iter().map(String::as_str).collect() -} - -fn destination_of(rule: &[String]) -> &str { - let index = rule - .iter() - .position(|arg| arg == "-d") - .unwrap_or_else(|| panic!("rule has no '-d' flag; actual: {rule:?}")); - &rule[index + 1] -} - -fn action_of(rule: &[String]) -> &str { - let index = rule - .iter() - .position(|arg| arg == "-j") - .unwrap_or_else(|| panic!("rule has no '-j' flag; actual: {rule:?}")); - &rule[index + 1] -} - -fn last_drop_index(rules: &[Vec]) -> Option { - rules.iter().rposition(|rule| action_of(rule) == "DROP") -} - -fn first_accept_index(rules: &[Vec]) -> Option { - rules.iter().position(|rule| action_of(rule) == "ACCEPT") -} - -fn sorted<'a>(items: &[&'a str]) -> Vec<&'a str> { - let mut items = items.to_vec(); - items.sort_unstable(); - items -} - -fn assert_deny_precedence( - rules: &[Vec], - expected_drop_destinations: &[&str], - expected_accept_destinations: &[&str], -) { - let mut drop_destinations: Vec<&str> = Vec::new(); - let mut accept_destinations: Vec<&str> = Vec::new(); - for rule in rules { - match action_of(rule) { - "DROP" => drop_destinations.push(destination_of(rule)), - "ACCEPT" => accept_destinations.push(destination_of(rule)), - other => panic!("unexpected -j target '{other}'; actual rule: {rule:?}"), - } - } - - assert_eq!( - sorted(&drop_destinations), - sorted(expected_drop_destinations), - "DROP destinations did not match expected set; actual rules: {rules:?}" - ); - assert_eq!( - sorted(&accept_destinations), - sorted(expected_accept_destinations), - "ACCEPT destinations did not match expected set; actual rules: {rules:?}" - ); - - if let (Some(last_drop), Some(first_accept)) = - (last_drop_index(rules), first_accept_index(rules)) - { - assert!( - last_drop < first_accept, - "every DROP rule must precede every ACCEPT rule (B1); \ - last DROP at index {last_drop}, first ACCEPT at index {first_accept}; \ - actual rules: {rules:?}" - ); - } -} - -fn expect_ok(result: Result, context: &str) -> FirewallRuleArgs { - match result { - Ok(args) => args, - Err(err) => panic!("{context}; actual Err: {err:?}"), - } -} - -fn parses_as_ipv4(destination: &str) -> bool { - let address = destination.split('/').next().unwrap_or(destination); - address.parse::().is_ok() -} - -fn parses_as_ipv6(destination: &str) -> bool { - let address = destination.split('/').next().unwrap_or(destination); - address.parse::().is_ok() -} - -#[test] -fn a_destination_in_both_lists_is_dropped_because_deny_rules_are_emitted_first() { - let destination = "203.0.113.44"; - let policy = ContainerPolicy { - blocked_hosts: vec![destination.to_string()], - allowed_hosts: vec![destination.to_string()], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - - let args = NetworkIptablesManager::build_policy_rule_args(CHAIN, &policy, false); - - assert_eq!( - args.ipv4.len(), - 2, - "expected one DROP rule and one ACCEPT rule for a doubly-listed \ - destination; actual: {:?}", - args.ipv4 - ); - assert_eq!( - as_str_slice(&args.ipv4[0]), - vec!["-A", CHAIN, "-d", destination, "-j", "DROP"], - "the deny rule must be emitted first; actual first rule: {:?}", - args.ipv4[0] - ); - assert_eq!( - as_str_slice(&args.ipv4[1]), - vec!["-A", CHAIN, "-d", destination, "-j", "ACCEPT"], - "the allow rule must follow the deny rule; actual second rule: {:?}", - args.ipv4[1] - ); - assert!( - args.ipv6.is_empty(), - "an IPv4-only policy must not produce IPv6 rules; actual: {:?}", - args.ipv6 - ); -} - -#[test] -fn an_ipv6_destination_in_both_lists_is_dropped_because_deny_rules_are_emitted_first() { - let destination = "2001:db8::44"; - let policy = ContainerPolicy { - blocked_hosts: vec![destination.to_string()], - allowed_hosts: vec![destination.to_string()], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - - let args = NetworkIptablesManager::build_policy_rule_args(CHAIN, &policy, false); - - assert_eq!( - args.ipv6.len(), - 2, - "expected one DROP rule and one ACCEPT rule for a doubly-listed \ - IPv6 destination; actual: {:?}", - args.ipv6 - ); - assert_eq!( - as_str_slice(&args.ipv6[0]), - vec!["-A", CHAIN, "-d", destination, "-j", "DROP"], - "the deny rule must be emitted first; actual first rule: {:?}", - args.ipv6[0] - ); - assert_eq!( - as_str_slice(&args.ipv6[1]), - vec!["-A", CHAIN, "-d", destination, "-j", "ACCEPT"], - "the allow rule must follow the deny rule; actual second rule: {:?}", - args.ipv6[1] - ); - assert!( - args.ipv4.is_empty(), - "an IPv6-only policy must not produce IPv4 rules; actual: {:?}", - args.ipv4 - ); -} - -#[test] -fn deny_precedence_holds_across_both_families_with_several_entries_in_each_list() { - let policy = ContainerPolicy { - blocked_hosts: vec![ - "10.0.0.0/8".to_string(), - "198.51.100.42/32".to_string(), - "2606:50c0::/32".to_string(), - ], - allowed_hosts: vec![ - "140.82.112.0/20".to_string(), - "203.0.113.44".to_string(), - "2001:db8::/32".to_string(), - "2001:db8::44".to_string(), - ], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - - let args = NetworkIptablesManager::build_policy_rule_args(CHAIN, &policy, false); - - assert_eq!( - args.ipv4.len(), - 4, - "2 blocked + 2 allowed IPv4 destinations must produce 4 IPv4 rules; \ - actual: {:?}", - args.ipv4 - ); - assert_deny_precedence( - &args.ipv4, - &["10.0.0.0/8", "198.51.100.42/32"], - &["140.82.112.0/20", "203.0.113.44"], - ); - - assert_eq!( - args.ipv6.len(), - 3, - "1 blocked + 2 allowed IPv6 destinations must produce 3 IPv6 rules; \ - actual: {:?}", - args.ipv6 - ); - assert_deny_precedence( - &args.ipv6, - &["2606:50c0::/32"], - &["2001:db8::/32", "2001:db8::44"], - ); -} - -#[test] -fn an_unresolvable_blocked_host_errors_under_an_allow_default_and_names_the_host() { - let host = "140.82.112.0/not-a-prefix"; - let policy = ContainerPolicy { - blocked_hosts: vec![host.to_string()], - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - - let result = - NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); - - let err = match result { - Err(err) => err, - Ok(args) => panic!( - "expected Err: a blocked, unresolvable host under an Allow \ - default leaves nothing to stop traffic (B4); actual ipv4: {:?}, \ - ipv6: {:?}", - args.ipv4, args.ipv6 - ), - }; - assert!( - err.contains(host), - "the error message must name the offending host '{host}'; actual \ - message: {err:?}" - ); -} - -#[test] -fn the_same_unresolvable_blocked_host_does_not_error_under_a_block_default() { - let host = "140.82.112.0/not-a-prefix"; - let policy = ContainerPolicy { - blocked_hosts: vec![host.to_string()], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - - let result = - NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); - let args = expect_ok( - result, - "a Block default already denies everything the allow list did not \ - name, so an unresolvable block entry is redundant, not fatal (B4)", - ); - - assert!( - args.ipv4.is_empty() && args.ipv6.is_empty(), - "an unresolvable entry contributes no rules; actual ipv4: {:?}, \ - ipv6: {:?}", - args.ipv4, - args.ipv6 - ); - - let expected_warning = format!("Warning: could not resolve host '{host}'"); - assert!( - logger - .get_buffer() - .lines() - .any(|line| line == expected_warning), - "expected the exact warning line {expected_warning:?}; actual \ - buffer: {:?}", - logger.get_buffer() - ); -} - -#[test] -fn an_unresolvable_allowed_host_never_errors_under_an_allow_default() { - let host = "/20"; - let policy = ContainerPolicy { - allowed_hosts: vec![host.to_string()], - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - - let result = - NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); - let args = expect_ok( - result, - "B4 reserves Err for an unresolvable BLOCK entry under an Allow \ - default; an unresolvable ALLOW entry must never error", - ); - - assert!( - args.ipv4.is_empty() && args.ipv6.is_empty(), - "an unresolvable entry contributes no rules; actual ipv4: {:?}, \ - ipv6: {:?}", - args.ipv4, - args.ipv6 - ); - - let expected_warning = format!("Warning: could not resolve host '{host}'"); - assert!( - logger - .get_buffer() - .lines() - .any(|line| line == expected_warning), - "expected the exact warning line {expected_warning:?}; actual \ - buffer: {:?}", - logger.get_buffer() - ); -} - -#[test] -fn an_unresolvable_allowed_host_never_errors_under_a_block_default() { - let host = "140.82.112.0/20/8"; - let policy = ContainerPolicy { - allowed_hosts: vec![host.to_string()], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - - let result = - NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); - let args = expect_ok( - result, - "an unresolvable ALLOW entry must never error, regardless of the \ - default network policy (B4)", - ); - - assert!( - args.ipv4.is_empty() && args.ipv6.is_empty(), - "an unresolvable entry contributes no rules; actual ipv4: {:?}, \ - ipv6: {:?}", - args.ipv4, - args.ipv6 - ); - - let expected_warning = format!("Warning: could not resolve host '{host}'"); - assert!( - logger - .get_buffer() - .lines() - .any(|line| line == expected_warning), - "expected the exact warning line {expected_warning:?}; actual \ - buffer: {:?}", - logger.get_buffer() - ); -} - -#[test] -fn an_unresolvable_entry_does_not_suppress_a_sibling_entrys_rule_or_log_line() { - let good_destination = "198.51.100.42/32"; - let bad_host = "2606:50c0::/129"; - let policy = ContainerPolicy { - blocked_hosts: vec![good_destination.to_string(), bad_host.to_string()], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - - let result = - NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); - let args = expect_ok( - result, - "an unresolvable block entry under a Block default must not error, \ - and must not stop a sibling entry in the same call from producing \ - a rule (B4)", - ); - - assert_eq!( - args.ipv4.len(), - 1, - "the resolvable sibling must still produce exactly one rule; \ - actual: {:?}", - args.ipv4 - ); - assert_eq!( - as_str_slice(&args.ipv4[0]), - vec!["-A", CHAIN, "-d", good_destination, "-j", "DROP"], - "actual rule: {:?}", - args.ipv4[0] - ); - - let buffer = logger.get_buffer(); - let expected_warning = format!("Warning: could not resolve host '{bad_host}'"); - assert!( - buffer.lines().any(|line| line == expected_warning), - "expected the warning line for the unresolvable sibling; actual \ - buffer: {buffer:?}" - ); - let expected_programmed_line = - format!("Programmed iptables rule: -A {CHAIN} -d {good_destination} -j DROP"); - assert!( - buffer.lines().any(|line| line == expected_programmed_line), - "expected the programmed-rule line for the resolvable sibling; \ - actual buffer: {buffer:?}" - ); -} - -#[test] -fn emitted_rules_have_the_exact_iptables_shape_for_both_allow_and_block_actions() { - let allowed = "203.0.113.44"; - let blocked = "10.0.0.0/8"; - let chain = "mxc_shape_chain"; - let policy = ContainerPolicy { - allowed_hosts: vec![allowed.to_string()], - blocked_hosts: vec![blocked.to_string()], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - - let args = NetworkIptablesManager::build_policy_rule_args(chain, &policy, false); - - assert_eq!( - args.ipv4.len(), - 2, - "one block entry and one allow entry must produce exactly 2 rules; \ - actual: {:?}", - args.ipv4 - ); - assert_eq!( - as_str_slice(&args.ipv4[0]), - vec!["-A", chain, "-d", blocked, "-j", "DROP"], - "actual rule: {:?}", - args.ipv4[0] - ); - assert_eq!( - as_str_slice(&args.ipv4[1]), - vec!["-A", chain, "-d", allowed, "-j", "ACCEPT"], - "actual rule: {:?}", - args.ipv4[1] - ); - for rule in &args.ipv4 { - assert_eq!( - rule.len(), - 6, - "a rule must have exactly 6 arguments; actual: {rule:?}" - ); - } -} - -#[test] -fn ipv4_and_ipv6_destinations_are_split_into_the_correct_bucket_and_never_cross_over() { - let policy = ContainerPolicy { - allowed_hosts: vec!["140.82.112.0/20".to_string(), "2001:db8::/32".to_string()], - blocked_hosts: vec!["198.51.100.42/32".to_string(), "fe80::1".to_string()], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - - let args = NetworkIptablesManager::build_policy_rule_args(CHAIN, &policy, false); - - for rule in &args.ipv4 { - let destination = destination_of(rule); - assert!( - parses_as_ipv4(destination), - "a destination in the ipv4 bucket must parse as IPv4; actual \ - destination: {destination:?}" - ); - } - for rule in &args.ipv6 { - let destination = destination_of(rule); - assert!( - parses_as_ipv6(destination), - "a destination in the ipv6 bucket must parse as IPv6; actual \ - destination: {destination:?}" - ); - } - - assert_eq!( - args.ipv4.len(), - 2, - "2 of the 4 destinations are IPv4; actual: {:?}", - args.ipv4 - ); - assert_eq!( - args.ipv6.len(), - 2, - "2 of the 4 destinations are IPv6; actual: {:?}", - args.ipv6 - ); -} - -#[test] -fn programmed_rules_are_logged_with_the_exact_iptables_and_ip6tables_prefixes() { - let allowed_v4 = "203.0.113.44"; - let blocked_v6 = "2606:50c0::/32"; - let chain = "mxc_log_chain"; - let policy = ContainerPolicy { - allowed_hosts: vec![allowed_v4.to_string()], - blocked_hosts: vec![blocked_v6.to_string()], - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - - let result = - NetworkIptablesManager::build_policy_rules_logged(chain, &policy, false, &mut logger); - let args = expect_ok(result, "both entries resolve, so no error is expected here"); - - assert_eq!(args.ipv4.len(), 1, "actual: {:?}", args.ipv4); - assert_eq!(args.ipv6.len(), 1, "actual: {:?}", args.ipv6); - - let buffer = logger.get_buffer(); - let expected_ipv4_line = - format!("Programmed iptables rule: -A {chain} -d {allowed_v4} -j ACCEPT"); - let expected_ipv6_line = - format!("Programmed ip6tables rule: -A {chain} -d {blocked_v6} -j DROP"); - assert!( - buffer.lines().any(|line| line == expected_ipv4_line), - "expected the IPv4 programmed-rule line {expected_ipv4_line:?}; \ - actual buffer: {buffer:?}" - ); - assert!( - buffer.lines().any(|line| line == expected_ipv6_line), - "expected the IPv6 programmed-rule line {expected_ipv6_line:?}; \ - actual buffer: {buffer:?}" - ); -} - -#[test] -fn an_empty_policy_produces_an_empty_ok_result_with_no_log_output() { - let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - - let result = - NetworkIptablesManager::build_policy_rules_logged(CHAIN, &policy, false, &mut logger); - let args = expect_ok(result, "B6: empty host lists must still return Ok"); - - assert!( - args.ipv4.is_empty(), - "an empty policy must produce no IPv4 rules; actual: {:?}", - args.ipv4 - ); - assert!( - args.ipv6.is_empty(), - "an empty policy must produce no IPv6 rules; actual: {:?}", - args.ipv6 - ); - assert!( - logger.get_buffer().is_empty(), - "with nothing to program and nothing unresolvable, nothing should \ - be logged; actual buffer: {:?}", - logger.get_buffer() - ); -} diff --git a/src/mxc-sdk/src/backends/lxc/common/network_iptables_ga_egress_spec.rs b/src/mxc-sdk/src/backends/lxc/common/network_iptables_ga_egress_spec.rs index 457be38e3..2de176c85 100644 --- a/src/mxc-sdk/src/backends/lxc/common/network_iptables_ga_egress_spec.rs +++ b/src/mxc-sdk/src/backends/lxc/common/network_iptables_ga_egress_spec.rs @@ -2,7 +2,6 @@ // Licensed under the MIT License. use super::*; -use crate::mxc_common::models::NetworkEnforcementMode; fn directional_policy( default: NetworkAction, @@ -182,7 +181,7 @@ fn explicit_deny_precedes_an_overlapping_allow_in_both_families() { vec![matching_rule()], vec![matching_rule()], ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( destination_actions(&rules.ipv4, ipv4), @@ -207,7 +206,7 @@ fn an_allow_peer_exclusion_is_not_accepted_alongside_its_parent() { vec![rule(vec![peer(parent, &[exclusion])], Vec::new())], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -244,7 +243,7 @@ fn a_deny_peer_exclusion_remains_outside_the_deny() { Vec::new(), vec![rule(vec![peer(parent, &[exclusion])], Vec::new())], ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -281,7 +280,7 @@ fn an_exclusion_inside_an_allow_rule_under_an_allow_default_stays_reachable() { vec![rule(vec![peer(parent, &[exclusion])], Vec::new())], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -312,7 +311,7 @@ fn an_exclusion_inside_a_deny_rule_under_a_deny_default_stays_blocked() { Vec::new(), vec![rule(vec![peer(parent, &[exclusion])], Vec::new())], ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -343,7 +342,7 @@ fn each_cidr_is_emitted_only_in_its_matching_address_family() { vec![rule(vec![peer(ipv4, &[]), peer(ipv6, &[])], Vec::new())], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( destination_actions(&rules.ipv4, ipv4), @@ -379,7 +378,7 @@ fn an_omitted_to_matches_destinations_in_both_address_families() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( new_connection_action( @@ -413,7 +412,7 @@ fn omitted_ports_match_every_protocol_and_port() { vec![rule(vec![peer(destination, &[])], Vec::new())], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); let address = packet_address("192.0.2.25"); for (protocol, port) in [("tcp", Some(443)), ("udp", Some(53)), ("icmp", None)] { @@ -437,7 +436,7 @@ fn explicit_any_without_a_port_matches_every_protocol_and_port() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); let address = packet_address("192.0.2.25"); for (protocol, port) in [ @@ -470,7 +469,7 @@ fn any_with_a_port_expands_to_tcp_and_udp_rules() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); let address = packet_address("192.0.2.25"); let mut selectors = rules .ipv4 @@ -528,7 +527,7 @@ fn icmp_ignores_a_written_port() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); let emitted_rule = rules.ipv4.iter().find(|rule| { argument_after(rule, "-d") == Some(destination) && argument_after(rule, "-j") == Some("ACCEPT") @@ -561,7 +560,7 @@ fn a_single_port_and_an_inclusive_range_restrict_their_protocols() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); let address = packet_address("192.0.2.25"); for (protocol, packet_port, expected_action) in [ @@ -599,7 +598,7 @@ fn each_peer_combines_with_each_port_selector() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); for (destination, address) in [ (first_destination, "192.0.2.25"), @@ -633,7 +632,7 @@ fn icmp_uses_the_family_specific_protocol_without_a_port() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); let ipv4_rule = rules.ipv4.iter().find(|rule| { argument_after(rule, "-d") == Some(ipv4) && argument_after(rule, "-j") == Some("ACCEPT") }); @@ -792,21 +791,6 @@ fn a_directional_deny_naming_a_resolver_is_not_preceded_by_a_dns_accept() { ); } -#[test] -fn a_legacy_policy_still_opens_dns() { - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: vec!["example.com".to_string()], - ..Default::default() - }; - let rules = appended_ipv4_chain_rules("legacy-dns", &policy); - - assert!( - opens_base_dns_exemption(&rules), - "input=legacy defaultPolicy=block; expected the documented port 53 accept; output={rules:?}" - ); -} - #[test] fn a_parsed_directional_request_drops_the_dns_exemption() { let policy = policy_from_json( @@ -819,12 +803,12 @@ fn a_parsed_directional_request_drops_the_dns_exemption() { assert!( !opens_base_dns_exemption(&rules), - "input=0.8 egress.default=deny; expected no port 53 accept; output={rules:?}" + "input=v0.9 egress.default=deny; expected no port 53 accept; output={rules:?}" ); } #[test] -fn a_parsed_v08_request_without_a_network_section_drops_the_dns_exemption() { +fn a_parsed_request_without_a_network_section_drops_the_dns_exemption() { let policy = policy_from_json( r#"{"version": "0.9.0-alpha", "process": {"commandLine": "echo hi"}}"#, @@ -832,7 +816,7 @@ fn a_parsed_v08_request_without_a_network_section_drops_the_dns_exemption() { assert!( !plan_network(&policy).installs_firewall(), - "input=0.8 with no network section; expected no chain at all, so no rule can open DNS" + "input=v0.9 with no network section; expected no chain at all, so no rule can open DNS" ); } #[test] @@ -845,7 +829,7 @@ fn an_exclusion_does_not_shadow_a_later_rule_that_names_it() { rule(vec![peer("10.10.1.0/24", &[])], Vec::new()), ], ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -883,7 +867,7 @@ fn the_emitted_blocks_cover_the_peer_without_its_exclusion() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); for reachable in ["10.0.0.1", "10.9.255.255", "10.11.0.0", "10.255.255.255"] { assert_eq!( @@ -926,7 +910,7 @@ fn an_ipv6_exclusion_is_subtracted_within_its_own_family() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -972,11 +956,10 @@ fn a_peer_that_expands_past_the_block_ceiling_is_refused() { ); let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let error = - NetworkIptablesManager::build_policy_rules_logged("MXC-test", &policy, true, &mut logger) - .expect_err( - "a peer expanding past the ceiling must be refused rather than partly installed", - ); + let error = NetworkIptablesManager::build_policy_rules_logged("MXC-test", &policy, &mut logger) + .expect_err( + "a peer expanding past the ceiling must be refused rather than partly installed", + ); assert!( error.contains("except"), @@ -994,7 +977,7 @@ fn an_ipv4_mapped_peer_still_reaches_the_ipv4_chain_after_subtraction() { )], Vec::new(), ); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -1042,7 +1025,6 @@ fn unresolvable_peer() -> NetworkPeer { #[test] fn an_unresolvable_directional_deny_is_fatal_under_an_allow_egress_default() { let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Block, ..directional_policy( NetworkAction::Allow, Vec::new(), @@ -1051,15 +1033,10 @@ fn an_unresolvable_directional_deny_is_fatal_under_an_allow_egress_default() { }; let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let error = NetworkIptablesManager::build_policy_rules_logged( - "MXC-test", - &policy, - uses_directional_keys(&policy), - &mut logger, - ) - .expect_err( - "input=egress.default=allow with an unresolvable deny, legacy default_network_policy=block; expected a refusal, since reading the legacy field instead would close the chain with ACCEPT and leave the denied destination reachable", - ); + let error = NetworkIptablesManager::build_policy_rules_logged("MXC-test", &policy, &mut logger) + .expect_err( + "egress.default=allow must refuse an unresolvable deny rather than leave it reachable", + ); assert!( error.contains("deny"), @@ -1070,7 +1047,6 @@ fn an_unresolvable_directional_deny_is_fatal_under_an_allow_egress_default() { #[test] fn an_unresolvable_directional_deny_is_tolerated_under_a_deny_egress_default() { let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, ..directional_policy( NetworkAction::Deny, Vec::new(), @@ -1082,17 +1058,16 @@ fn an_unresolvable_directional_deny_is_tolerated_under_a_deny_egress_default() { NetworkIptablesManager::build_policy_rules_logged( "MXC-test", &policy, - uses_directional_keys(&policy), &mut logger, ) .expect( - "input=egress.default=deny with an unresolvable deny, legacy default_network_policy=allow; expected no refusal, since the closing DROP already covers what the deny could not program", + "input=egress.default=deny with an unresolvable deny, expected no refusal, since the closing DROP already covers what the deny could not program", ); } fn lowering_error(policy: &ContainerPolicy) -> String { let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - NetworkIptablesManager::build_policy_rules_logged("MXC-test", policy, true, &mut logger) + NetworkIptablesManager::build_policy_rules_logged("MXC-test", policy, &mut logger) .expect_err("expected the policy to be refused") } @@ -1118,7 +1093,7 @@ fn an_ipv6_peer_whose_exclusion_names_the_mapped_range_is_refused() { #[test] fn an_ipv4_peer_subtracts_an_exclusion_written_in_mapped_notation() { let policy = allow_peer_policy(vec![peer("10.0.0.0/8", &["::ffff:10.10.0.0/112"])]); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -1149,7 +1124,7 @@ fn an_ipv4_peer_subtracts_an_exclusion_written_in_mapped_notation() { #[test] fn a_mapped_peer_subtracts_an_exclusion_written_in_plain_ipv4() { let policy = allow_peer_policy(vec![peer("::ffff:10.0.0.0/104", &["10.10.0.0/16"])]); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert_eq!( chain_verdict( @@ -1215,7 +1190,7 @@ fn an_ipv6_peer_whose_exclusion_neighbours_the_mapped_range_is_refused() { #[test] fn an_ipv6_peer_far_from_the_mapped_range_still_subtracts() { let policy = allow_peer_policy(vec![peer("2001:db8::/32", &["2001:db8:1::/48"])]); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert!( rules.ipv4.is_empty(), @@ -1239,7 +1214,7 @@ fn an_ipv6_peer_far_from_the_mapped_range_still_subtracts() { #[test] fn an_ipv6_catch_all_without_an_exclusion_is_untouched_by_the_guard() { let policy = allow_peer_policy(vec![peer("::/0", &[])]); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy, true); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); assert!( rules.ipv4.is_empty(), diff --git a/src/mxc-sdk/src/backends/lxc/common/network_iptables_proxy_spec.rs b/src/mxc-sdk/src/backends/lxc/common/network_iptables_proxy_spec.rs deleted file mode 100644 index f45da9d4a..000000000 --- a/src/mxc-sdk/src/backends/lxc/common/network_iptables_proxy_spec.rs +++ /dev/null @@ -1,535 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use super::*; -use crate::mxc_common::logger::{Logger, Mode}; -use crate::mxc_common::models::{ - ContainerPolicy, NetworkEnforcementMode, NetworkPolicy, ProxyAddress, ProxyConfig, -}; - -fn policy_with_proxy(host: &str, port: u16) -> ContainerPolicy { - ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - network_proxy: ProxyConfig { - address: Some(ProxyAddress::new(host.to_string(), port)), - builtin_test_server: false, - }, - ..Default::default() - } -} - -fn apply_and_collect( - container: &str, - policy: &ContainerPolicy, -) -> ( - NetworkIptablesManager, - Vec>, - Result, -) { - let fake = super::test_firewall::install(); - let mut manager = NetworkIptablesManager::new(container, EgressHookPoint::ContainerNetns(4242)); - let mut logger = Logger::new(Mode::Buffer); - let _ = fake.forget_issued(); - - let result = manager.apply_firewall_rules(policy, &mut logger); - let issued = fake.issued(); - (manager, issued, result) -} - -fn appended_rules<'a>( - issued: &'a [Vec], - binary: &str, - chain: &str, -) -> Vec<&'a Vec> { - issued - .iter() - .filter(|argv| { - argv.first().map(String::as_str) == Some(binary) - && argv.get(1).map(String::as_str) == Some("-A") - && argv.get(2).map(String::as_str) == Some(chain) - }) - .collect() -} - -fn action_of(rule: &[String]) -> Option<&str> { - let index = rule.iter().position(|arg| arg == "-j")?; - rule.get(index + 1).map(String::as_str) -} - -fn has_pair(rule: &[String], flag: &str, value: &str) -> bool { - rule.windows(2) - .any(|pair| pair[0] == flag && pair[1] == value) -} - -#[test] -fn proxy_mode_forces_a_drop_default_even_when_the_policy_says_allow() { - assert_eq!( - NetworkIptablesManager::default_policy_action(NetworkPolicy::Allow, true), - "DROP" - ); - assert_eq!( - NetworkIptablesManager::default_policy_action(NetworkPolicy::Block, true), - "DROP" - ); -} - -#[test] -fn without_a_proxy_the_configured_default_policy_still_decides_the_catch_all() { - assert_eq!( - NetworkIptablesManager::default_policy_action(NetworkPolicy::Allow, false), - "ACCEPT" - ); - assert_eq!( - NetworkIptablesManager::default_policy_action(NetworkPolicy::Block, false), - "DROP" - ); -} - -#[test] -fn the_terminal_rule_built_in_proxy_mode_drops() { - let rule = NetworkIptablesManager::build_default_policy_rule_arg( - "MXC-proxy-terminal", - NetworkPolicy::Allow, - true, - ); - - assert_eq!(action_of(&rule), Some("DROP"), "actual rule: {rule:?}"); -} - -#[test] -fn an_applied_proxy_chain_ends_in_drop_under_an_allow_default() { - let mut policy = policy_with_proxy("10.9.8.7", 3128); - policy.default_network_policy = NetworkPolicy::Allow; - - let (manager, issued, result) = apply_and_collect("proxy-allow-default", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "iptables", manager.chain_name()); - let last = rules.last().expect("the chain must have at least one rule"); - assert_eq!( - action_of(last), - Some("DROP"), - "the last rule appended to a proxied chain must be the closing DROP; actual: {last:?}" - ); -} - -#[test] -fn the_proxy_accept_names_the_proxy_address_port_and_protocol() { - let policy = policy_with_proxy("10.9.8.7", 3128); - - let (manager, issued, result) = apply_and_collect("proxy-shape", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "iptables", manager.chain_name()); - let accepts: Vec<&&Vec> = rules - .iter() - .filter(|rule| action_of(rule) == Some("ACCEPT") && !has_pair(rule, "-o", "lo")) - .collect(); - - assert_eq!( - accepts.len(), - 1, - "a proxied chain must carry exactly one ACCEPT reaching off-box, for the proxy; \ - actual: {rules:?}" - ); - let accept = accepts[0]; - assert!( - has_pair(accept, "-d", "10.9.8.7"), - "the proxy ACCEPT must name the proxy address; actual: {accept:?}" - ); - assert!( - has_pair(accept, "--dport", "3128"), - "the proxy ACCEPT must name the proxy port; actual: {accept:?}" - ); - assert!( - has_pair(accept, "-p", "tcp"), - "the proxy ACCEPT must be scoped to TCP; actual: {accept:?}" - ); -} - -#[test] -fn the_proxy_accept_is_appended_before_the_closing_drop() { - let policy = policy_with_proxy("10.9.8.7", 3128); - - let (manager, issued, result) = apply_and_collect("proxy-order", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "iptables", manager.chain_name()); - let actions: Vec> = rules.iter().map(|rule| action_of(rule)).collect(); - - assert_eq!( - actions, - vec![Some("ACCEPT"), Some("ACCEPT"), Some("DROP")], - "a proxied chain must read exactly 'keep loopback, accept the proxy, drop the rest'; \ - actual: {rules:?}" - ); - assert!( - has_pair(rules[0], "-o", "lo"), - "loopback comes first; actual: {rules:?}" - ); - assert!( - has_pair(rules[1], "-d", "10.9.8.7"), - "the proxy ACCEPT comes second; actual: {rules:?}" - ); -} - -#[test] -fn every_resolved_proxy_address_is_opened() { - let mut logger = Logger::new(Mode::Buffer); - let policy = policy_with_proxy("localhost", 8888); - - let (endpoints, _pin) = NetworkIptablesManager::resolve_proxy_endpoints(&policy, &mut logger) - .expect("localhost must resolve"); - - assert!( - !endpoints.is_empty(), - "localhost must yield at least one endpoint" - ); - assert!( - endpoints.iter().all(|endpoint| endpoint.port == 8888), - "every endpoint must carry the configured proxy port; actual: {endpoints:?}" - ); -} - -#[test] -fn proxy_mode_opens_no_dns_port() { - let policy = policy_with_proxy("10.9.8.7", 3128); - - let (manager, issued, result) = apply_and_collect("proxy-nodns", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "iptables", manager.chain_name()); - assert!( - !rules.is_empty(), - "the proxied chain must have been programmed at all; issued: {issued:?}" - ); - - for rule in rules { - assert!( - !has_pair(rule, "--dport", "53"), - "a proxied chain must not open DNS; actual: {rule:?}" - ); - } -} - -#[test] -fn proxy_mode_keeps_loopback_and_drops_the_conntrack_exemption() { - let policy = policy_with_proxy("10.9.8.7", 3128); - - let (manager, issued, result) = apply_and_collect("proxy-nobase", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "iptables", manager.chain_name()); - assert!( - !rules.is_empty(), - "the proxied chain must have been programmed at all; issued: {issued:?}" - ); - - assert!( - rules.iter().any(|rule| has_pair(rule, "-o", "lo")), - "a proxied container must still reach its own loopback; actual: {rules:?}" - ); - - for rule in rules { - assert!( - !has_pair(rule, "-i", "lo"), - "this chain hangs off OUTPUT, where -i never matches; actual: {rule:?}" - ); - assert!( - !has_pair(rule, "--state", "ESTABLISHED,RELATED"), - "a proxied chain must not carry the conntrack exemption; actual: {rule:?}" - ); - } -} - -#[test] -fn proxy_mode_does_not_program_the_allow_list() { - let mut policy = policy_with_proxy("10.9.8.7", 3128); - policy.allowed_hosts = vec!["10.1.1.1".to_string()]; - - let (manager, issued, result) = apply_and_collect("proxy-nolists", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "iptables", manager.chain_name()); - assert!( - !rules.is_empty(), - "the proxied chain must have been programmed at all; issued: {issued:?}" - ); - - for rule in rules { - assert!( - !has_pair(rule, "-d", "10.1.1.1"), - "a proxied chain must ignore the allow list; actual: {rule:?}" - ); - } -} - -#[test] -fn a_proxy_combined_with_a_block_list_is_refused() { - let mut policy = policy_with_proxy("10.9.8.7", 3128); - policy.blocked_hosts = vec!["10.2.2.2".to_string()]; - - let (_manager, _issued, result) = apply_and_collect("proxy-blocked", &policy); - - let message = result.expect_err("a policy whose block list cannot be enforced must be refused"); - assert!( - message.contains("blockedHosts"), - "the refusal must name the setting that cannot be enforced, got: {message}" - ); -} - -#[test] -fn the_ipv6_chain_allows_only_loopback_before_its_closing_drop_in_proxy_mode() { - let policy = policy_with_proxy("10.9.8.7", 3128); - - let (manager, issued, result) = apply_and_collect("proxy-v6", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "ip6tables", manager.chain_name()); - let actions: Vec> = rules.iter().map(|rule| action_of(rule)).collect(); - - assert_eq!( - actions, - vec![Some("ACCEPT"), Some("DROP")], - "the IPv6 chain of a proxied container must deny everything it can route; \ - actual: {rules:?}" - ); - assert!( - has_pair(rules[0], "-o", "lo"), - "the only IPv6 ACCEPT must be the loopback one; actual: {rules:?}" - ); -} - -#[test] -fn without_a_proxy_the_base_exemptions_and_host_lists_are_still_programmed() { - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: vec!["10.1.1.1".to_string()], - ..Default::default() - }; - - let (manager, issued, result) = apply_and_collect("proxy-control", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let rules = appended_rules(&issued, "iptables", manager.chain_name()); - assert!( - rules.iter().any(|rule| has_pair(rule, "-o", "lo")), - "a non-proxied chain must still carry the loopback exemption; actual: {rules:?}" - ); - assert!( - rules.iter().any(|rule| has_pair(rule, "--dport", "53")), - "a non-proxied chain must still open DNS; actual: {rules:?}" - ); - assert!( - rules.iter().any(|rule| has_pair(rule, "-d", "10.1.1.1")), - "a non-proxied chain must still program its allow list; actual: {rules:?}" - ); -} - -#[test] -fn no_chain_opens_a_dhcp_port() { - let filtered = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - default_network_policy: NetworkPolicy::Block, - allowed_hosts: vec!["10.1.1.1".to_string()], - ..Default::default() - }; - - for (name, policy) in [ - ("dhcp-filtered", filtered), - ("dhcp-proxied", policy_with_proxy("10.9.8.7", 3128)), - ] { - let (manager, issued, result) = apply_and_collect(name, &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let mut programmed = false; - for binary in ["iptables", "ip6tables"] { - for rule in appended_rules(&issued, binary, manager.chain_name()) { - programmed = true; - assert!( - !has_pair(rule, "--dport", "67") && !has_pair(rule, "--dport", "547"), - "{name} must not open a DHCP port; actual: {rule:?}" - ); - } - } - assert!( - programmed, - "{name} never reached the rule builder, so this assertion would hold \ - vacuously; issued: {issued:?}" - ); - } -} - -#[test] -fn an_ipv6_proxy_literal_is_refused_rather_than_silently_dropped() { - let mut logger = Logger::new(Mode::Buffer); - - for host in ["2001:db8::1", "[2001:db8::1]"] { - let policy = policy_with_proxy(host, 3128); - let err = NetworkIptablesManager::resolve_proxy_endpoints(&policy, &mut logger) - .expect_err("an IPv6 proxy endpoint must be refused"); - - assert!( - err.to_lowercase().contains("ipv6"), - "the refusal must say IPv6 is the reason, got: {err}" - ); - } -} - -#[test] -fn ipv6_literals_are_recognized_bracketed_or_bare() { - assert!( - NetworkIptablesManager::host_is_ipv6_literal("::1"), - "a bare IPv6 literal must be recognized" - ); - assert!( - NetworkIptablesManager::host_is_ipv6_literal("[::1]"), - "a bracketed IPv6 literal must be recognized" - ); - assert!( - NetworkIptablesManager::host_is_ipv6_literal("2001:db8::1"), - "a full IPv6 literal must be recognized" - ); - assert!( - !NetworkIptablesManager::host_is_ipv6_literal("10.9.8.7"), - "an IPv4 literal must not be classified as IPv6" - ); - assert!( - !NetworkIptablesManager::host_is_ipv6_literal("proxy.example.com"), - "a hostname must not be classified as IPv6" - ); -} - -#[test] -fn a_hostname_proxy_records_a_pin_naming_an_authorized_address() { - let policy = policy_with_proxy("localhost", 8888); - - let (manager, _issued, result) = apply_and_collect("proxy-pin", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - let pin = manager - .proxy_host_pin() - .expect("a hostname proxy must record a pin"); - assert_eq!(pin.hostname(), "localhost"); - assert_eq!(pin.ip().to_string(), "127.0.0.1"); -} - -#[test] -fn an_ip_literal_proxy_records_no_pin() { - let policy = policy_with_proxy("10.9.8.7", 3128); - - let (manager, _issued, result) = apply_and_collect("proxy-nopin", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - assert!( - manager.proxy_host_pin().is_none(), - "an IP-literal proxy needs no hosts entry" - ); -} - -#[test] -fn a_policy_without_a_proxy_records_no_pin() { - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - ..Default::default() - }; - - let (manager, _issued, result) = apply_and_collect("proxy-absent", &policy); - assert!(result.is_ok(), "apply must succeed, got {result:?}"); - - assert!( - manager.proxy_host_pin().is_none(), - "a policy without a proxy must not record a hosts-file pin" - ); -} - -#[test] -fn a_zero_proxy_port_is_refused() { - let mut logger = Logger::new(Mode::Buffer); - let policy = policy_with_proxy("10.9.8.7", 0); - - let err = NetworkIptablesManager::resolve_proxy_endpoints(&policy, &mut logger) - .expect_err("port 0 must be refused"); - - assert!( - err.to_lowercase().contains("port"), - "the refusal must name the port as the reason, got: {err}" - ); -} - -#[test] -fn an_unresolvable_proxy_host_is_refused() { - let mut logger = Logger::new(Mode::Buffer); - let policy = policy_with_proxy("proxy.invalid", 3128); - - let err = NetworkIptablesManager::resolve_proxy_endpoints(&policy, &mut logger) - .expect_err("an unresolvable proxy host must be refused"); - - assert!( - err.contains("proxy.invalid"), - "the refusal must name the host that failed, got: {err}" - ); -} - -#[test] -fn a_policy_without_a_proxy_resolves_to_no_endpoints() { - let mut logger = Logger::new(Mode::Buffer); - let policy = ContainerPolicy::default(); - - let (endpoints, pin) = NetworkIptablesManager::resolve_proxy_endpoints(&policy, &mut logger) - .expect("a policy with no proxy must not be an error"); - - assert!(endpoints.is_empty()); - assert!(pin.is_none()); -} - -#[test] -fn a_proxy_answer_larger_than_the_cap_contributes_no_more_than_the_cap() { - let addresses: Vec = (0..40).map(|n| format!("203.0.113.{n}")).collect(); - let mut logger = Logger::new(Mode::Buffer); - - let accepted = - NetworkIptablesManager::bound_proxy_addresses("proxy.invalid", &addresses, &mut logger); - - assert_eq!( - accepted.len(), - NetworkIptablesManager::MAX_PROXY_ENDPOINTS, - "a 40-address answer must not become 40 ACCEPT rules and 40 iptables \ - processes on the container-start path; actual count: {}", - accepted.len() - ); - - assert_eq!( - accepted.first().map(String::as_str), - Some("203.0.113.0"), - "the pinned address must survive the bound; actual: {:?}", - accepted.first() - ); - - let buffer = logger.get_buffer(); - assert!( - buffer.contains("proxy.invalid") && buffer.contains("40"), - "a trimmed answer must say which host was trimmed and from what size; \ - actual buffer: {buffer:?}" - ); -} - -#[test] -fn a_proxy_answer_within_the_cap_is_left_alone() { - let addresses: Vec = (0..3).map(|n| format!("203.0.113.{n}")).collect(); - let mut logger = Logger::new(Mode::Buffer); - - let accepted = - NetworkIptablesManager::bound_proxy_addresses("proxy.invalid", &addresses, &mut logger); - - assert_eq!( - accepted, - addresses.as_slice(), - "an answer within the cap must reach the chain intact" - ); - assert!( - logger.get_buffer().is_empty(), - "an untrimmed answer must not warn; actual buffer: {:?}", - logger.get_buffer() - ); -} diff --git a/src/mxc-sdk/src/backends/lxc/common/signal_cleanup.rs b/src/mxc-sdk/src/backends/lxc/common/signal_cleanup.rs index a04268768..e72364e2b 100644 --- a/src/mxc-sdk/src/backends/lxc/common/signal_cleanup.rs +++ b/src/mxc-sdk/src/backends/lxc/common/signal_cleanup.rs @@ -329,7 +329,7 @@ mod tests { fn a_backend_that_registers_no_container_publishes_no_ownership() { use crate::lxc_common::network_iptables::{EgressHookPoint, NetworkIptablesManager}; use crate::mxc_common::logger::{Logger, Mode}; - use crate::mxc_common::models::{ContainerPolicy, NetworkEnforcementMode}; + use crate::mxc_common::models::{ContainerPolicy, NetworkAction, NetworkEgressPolicy}; let _watchdog = Watchdog::installed(); let _fake = crate::lxc_common::network_iptables::test_firewall::install(); @@ -337,8 +337,10 @@ mod tests { let mut manager = NetworkIptablesManager::new("mxc-bwrap-publish-test", EgressHookPoint::Unhooked); let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - allowed_hosts: vec!["192.0.2.10".to_string()], + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), ..Default::default() }; let mut logger = Logger::new(Mode::Buffer); diff --git a/src/mxc-sdk/src/backends/nanvix/runner/mod.rs b/src/mxc-sdk/src/backends/nanvix/runner/mod.rs index a578d9f97..fb84b0b71 100644 --- a/src/mxc-sdk/src/backends/nanvix/runner/mod.rs +++ b/src/mxc-sdk/src/backends/nanvix/runner/mod.rs @@ -36,10 +36,10 @@ //! ## Networking //! //! Host networking is **off by default** and is enabled per-run by passing -//! `-allow-host-networking` to `nanvixd`. The runner adds that flag when the -//! request sets `network.defaultPolicy = "allow"`, or when `allowedHosts` / -//! `blockedHosts` is present (forwarded as `-allow-host` / `-block-host`). -//! Network proxies are not supported and are rejected at validation time. +//! `-allow-host-networking` to `nanvixd` only when egress.default, +//! ingress.default, and ingress.hostLoopback all allow. All-deny (including +//! omitted defaults) stays disconnected; mixed postures, egress rules, proxies, +//! and retired legacy network fields are rejected before execution. //! //! Auto-discovery //! @@ -47,7 +47,6 @@ //! are discovered next to the running executable. No configuration is needed. use std::fmt::Write; -use std::net::ToSocketAddrs; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -57,7 +56,9 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ExecutionRequest, NetworkAction, NetworkPolicy, ScriptResponse}; +use crate::mxc_common::models::{ + ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, ScriptResponse, +}; use crate::mxc_common::script_runner::ScriptRunner; use crate::mxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; @@ -99,44 +100,17 @@ const ERR_DENIED_PATHS: &str = concat!( "-- the guest has no host filesystem visibility. ", "Only readwrite_paths and readonly_paths are supported", ); -const ERR_NETWORK_HOSTS: &str = concat!( - "allowedHosts and blockedHosts are mutually exclusive for the NanVix backend -- ", - "the guest egress filter is allow-XOR-block. Specify an allowlist (allowedHosts) ", - "or a blocklist (blockedHosts), not both", -); -const ERR_HOSTS_UNRESOLVED: &str = concat!( - "none of the specified allowedHosts/blockedHosts resolved to an IPv4 address -- ", - "the NanVix guest filter is IPv4-only; use IPv4 literals/CIDR or hosts with A records", -); -const ERR_BLOCKED_HOST_UNRESOLVED: &str = concat!( - "a blockedHosts entry did not resolve to any IPv4 address -- ", - "the NanVix guest egress filter is static (resolved once at preflight) and IPv4-only, ", - "so an unresolvable blocked host cannot be enforced. Silently dropping it would let ", - "traffic the policy explicitly blocks flow freely, so the run is rejected (fail-closed). ", - "Use an IPv4 literal/CIDR or a host with A records", -); +const ERR_LEGACY_NETWORK: &str = "NanVix does not support retired legacy network fields \ + (defaultPolicy, enforcementMode, allowLocalNetwork, allowedHosts, blockedHosts); \ + use network.egress and network.ingress"; const ERR_PROXY_POLICY: &str = "network proxy is not supported by the NanVix backend"; const ERR_DIRECTIONAL_NETWORK: &str = "NanVix supports only fully isolated or explicitly \ unrestricted directional networking: egress.default, ingress.default and ingress.hostLoopback \ must all be deny or all be allow; independent ingress or host-loopback restrictions are not supported"; -const ERR_DIRECTIONAL_FILTERS: &str = "NanVix cannot enforce directional egress rules: its legacy \ - IPv4 filter has implicit exceptions and does not implement the directional rule contract; \ +const ERR_DIRECTIONAL_FILTERS: &str = "NanVix cannot enforce directional egress rules; \ use fully isolated networking or explicitly unrestricted networking without rules"; const ERR_WORKDIR: &str = "workingDirectory is not supported by the NanVix backend -- guest has its own filesystem namespace"; -/// Outcome of resolving a request's egress host lists. -/// -/// `allow`/`block` are the IPv4/CIDR literals handed to nanvixd; at most one is -/// non-empty. `warnings` carries human-readable notices for allowlist entries -/// that were dropped during resolution (blocklist drops are a hard error and -/// never reach here). -#[derive(Debug)] -struct ResolvedHostLists { - allow: Vec, - block: Vec, - warnings: Vec, -} - /// Maps a finished child's [`ExitStatus`] to a host-visible exit code. /// /// On Unix, processes terminated by a signal have no exit code (`status.code()` @@ -527,16 +501,18 @@ impl NanVixScriptRunner { fn resolve_networking_mode(request: &ExecutionRequest) -> Result { let policy = &request.policy; - if policy.network_egress.is_none() && policy.network_ingress.is_none() { - return Ok(policy.default_network_policy == NetworkPolicy::Allow - || !policy.allowed_hosts.is_empty()); - } - if !policy.allowed_hosts.is_empty() + if policy.default_network_policy != NetworkPolicy::Block + || policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities + || policy.allow_local_network + || !policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty() - || policy - .network_egress - .as_ref() - .is_some_and(|egress| !egress.allow.is_empty() || !egress.deny.is_empty()) + { + return Err(NanVixError::Preflight(ERR_LEGACY_NETWORK.to_string())); + } + if policy + .network_egress + .as_ref() + .is_some_and(|egress| !egress.allow.is_empty() || !egress.deny.is_empty()) { return Err(NanVixError::Preflight(ERR_DIRECTIONAL_FILTERS.to_string())); } @@ -561,122 +537,11 @@ impl NanVixScriptRunner { Ok(egress == NetworkAction::Allow) } - /// Resolves a host entry list into IPv4/CIDR literals for nanvixd's - /// `-allow-host`/`-block-host` flags, alongside the entries that resolved - /// to nothing. - /// - /// - `a.b.c.d` and `a.b.c.d/n` literals pass through unchanged. - /// - Hostnames resolve to their IPv4 (A-record) addresses; AAAA results are - /// dropped because the guest filter is IPv4-only. - /// - Entries that fail to parse or resolve to any IPv4 address contribute - /// nothing to the resolved list and are collected into the second return - /// value so callers can warn (allowlist) or reject (blocklist). - /// - /// Mirrors `lxc::network_iptables::resolve_host` for the hostname-to-IPv4 - /// mapping; unlike that helper this also preserves IPv4/CIDR literals. - /// - /// Returns `(resolved_ips, unresolved_entries)`. Empty/whitespace entries - /// are ignored entirely and appear in neither list. - fn resolve_hosts_detailed(hosts: &[String]) -> (Vec, Vec) { - let mut out: Vec = Vec::new(); - let mut unresolved: Vec = Vec::new(); - for host in hosts { - let entry = host.trim(); - if entry.is_empty() { - continue; - } - let before = out.len(); - // CIDR literal: pass through only when the address is IPv4 and the - // prefix is in range. nanvixd parses CIDR directly. - if let Some((addr, prefix)) = entry.split_once('/') { - let addr_ok = addr.trim().parse::().is_ok(); - let prefix_ok = prefix - .trim() - .parse::() - .map(|p| p <= 32) - .unwrap_or(false); - if addr_ok && prefix_ok { - out.push(entry.to_string()); - } - } else if let Ok(addr) = entry.parse::() { - // Bare IP literal: keep IPv4, drop IPv6. - if addr.is_ipv4() { - out.push(entry.to_string()); - } - } else if let Ok(addrs) = format!("{}:0", entry).to_socket_addrs() { - // Hostname: resolve to IPv4 A records. - for ip in addrs.map(|a| a.ip()).filter(|ip| ip.is_ipv4()) { - out.push(ip.to_string()); - } - } - if out.len() == before { - unresolved.push(entry.to_string()); - } - } - (out, unresolved) - } - - /// Resolves the request's allow/block host lists, failing closed. - /// - /// Returns the resolved allow/block IPv4 lists plus human-readable warnings - /// for any allowlist entries that were dropped. Shared validation rejects - /// invalid default/list combinations before execution, and NanVix rejects - /// simultaneous lists because its guest filter is allow-XOR-block. - /// - /// Fail-closed semantics differ by list direction: - /// - **Allowlist** (deny-by-default): a fully unresolvable allowlist is an - /// error, because emitting `-allow-host-networking` with no filter would - /// fail open (nanvixd treats no list as allow-all). Partially dropped - /// entries only narrow access, so they are reported as warnings and the - /// run continues. - /// - **Blocklist** (allow-by-default): *any* unresolvable entry is an - /// error. Silently dropping a blocked host would let traffic the policy - /// explicitly blocks flow freely (fail-open), and the static preflight - /// filter cannot enforce a name that does not resolve. - fn resolve_host_lists(request: &ExecutionRequest) -> Result { - let (allow, allow_unresolved) = Self::resolve_hosts_detailed(&request.policy.allowed_hosts); - if !request.policy.allowed_hosts.is_empty() && allow.is_empty() { - return Err(NanVixError::Preflight(ERR_HOSTS_UNRESOLVED.to_string())); - } - - let (block, block_unresolved) = Self::resolve_hosts_detailed(&request.policy.blocked_hosts); - if let Some(first) = block_unresolved.first() { - return Err(NanVixError::Preflight(format!( - "{} (entry: '{}')", - ERR_BLOCKED_HOST_UNRESOLVED, first - ))); - } - - let warnings = allow_unresolved - .iter() - .map(|h| { - format!( - "Warning: could not resolve allowedHosts entry '{}' to an IPv4 address; skipping", - h - ) - }) - .collect(); - - Ok(ResolvedHostLists { - allow, - block, - warnings, - }) - } - fn validate_policies(request: &ExecutionRequest) -> Result<(), NanVixError> { // denied_paths is explicitly rejected — microvm has no host visibility. if !request.policy.denied_paths.is_empty() { return Err(NanVixError::Preflight(ERR_DENIED_PATHS.to_string())); } - // NanVix's guest egress filter is allow-XOR-block and cannot represent - // simultaneous allow and block lists, even though the shared policy model can. - if request.policy.default_network_policy == NetworkPolicy::Block - && !request.policy.allowed_hosts.is_empty() - && !request.policy.blocked_hosts.is_empty() - { - return Err(NanVixError::Preflight(ERR_NETWORK_HOSTS.to_string())); - } if request.policy.network_proxy.is_enabled() { return Err(NanVixError::Preflight(ERR_PROXY_POLICY.to_string())); } @@ -692,8 +557,6 @@ impl NanVixScriptRunner { paths: &ResolvedPaths, staging_dir: &Path, request: &ExecutionRequest, - allow_hosts: &[String], - block_hosts: &[String], ) -> Result { let host_networking = Self::resolve_networking_mode(request)?; let trace = nanvix_trace_enabled(); @@ -718,18 +581,6 @@ impl NanVixScriptRunner { cmd.arg("-allow-host-networking"); } - // Per-host egress filtering. The supplied lists have already been - // reduced to the one that refines the shared legacy default. NanVix - // rejects simultaneous lists during validation, so at most one loop - // emits flags. The guest daemon auto-exempts the DNS port in allowlist - // mode, so no resolver IPs are added here. - for host in allow_hosts { - cmd.arg("-allow-host").arg(host); - } - for host in block_hosts { - cmd.arg("-block-host").arg(host); - } - #[cfg(target_os = "windows")] { // nanvixd loads kernel.vmem from /snapshots/ so cwd must be @@ -777,10 +628,8 @@ impl NanVixScriptRunner { paths: &ResolvedPaths, staging_dir: &Path, request: &ExecutionRequest, - allow_hosts: &[String], - block_hosts: &[String], ) -> Result { - Self::nanvixd_command(paths, staging_dir, request, allow_hosts, block_hosts)? + Self::nanvixd_command(paths, staging_dir, request)? .spawn() .map_err(|e| { NanVixError::Platform(format!("failed to spawn {}: {}", NANVIXD_BINARY, e)) @@ -990,6 +839,9 @@ impl ScriptRunner for NanVixScriptRunner { } fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + if let Err(response) = self.validate_runner(request) { + return response; + } let host_networking = match Self::resolve_networking_mode(request) { Ok(enabled) => enabled, Err(error) => return error.to_response(), @@ -1026,31 +878,7 @@ impl ScriptRunner for NanVixScriptRunner { if host_networking { let _ = writeln!(logger, "NanVix: host networking enabled"); } - let (allow_hosts, block_hosts) = match Self::resolve_host_lists(request) { - Ok(resolved) => { - for warning in &resolved.warnings { - let _ = writeln!(logger, "NanVix: {}", warning); - } - (resolved.allow, resolved.block) - } - Err(e) => { - let _ = writeln!(logger, "{}", e); - return e.to_response(); - } - }; - if !allow_hosts.is_empty() { - let _ = writeln!(logger, "NanVix: egress allowlist={:?}", allow_hosts); - } - if !block_hosts.is_empty() { - let _ = writeln!(logger, "NanVix: egress blocklist={:?}", block_hosts); - } - let mut child = match Self::spawn_nanvixd( - &paths, - staging.path(), - request, - &allow_hosts, - &block_hosts, - ) { + let mut child = match Self::spawn_nanvixd(&paths, staging.path(), request) { Ok(c) => c, Err(e) => { let _ = writeln!(logger, "{}", e); @@ -1102,7 +930,7 @@ impl ScriptRunner for NanVixScriptRunner { mod tests { use super::*; use crate::mxc_common::logger::{Logger, Mode}; - use crate::mxc_common::models::{ContainerPolicy, NetworkPolicy}; + use crate::mxc_common::models::ContainerPolicy; #[test] fn total_timeout_adds_boot_staging_and_script() { @@ -1147,11 +975,7 @@ mod tests { } } - fn command_arguments( - request: &ExecutionRequest, - allow_hosts: &[String], - block_hosts: &[String], - ) -> Result, NanVixError> { + fn command_arguments(request: &ExecutionRequest) -> Result, NanVixError> { let root = PathBuf::from("nanvix-command-test"); let paths = ResolvedPaths { nanvixd: root.join(NANVIXD_BINARY), @@ -1160,13 +984,7 @@ mod tests { exe_dir: root.clone(), snapshot_home: root, }; - let command = NanVixScriptRunner::nanvixd_command( - &paths, - Path::new("staging"), - request, - allow_hosts, - block_hosts, - )?; + let command = NanVixScriptRunner::nanvixd_command(&paths, Path::new("staging"), request)?; Ok(command .get_args() .map(|argument| argument.to_string_lossy().into_owned()) @@ -1186,7 +1004,7 @@ mod tests { NanVixScriptRunner::resolve_networking_mode(&request).unwrap(), egress == NetworkAction::Allow ); - let arguments = command_arguments(&request, &[], &[]).unwrap(); + let arguments = command_arguments(&request).unwrap(); assert_eq!( arguments .iter() @@ -1197,7 +1015,7 @@ mod tests { } else { let error = runner.validate_runner(&request).unwrap_err(); assert!(error.error_message.contains(ERR_DIRECTIONAL_NETWORK)); - assert!(command_arguments(&request, &[], &[]).is_err()); + assert!(command_arguments(&request).is_err()); } } } @@ -1213,6 +1031,8 @@ mod tests { ); request.policy.network_ingress = None; assert!(NanVixScriptRunner::resolve_networking_mode(&request).is_err()); + assert!(NanVixScriptRunner::new().validate_runner(&request).is_err()); + assert!(command_arguments(&request).is_err()); } #[test] @@ -1240,7 +1060,10 @@ mod tests { "unexpected validation error: {}", error.error_message ); - assert!(command_arguments(&request, &[], &[]).is_err()); + assert!(command_arguments(&request).is_err()); + let mut runner = NanVixScriptRunner::new(); + let response = runner.run(&request, &mut Logger::new(Mode::Buffer)); + assert!(response.error_message.contains(ERR_DIRECTIONAL_FILTERS)); } } @@ -1260,7 +1083,7 @@ mod tests { }; NanVixScriptRunner::new().validate_runner(&request).unwrap(); assert!(NanVixScriptRunner::resolve_networking_mode(&request).unwrap()); - let arguments = command_arguments(&request, &[], &[]).unwrap(); + let arguments = command_arguments(&request).unwrap(); assert_eq!( arguments.first().map(String::as_str), Some("-allow-host-networking") @@ -1272,46 +1095,35 @@ mod tests { } #[test] - fn command_arguments_preserve_network_defaults_and_host_filters() { - for (default, allow, block, expected_prefix) in [ - (NetworkPolicy::Block, vec![], vec![], vec![]), + fn command_arguments_use_only_directional_host_networking() { + for (request, enabled) in [ + (ExecutionRequest::default(), false), ( - NetworkPolicy::Allow, - vec![], - vec![], - vec!["-allow-host-networking"], + directional_request( + NetworkAction::Deny, + NetworkAction::Deny, + NetworkAction::Deny, + ), + false, ), ( - NetworkPolicy::Block, - vec!["192.0.2.1"], - vec![], - vec!["-allow-host-networking", "-allow-host", "192.0.2.1"], - ), - ( - NetworkPolicy::Allow, - vec![], - vec!["192.0.2.0/24"], - vec!["-allow-host-networking", "-block-host", "192.0.2.0/24"], + directional_request( + NetworkAction::Allow, + NetworkAction::Allow, + NetworkAction::Allow, + ), + true, ), ] { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: default, - allowed_hosts: allow.into_iter().map(str::to_owned).collect(), - blocked_hosts: block.into_iter().map(str::to_owned).collect(), - ..Default::default() - }, - ..Default::default() - }; - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - let arguments = command_arguments(&request, &resolved.allow, &resolved.block).unwrap(); - let expected: Vec = expected_prefix.into_iter().map(str::to_owned).collect(); - assert!(arguments.starts_with(&expected), "{arguments:?}"); + let arguments = command_arguments(&request).unwrap(); assert_eq!( - arguments.iter().any(|arg| arg == "-allow-host-networking"), - !expected.is_empty(), + arguments.first().map(String::as_str) == Some("-allow-host-networking"), + enabled, "{arguments:?}" ); + assert!(!arguments + .iter() + .any(|arg| arg == "-allow-host" || arg == "-block-host")); } } @@ -1364,248 +1176,88 @@ mod tests { } #[test] - fn policy_accepts_allowlist_only() { - // A bare allowlist is now supported (forwarded as -allow-host). - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - allowed_hosts: vec!["93.184.216.34".to_string()], - ..Default::default() - }, - ..Default::default() - }; - assert!( - NanVixScriptRunner::validate_policies(&request).is_ok(), - "a bare allowlist should pass validation" - ); - } - - #[test] - fn policy_rejects_blocklist_without_allowlist_under_block_default() { - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - blocked_hosts: vec!["93.184.216.34".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let error = NanVixScriptRunner::new() - .validate_runner(&request) - .unwrap_err(); - assert_eq!( - error.error_message, - "blockedHosts requires allowedHosts when network.defaultPolicy='block'" - ); - } - - #[test] - fn policy_rejects_both_host_lists() { - // allow + block are mutually exclusive (the guest filter is allow-XOR-block). - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - allowed_hosts: vec!["10.0.0.1".to_string()], - blocked_hosts: vec!["10.0.0.2".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let err = NanVixScriptRunner::validate_policies(&request).unwrap_err(); - assert!( - err.to_string().contains(ERR_NETWORK_HOSTS), - "both lists should be rejected, got: {}", - err - ); - } - - // -- Host resolution / decision-matrix tests -------------------------------- - - #[test] - fn resolve_hosts_passes_ipv4_and_cidr_literals() { - let hosts = vec![ - "1.2.3.4".to_string(), - "10.0.0.0/8".to_string(), - "192.168.1.1/32".to_string(), - ]; - let (resolved, unresolved) = NanVixScriptRunner::resolve_hosts_detailed(&hosts); - assert_eq!(resolved, vec!["1.2.3.4", "10.0.0.0/8", "192.168.1.1/32"]); - assert!(unresolved.is_empty()); - } - - #[test] - fn resolve_hosts_drops_ipv6_and_bad_entries() { - let hosts = vec![ - "::1".to_string(), // IPv6 literal -> dropped - "2001:db8::/32".to_string(), // IPv6 CIDR -> dropped (addr not IPv4) - "1.2.3.4/33".to_string(), // out-of-range prefix -> dropped - " ".to_string(), // blank -> skipped - "5.6.7.8".to_string(), // valid -> kept - ]; - let (resolved, _) = NanVixScriptRunner::resolve_hosts_detailed(&hosts); - assert_eq!(resolved, vec!["5.6.7.8"]); - } - - #[test] - fn resolve_host_lists_returns_resolved_allow() { - let request = ExecutionRequest { - policy: ContainerPolicy { - allowed_hosts: vec!["1.1.1.1".to_string(), "8.8.8.8/32".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - assert_eq!(resolved.allow, vec!["1.1.1.1", "8.8.8.8/32"]); - assert!(resolved.block.is_empty()); - assert!(resolved.warnings.is_empty()); - } - - #[test] - fn resolve_host_lists_fails_closed_when_allowlist_unresolvable() { - // A non-empty allowlist that resolves to nothing must error rather than - // silently fall through to allow-all. - let request = ExecutionRequest { - policy: ContainerPolicy { - // IPv6-only literal resolves to no IPv4 entry. - allowed_hosts: vec!["::1".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let err = NanVixScriptRunner::resolve_host_lists(&request).unwrap_err(); - assert!( - err.to_string().contains(ERR_HOSTS_UNRESOLVED), - "unresolvable allowlist should fail closed, got: {}", - err - ); - } - - #[test] - fn resolve_hosts_detailed_reports_unresolved_entries() { - // IPv4/CIDR pass through; IPv6 + malformed entries are reported as - // unresolved; blanks are ignored entirely. - let hosts = vec![ - "5.6.7.8".to_string(), // valid -> kept - "::1".to_string(), // IPv6 literal -> unresolved - "2001:db8::/32".to_string(), // IPv6 CIDR -> unresolved - "1.2.3.4/33".to_string(), // out-of-range prefix -> unresolved - "not_a_host.invalid".to_string(), // no A record -> unresolved - " ".to_string(), // blank -> ignored (neither list) + fn directly_constructed_legacy_network_fields_fail_closed() { + let legacy_policies = [ + ( + "defaultPolicy", + ContainerPolicy { + default_network_policy: NetworkPolicy::Allow, + ..Default::default() + }, + ), + ( + "enforcementMode", + ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + ..Default::default() + }, + ), + ( + "allowLocalNetwork", + ContainerPolicy { + allow_local_network: true, + ..Default::default() + }, + ), + ( + "allowedHosts", + ContainerPolicy { + allowed_hosts: vec!["192.0.2.1".to_string()], + ..Default::default() + }, + ), + ( + "blockedHosts", + ContainerPolicy { + blocked_hosts: vec!["192.0.2.1".to_string()], + ..Default::default() + }, + ), ]; - let (resolved, unresolved) = NanVixScriptRunner::resolve_hosts_detailed(&hosts); - assert_eq!(resolved, vec!["5.6.7.8"]); - assert_eq!( - unresolved, - vec!["::1", "2001:db8::/32", "1.2.3.4/33", "not_a_host.invalid"] - ); - } + for (field, legacy_policy) in legacy_policies { + let mut request = directional_request( + NetworkAction::Allow, + NetworkAction::Allow, + NetworkAction::Allow, + ); + request.script_code = "print(1)".to_string(); + request.policy.default_network_policy = legacy_policy.default_network_policy; + request.policy.network_enforcement_mode = legacy_policy.network_enforcement_mode; + request.policy.allow_local_network = legacy_policy.allow_local_network; + request.policy.allowed_hosts = legacy_policy.allowed_hosts; + request.policy.blocked_hosts = legacy_policy.blocked_hosts; - #[test] - fn resolve_host_lists_warns_on_dropped_allowlist_entries() { - // A partially-resolvable allowlist narrows access (fail-safe): the run - // continues and each dropped entry produces a warning. - let request = ExecutionRequest { - policy: ContainerPolicy { - allowed_hosts: vec![ - "9.9.9.9".to_string(), - "::1".to_string(), - "dropme.invalid".to_string(), - ], - ..Default::default() - }, - ..Default::default() - }; - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - assert_eq!(resolved.allow, vec!["9.9.9.9"]); - assert!(resolved.block.is_empty()); - assert_eq!(resolved.warnings.len(), 2, "two entries were dropped"); - assert!(resolved.warnings.iter().any(|w| w.contains("::1"))); - assert!(resolved - .warnings - .iter() - .any(|w| w.contains("dropme.invalid"))); - } + let error = NanVixScriptRunner::new() + .validate_runner(&request) + .unwrap_err(); + assert!(error.error_message.contains(ERR_LEGACY_NETWORK), "{field}"); + assert!(command_arguments(&request).is_err(), "{field}"); - #[test] - fn resolve_host_lists_fails_closed_on_unresolvable_blocklist_entry() { - // A blocklist (allow-by-default) must fail closed if ANY entry cannot - // be resolved -- silently dropping it would let blocked traffic flow. - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - blocked_hosts: vec!["10.0.0.1".to_string(), "::1".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let err = NanVixScriptRunner::resolve_host_lists(&request).unwrap_err(); - assert!( - err.to_string().contains(ERR_BLOCKED_HOST_UNRESOLVED), - "unresolvable blocklist entry should fail closed, got: {}", - err - ); - assert!( - err.to_string().contains("::1"), - "error should name the offending entry, got: {}", - err - ); + let mut runner = NanVixScriptRunner::new(); + let mut logger = Logger::new(Mode::Buffer); + let response = runner.run(&request, &mut logger); + assert!( + response.error_message.contains(ERR_LEGACY_NETWORK), + "{field}" + ); + let response = runner.execute(&request, &mut logger); + assert!( + response.error_message.contains(ERR_LEGACY_NETWORK), + "{field}" + ); + } } - #[test] - fn resolve_host_lists_accepts_fully_resolved_blocklist() { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - blocked_hosts: vec!["10.0.0.1".to_string(), "192.168.0.0/16".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - assert!(resolved.allow.is_empty()); - assert_eq!(resolved.block, vec!["10.0.0.1", "192.168.0.0/16"]); - assert!(resolved.warnings.is_empty()); - } + // -- Directional network decision tests ------------------------------------ #[test] - fn default_block_no_lists_disables_host_networking() { - // The default posture (block, no lists) keeps networking off. + fn omitted_network_policy_disables_host_networking() { let request = ExecutionRequest::default(); assert!(!NanVixScriptRunner::resolve_networking_mode(&request).unwrap()); - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - assert!(resolved.allow.is_empty() && resolved.block.is_empty()); - assert!(resolved.warnings.is_empty()); - } - - #[test] - fn allow_policy_enables_host_networking() { - // `network.defaultPolicy = "allow"` maps to host networking and must - // pass validation (the run later fails on missing nanvixd binaries, - // not on policy). Per-host filtering is absent, so it is accepted. - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - }, - ..Default::default() - }; - assert!(NanVixScriptRunner::resolve_networking_mode(&request).unwrap()); - assert!( - NanVixScriptRunner::validate_policies(&request).is_ok(), - "allow posture without per-host filtering should pass validation" - ); - - let mut runner = NanVixScriptRunner::new(); - let mut logger = Logger::new(Mode::Buffer); - let resp = runner.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!( - !resp.error_message.contains(ERR_NETWORK_HOSTS), - "allow posture must not trigger a network policy rejection, got: {}", - resp.error_message - ); + assert!(!command_arguments(&request) + .unwrap() + .iter() + .any(|argument| argument == "-allow-host-networking")); } #[test] @@ -1624,9 +1276,8 @@ mod tests { #[test] fn policy_allows_defaults() { - // NanVix accepts a default (deny-by-default) policy. With no host - // networking requested, the run later fails on missing nanvixd - // binaries, not on policy. + // NanVix accepts a default isolated policy. The run later fails on + // missing nanvixd binaries, not on policy. let mut runner = NanVixScriptRunner::new(); let request = ExecutionRequest { script_code: "echo test".to_string(), @@ -1637,7 +1288,7 @@ mod tests { let resp = runner.run(&request, &mut logger); assert_eq!(resp.exit_code, ERROR_EXIT_CODE); assert!( - !resp.error_message.contains(ERR_NETWORK_HOSTS), + !resp.error_message.contains(ERR_LEGACY_NETWORK), "default request should not trigger network policy rejection" ); assert!( diff --git a/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs b/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs index a2caf66de..5b3606029 100644 --- a/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs @@ -477,8 +477,7 @@ pub(crate) fn derive_sid_string(profile_name: &str) -> Result /// instead. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum FilesystemMode { - /// Configure the AppContainer's BFS policy via `bfscfg.exe` (default - /// historical behavior). + /// Configure the AppContainer's BFS policy via `bfscfg.exe` (default). #[default] Bfs, /// Skip BFS setup; the caller has handled filesystem policy via host @@ -627,9 +626,9 @@ pub struct AppContainerScriptRunner { denied_paths_enforced_externally: bool, /// Optional pre-derived SID string supplied by the dispatcher. /// - /// When `Some`, the runner uses this value for the firewall - /// principal-id and any other capability-string lookups instead of - /// re-running `ConvertSidToStringSidW` on its owned `PSID`. The + /// When `Some`, the runner uses this value for proxy setup and + /// diagnostics instead of re-running `ConvertSidToStringSidW` on its + /// owned `PSID`. The /// `PSID` itself is still derived by [`create_app_container_sid`] /// at run time because `windows-rs` does not expose a safe /// "string → PSID" conversion with the same ownership semantics as @@ -1328,7 +1327,7 @@ impl AppContainerScriptRunner { Ok(()) } - /// Return the SID string for firewall rule association. + /// Return the SID string for proxy setup and diagnostics. fn get_principal_id(&self) -> String { // Prefer the dispatcher-supplied string when present — saves a // `ConvertSidToStringSidW` round-trip (the dispatcher has @@ -1503,7 +1502,7 @@ impl Drop for AppContainerScriptRunner { // Shared setup/teardown + streaming (handle-based) execution // ─────────────────────────────────────────────────────────────────────────── -/// Per-run resources (firewall + filesystem policy) whose lifetime is tied to +/// Per-run resources (proxy + filesystem policy) whose lifetime is tied to /// the sandboxed child. Created by [`AppContainerScriptRunner::prepare`] and /// torn down by [`AppContainerScriptRunner::teardown`] after the child exits. struct Prepared { @@ -1584,11 +1583,7 @@ impl AppContainerScriptRunner { logger, ); if logger.has_diagnostic_sink() { - let firewall_applied = network_manager.firewall_applied(); - let plan = NetworkManager::describe_policy(&request.policy); - let firewall_ok = !plan.rules_will_be_installed - || matches!(network_manager.firewall_apply_ok(), Some(true)); - let status = if network_result.is_ok() && firewall_ok { + let status = if network_result.is_ok() { OperationStatus::Success } else { OperationStatus::Failure @@ -1599,11 +1594,11 @@ impl AppContainerScriptRunner { .str("tier", self.tier_str()) .str( "enforcement_mode", - request.policy.network_enforcement_mode.as_str(), + crate::process_container_common::network_policy_helpers::CAPABILITIES_ENFORCEMENT_MODE, ) .str( "default_policy", - request.policy.default_network_policy.as_str(), + crate::process_container_common::network_policy_helpers::audit_egress_default(&request.policy), ) .u64( "proxy_port", @@ -1612,19 +1607,16 @@ impl AppContainerScriptRunner { .map(|address| address.port as u64) .unwrap_or(0), ) - .u64( - "firewall_rules_created", - network_manager.rule_count() as u64, - ) - .bool("firewall_applied", firewall_applied) + .u64("firewall_rules_created", 0) + .bool("firewall_applied", false) .str("status", status.as_str()); logger.log_audit_event(&record); } if crate::mxc_common::telemetry::is_active() { crate::mxc_common::telemetry::log_network_policy_applied( sanitize_identity(&self.app_container_name), - request.policy.network_enforcement_mode.as_str(), - request.policy.default_network_policy.as_str(), + crate::process_container_common::network_policy_helpers::CAPABILITIES_ENFORCEMENT_MODE, + crate::process_container_common::network_policy_helpers::audit_egress_default(&request.policy), network_manager .proxy_address() .map(|address| address.port as u64) @@ -1647,10 +1639,9 @@ impl AppContainerScriptRunner { }) } - /// Tear down the per-run firewall and filesystem policy. Idempotent at the - /// manager level; called once after the child exits. + /// Tear down the per-run proxy and filesystem policy after the child exits. fn teardown(&self, prepared: &mut Prepared, preserve_policy: bool, logger: &mut Logger) { - let network = prepared.network_manager.stop_all(!preserve_policy, logger); + let proxy_stopped = prepared.network_manager.stop_all(logger); let bfs_requested = self.filesystem_mode == FilesystemMode::Bfs && prepared.bfs_manager.configured() && !preserve_policy; @@ -1659,22 +1650,18 @@ impl AppContainerScriptRunner { } else { false }; - let (status, skip_reason) = appcontainer_teardown_status_with_bfs( - preserve_policy, - network.firewall_removal_ok, - bfs_requested, - bfs_removed, - ); + let (status, skip_reason) = + appcontainer_teardown_status_with_bfs(preserve_policy, bfs_requested, bfs_removed); if logger.has_diagnostic_sink() { let mut record = AuditEvent::new(AuditEventName::SandboxTornDown) .str("backend", ContainmentBackend::ProcessContainer.wire_name()) .str("identity", sanitize_identity(&self.app_container_name)) .str("tier", self.tier_str()) .str("status", status.as_str()) - .u64("firewall_rules_removed", network.rules_removed as u64) - .bool("firewall_removal_ok", network.firewall_removal_ok) + .u64("firewall_rules_removed", 0) + .bool("firewall_removal_ok", true) .bool("bfs_removed", bfs_removed) - .bool("proxy_stopped", network.proxy_stopped) + .bool("proxy_stopped", proxy_stopped) .bool("preserve_policy", preserve_policy) .bool("container_released", false); if let Some(reason) = skip_reason { @@ -1686,11 +1673,7 @@ impl AppContainerScriptRunner { crate::mxc_common::telemetry::log_sandbox_torn_down( sanitize_identity(&self.app_container_name), status.as_str(), - &format_released_resources( - network.rules_removed, - bfs_removed, - network.proxy_stopped, - ), + &format_released_resources(bfs_removed, proxy_stopped), ); } } @@ -1700,20 +1683,15 @@ impl AppContainerScriptRunner { } } -fn format_released_resources( - firewall_rules_removed: usize, - bfs_removed: bool, - proxy_stopped: bool, -) -> String { +fn format_released_resources(bfs_removed: bool, proxy_stopped: bool) -> String { format!( - "firewall_rules_removed={firewall_rules_removed},bfs_removed={bfs_removed},\ + "firewall_rules_removed=0,bfs_removed={bfs_removed},\ proxy_stopped={proxy_stopped},container_released=false" ) } fn appcontainer_teardown_status_with_bfs( preserve_policy: bool, - firewall_removal_ok: bool, bfs_requested: bool, bfs_removed: bool, ) -> (TeardownStatus, Option) { @@ -1723,7 +1701,7 @@ fn appcontainer_teardown_status_with_bfs( Some(TeardownSkipReason::PreservePolicy), ); } - if firewall_removal_ok && (!bfs_requested || bfs_removed) { + if !bfs_requested || bfs_removed { (TeardownStatus::Success, None) } else { (TeardownStatus::Failure, None) @@ -1808,6 +1786,9 @@ impl SandboxBackend for AppContainerScriptRunner { crate::mxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, )); } + crate::process_container_common::network_policy_helpers::reject_retired_network_policy( + &request.policy, + )?; Ok(()) } @@ -1863,7 +1844,7 @@ impl SandboxBackend for AppContainerScriptRunner { /// A running AppContainer-sandboxed process exposed as a [`SandboxProcess`]. /// Owns the process/job handles, the parent-side pipes, and the per-run -/// firewall/filesystem policy, which it tears down once the child exits. +/// proxy/filesystem policy, which it tears down once the child exits. struct AppContainerSandboxProcess { process: SendOwnedHandle, _thread: SendOwnedHandle, @@ -1908,17 +1889,9 @@ struct AppContainerSandboxProcess { // and this handle is owned exclusively by the caller (not shared), so it is // only ever touched from one thread at a time. // -// The one historically thread-affine field was the `NetworkManager` inside -// `prepared`: it used to cache an STA `INetFwPolicy2` interface plus its -// `CoInitializeEx` state and reuse them at teardown, which is unsound when -// `wait()`/`kill()`/`Drop` run on a different thread (e.g. a tokio -// `spawn_blocking` worker) than `spawn`. That no longer happens: each firewall -// apply/remove is apartment-self-contained (it opens its own COM apartment, -// creates a fresh interface, and uninitializes — all on whichever thread runs -// it), so no COM interface or apartment state is moved across threads. The only -// remaining OS state the manager keeps is the process-global Winsock refcount, -// which is thread-agnostic. Moving this handle across threads is therefore -// sound. +// `NetworkManager` owns only the proxy coordinator's process-global handles +// and paths; no thread-affine COM interface or apartment state is retained. +// Moving this handle across threads is therefore sound. unsafe impl Send for AppContainerSandboxProcess {} impl AppContainerSandboxProcess { @@ -2016,10 +1989,10 @@ impl AppContainerSandboxProcess { if let Some(result) = &self.teardown_result { return result.clone().map_err(std::io::Error::other); } - let network = self + let proxy_stopped = self .prepared .network_manager - .stop_all(!self.preserve_policy, &mut self.audit_logger); + .stop_all(&mut self.audit_logger); let bfs_requested = self.filesystem_mode == FilesystemMode::Bfs && self.prepared.bfs_manager.configured() && !self.preserve_policy; @@ -2056,12 +2029,8 @@ impl AppContainerSandboxProcess { Ok(()) }; let result = result.map_err(|error| error.to_string()); - let (mut status, skip_reason) = appcontainer_teardown_status_with_bfs( - self.preserve_policy, - network.firewall_removal_ok, - bfs_requested, - bfs_removed, - ); + let (mut status, skip_reason) = + appcontainer_teardown_status_with_bfs(self.preserve_policy, bfs_requested, bfs_removed); if result.is_err() { status = TeardownStatus::Failure; } @@ -2069,10 +2038,10 @@ impl AppContainerSandboxProcess { let mut record = self .audit(AuditEventName::SandboxTornDown) .str("status", status.as_str()) - .u64("firewall_rules_removed", network.rules_removed as u64) - .bool("firewall_removal_ok", network.firewall_removal_ok) + .u64("firewall_rules_removed", 0) + .bool("firewall_removal_ok", true) .bool("bfs_removed", bfs_removed) - .bool("proxy_stopped", network.proxy_stopped) + .bool("proxy_stopped", proxy_stopped) .bool("preserve_policy", self.preserve_policy) .bool("container_released", false); if let Some(reason) = skip_reason { @@ -2084,11 +2053,7 @@ impl AppContainerSandboxProcess { crate::mxc_common::telemetry::log_sandbox_torn_down( &self.identity, status.as_str(), - &format_released_resources( - network.rules_removed, - bfs_removed, - network.proxy_stopped, - ), + &format_released_resources(bfs_removed, proxy_stopped), ); } self.teardown_result = Some(result.clone()); @@ -2255,10 +2220,10 @@ impl SandboxProcess for AppContainerSandboxProcess { }; // Tree-kill the job so any backgrounded descendant dies *before* - // `run_teardown()` removes the firewall / BFS enforcement (keyed to the - // shared AppContainer package SID) — upholding the same invariant as - // `Drop`. The foreground child has already exited on the success path; on - // a timeout or wait failure this also terminates it. Then reap the root + // `run_teardown()` removes BFS enforcement (keyed to the shared + // AppContainer package SID). The foreground child has already exited + // on the success path; on a timeout or wait failure this terminates it. + // Then reap the root // (immediate once it has exited) before releasing the pipe drains — and // killing the tree closes the descendant's pipe write-ends, so the drains // can finish. @@ -2281,7 +2246,7 @@ impl SandboxProcess for AppContainerSandboxProcess { impl Drop for AppContainerSandboxProcess { fn drop(&mut self) { - // Kill the tree and reap before tearing down firewall/filesystem + // Kill the tree and reap before tearing down filesystem // policy, so an abandoned-but-running sandbox cannot outlive its // enforcement (or leak as an orphan). `kill()` terminates the job. if let Err(error) = self.kill() { @@ -2309,43 +2274,36 @@ mod tests { #[test] fn released_resources_format_is_stable() { assert_eq!( - super::format_released_resources(2, true, false), - "firewall_rules_removed=2,bfs_removed=true,proxy_stopped=false,container_released=false" + super::format_released_resources(true, false), + "firewall_rules_removed=0,bfs_removed=true,proxy_stopped=false,container_released=false" ); } #[test] fn teardown_status_reports_preserve_policy_as_skipped() { - for firewall_ok in [true, false] { - for (bfs_requested, bfs_ok) in [(false, false), (true, true), (true, false)] { - assert_eq!( - super::appcontainer_teardown_status_with_bfs( - true, - firewall_ok, - bfs_requested, - bfs_ok, - ), - ( - TeardownStatus::Skipped, - Some(TeardownSkipReason::PreservePolicy) - ) - ); - } + for (bfs_requested, bfs_ok) in [(false, false), (true, true), (true, false)] { + assert_eq!( + super::appcontainer_teardown_status_with_bfs(true, bfs_requested, bfs_ok), + ( + TeardownStatus::Skipped, + Some(TeardownSkipReason::PreservePolicy) + ) + ); } } #[test] fn teardown_status_distinguishes_cleanup_failures() { assert_eq!( - super::appcontainer_teardown_status_with_bfs(false, true, false, false), + super::appcontainer_teardown_status_with_bfs(false, false, false), (TeardownStatus::Success, None) ); assert_eq!( - super::appcontainer_teardown_status_with_bfs(false, false, false, false), - (TeardownStatus::Failure, None) + super::appcontainer_teardown_status_with_bfs(false, true, true), + (TeardownStatus::Success, None) ); assert_eq!( - super::appcontainer_teardown_status_with_bfs(false, true, true, false), + super::appcontainer_teardown_status_with_bfs(false, true, false), (TeardownStatus::Failure, None) ); } diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs index 4dfa57496..1b60e3950 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs @@ -4,8 +4,8 @@ //! BaseContainer configuration and policy helpers. use crate::mxc_common::models::{ - ContainerPolicy, ExecutionRequest, NetworkAction, NetworkCidr, NetworkPeer, NetworkPolicy, - NetworkPort, NetworkProtocol, NetworkRule, + ContainerPolicy, ExecutionRequest, NetworkAction, NetworkCidr, NetworkPeer, NetworkPort, + NetworkProtocol, NetworkRule, }; use crate::process_security_environment_spec::process_security_environment_layout::{ finish_process_security_environment_buffer, DestinationRuleT as PsecDestinationRuleT, @@ -143,13 +143,10 @@ fn psec_filter_action(action: NetworkAction) -> PsecFilterAction { } fn effective_egress_default(policy: &ContainerPolicy) -> NetworkAction { - policy.network_egress.as_ref().map_or( - match policy.default_network_policy { - NetworkPolicy::Allow => NetworkAction::Allow, - NetworkPolicy::Block => NetworkAction::Deny, - }, - |egress| egress.default, - ) + policy + .network_egress + .as_ref() + .map_or(NetworkAction::Deny, |egress| egress.default) } pub(super) fn unrestricted_host_loopback_allowed(policy: &ContainerPolicy) -> bool { diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index 0556e58f7..4d08349a3 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -1044,8 +1044,10 @@ impl BaseContainerRunner { crate::mxc_common::telemetry::log_network_policy_applied( sanitize_identity(&identity), - request.policy.network_enforcement_mode.as_str(), - request.policy.default_network_policy.as_str(), + crate::process_container_common::network_policy_helpers::CAPABILITIES_ENFORCEMENT_MODE, + crate::process_container_common::network_policy_helpers::audit_egress_default( + &request.policy, + ), request .policy .network_proxy @@ -1064,11 +1066,13 @@ impl BaseContainerRunner { ) .str( "enforcement_mode", - request.policy.network_enforcement_mode.as_str(), + crate::process_container_common::network_policy_helpers::CAPABILITIES_ENFORCEMENT_MODE, ) .str( "default_policy", - request.policy.default_network_policy.as_str(), + crate::process_container_common::network_policy_helpers::audit_egress_default( + &request.policy, + ), ) .u64( "proxy_port", @@ -1165,6 +1169,9 @@ impl SandboxBackend for BaseContainerRunner { crate::mxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, )); } + crate::process_container_common::network_policy_helpers::reject_retired_network_policy( + &request.policy, + )?; if has_conflicting_proxy_identity(&request.policy) { return Err(ScriptResponse::rejected( "processContainer.network.allowedProxyPeer grants loopback access only to the \ @@ -2055,8 +2062,7 @@ mod tests { }; use crate::mxc_common::models::{ BaseProcessUiConfig, ClipboardPolicy, ContainerPolicy, NetworkAction, NetworkCidr, - NetworkPeer, NetworkPolicy, NetworkPort, NetworkProtocol, NetworkRule, ProxyConfig, - UiPolicy, + NetworkPeer, NetworkPort, NetworkProtocol, NetworkRule, ProxyConfig, UiPolicy, }; use crate::mxc_common::ui_policy::EffectiveUiRestrictions; use crate::process_container_common::job_object::to_job_object_uilimit_mask; @@ -2781,7 +2787,10 @@ mod tests { fn build_process_security_environment_spec_ignores_empty_capability() { let mut request = ExecutionRequest::default(); request.policy.capabilities = vec![String::new()]; - request.policy.default_network_policy = NetworkPolicy::Allow; + request.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }); let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); @@ -2790,9 +2799,8 @@ mod tests { } #[test] - fn build_process_security_environment_spec_preserves_allow_egress() { - let mut request = ExecutionRequest::default(); - request.policy.default_network_policy = NetworkPolicy::Allow; + fn build_process_security_environment_spec_preserves_implicit_deny_egress() { + let request = ExecutionRequest::default(); let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); @@ -2801,8 +2809,8 @@ mod tests { .and_then(|policy| policy.egress()) .expect("PSEC must carry an explicit egress default"); - assert_eq!(egress.default_action(), psec_layout::FilterAction::allow); - assert_eq!(spec.capabilities(), Some("internetClient")); + assert_eq!(egress.default_action(), psec_layout::FilterAction::deny); + assert!(spec.capabilities().is_none()); } #[test] diff --git a/src/mxc-sdk/src/backends/process_container/common/network_manager.rs b/src/mxc-sdk/src/backends/process_container/common/network_manager.rs index 6742f3a23..67876c96d 100644 --- a/src/mxc-sdk/src/backends/process_container/common/network_manager.rs +++ b/src/mxc-sdk/src/backends/process_container/common/network_manager.rs @@ -1,435 +1,50 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use std::net::{IpAddr, ToSocketAddrs}; - -use windows::core::BSTR; -use windows::Win32::Foundation::VARIANT_BOOL; -use windows::Win32::NetworkManagement::WindowsFirewall::{ - INetFwPolicy2, INetFwRule3, NetFwPolicy2, NetFwRule, NET_FW_ACTION, NET_FW_ACTION_ALLOW, - NET_FW_ACTION_BLOCK, NET_FW_RULE_DIR_OUT, -}; -use windows::Win32::Networking::WinSock::{WSACleanup, WSAStartup, WSADATA}; -use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, - COINIT_APARTMENTTHREADED, -}; -use windows::Win32::System::Variant::VARIANT; -use windows_core::Interface; +use windows::Win32::Security::PSID; use crate::mxc_common::error::WxcError; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ContainerPolicy, NetworkEnforcementMode, NetworkPolicy}; +use crate::mxc_common::models::{ContainerPolicy, ProxyAddress}; +use crate::process_container_common::network_policy_helpers::reject_retired_network_policy; use crate::process_container_common::proxy_coordinator::ProxyCoordinator; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DefaultPolicy { - Allow, - Block, -} - -/// `RPC_E_CHANGED_MODE`: `CoInitializeEx` returns this when COM is already -/// initialized on the calling thread with a *different* apartment model. The -/// existing initialization is reused and must **not** be balanced by our own -/// `CoUninitialize`. -const RPC_E_CHANGED_MODE: u32 = 0x8001_0106; - -/// RAII guard for a per-call COM apartment on the **current** thread. -/// -/// Every firewall operation creates one of these, does all of its COM work -/// (`CoCreateInstance`, interface use, release) while it is alive, and lets it -/// drop — running the matching `CoUninitialize` — before returning. Because no -/// COM interface or apartment state is ever cached on [`NetworkManager`] across -/// calls, teardown (`remove_firewall_rules`) can run on a *different* thread -/// than setup (`apply_firewall_rules`) without ever using an interface from -/// another apartment or pairing `CoInitializeEx`/`CoUninitialize` across -/// threads. That self-containment is what makes the `unsafe impl Send` on the -/// owning sandbox handle sound. -struct ComApartment { - /// Whether *this* guard performed the initialization that it must balance - /// with `CoUninitialize`. `false` when COM was already initialized on this - /// thread under a different model (`RPC_E_CHANGED_MODE`). - owns_init: bool, -} - -impl ComApartment { - /// Join (or initialize) an apartment-threaded COM apartment for the current - /// thread. `S_OK`/`S_FALSE` both count as an initialization this guard must - /// balance; `RPC_E_CHANGED_MODE` reuses an existing apartment without - /// taking ownership of its teardown. - fn new() -> Result { - // SAFETY: `CoInitializeEx` is always safe to call; the matching - // `CoUninitialize` runs in `Drop` on this same thread when we own it. - let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; - if hr.is_ok() { - Ok(Self { owns_init: true }) - } else if hr.0 as u32 == RPC_E_CHANGED_MODE { - Ok(Self { owns_init: false }) - } else { - Err(WxcError::Firewall(format!( - "CoInitializeEx failed: 0x{:08X}", - hr.0 as u32 - ))) - } - } -} - -impl Drop for ComApartment { - fn drop(&mut self) { - if self.owns_init { - // SAFETY: balances the `CoInitializeEx` in `new` on the same thread. - unsafe { CoUninitialize() }; - } - } -} - +#[derive(Default)] pub struct NetworkManager { - created_rule_names: Vec, - wsa_initialized: bool, proxy_coordinator: ProxyCoordinator, - /// The result of the most recent `apply_firewall_rules` call. `None` when - /// apply has never been attempted (e.g. `start` returned early on a proxy - /// failure). `Some(true)` when apply succeeded — either all requested rules - /// were installed, or the policy required no rules at all. `Some(false)` - /// when apply failed. Consulted by the caller when building the - /// `NetworkPolicyApplied` audit record so `firewall_applied` reflects the - /// actual apply outcome rather than the policy *plan*. - firewall_apply_ok: Option, -} - -/// What [`NetworkManager::stop_all`] actually released. -/// -/// Previously the firewall-removal `Result` was discarded at the call site, so -/// a partially-failed cleanup was indistinguishable from a clean one. These are -/// diagnostic values only — teardown remains best-effort and non-fatal. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NetworkTeardown { - /// How many firewall rules this manager had created and attempted to - /// remove. Zero when no rules were installed or cleanup was skipped. - pub rules_removed: usize, - /// Whether every rule removal succeeded. `true` when there was nothing to - /// remove. - pub firewall_removal_ok: bool, - /// Whether an active proxy coordinator was stopped. - pub proxy_stopped: bool, -} - -/// What [`NetworkManager::remove_firewall_rules`] actually released. -/// -/// Windows Firewall `Rules.Remove` is per-rule: some can land while others -/// fail. Reporting the entry count as `rules_removed` when only a subset -/// actually came out would hide a partial-cleanup failure — the two are kept -/// distinct here so `stop_all` can carry the truthful count into the audit -/// record and derive `firewall_removal_ok` from the aggregate. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FirewallRemoval { - /// Number of rules whose `Rules.Remove` call returned success. - pub removed: usize, - /// Whether every removal succeeded. `false` when at least one rule - /// removal failed. - pub all_success: bool, -} - -impl Default for FirewallRemoval { - /// "Nothing to remove, and that is fine." Written by hand for the same - /// reason as [`NetworkTeardown::default`]: the derived default would set - /// `all_success: false`, which reads as a failed removal. - fn default() -> Self { - Self { - removed: 0, - all_success: true, - } - } -} - -impl Default for NetworkTeardown { - /// "Nothing to remove, and that is fine." - /// - /// Written by hand because the derived default would set - /// `firewall_removal_ok: false`, which reads as a *failed* removal — the - /// opposite of what an empty teardown means. - fn default() -> Self { - Self { - rules_removed: 0, - firewall_removal_ok: true, - proxy_stopped: false, - } - } -} - -/// Invariant context for creating firewall rules within a single -/// `apply_firewall_rules` call: the firewall interface (valid only for the -/// current COM apartment / thread) and the AppContainer principal the rules are -/// scoped to. Bundled so the rule helpers stay within the argument-count lint. -struct RuleContext<'a> { - fw_policy: &'a INetFwPolicy2, - principal_id: &'a str, -} - -/// The outcome of evaluating a request's network policy: the effective default -/// policy, whether the caller asked for firewall-rule enforcement, and whether -/// any rules will actually be installed. -/// -/// The last two are **not** the same, and conflating them is a real bug: with -/// `enforcementMode: firewall`, no host lists, and `defaultPolicy: allow`, the -/// caller asked for firewall enforcement but there is nothing to install. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NetworkPolicyPlan { - /// Effective default policy for the rules that get installed. - pub default_policy: DefaultPolicy, - /// Whether `enforcementMode` selects firewall-rule enforcement at all. - pub firewall_mode_selected: bool, - /// Whether this policy actually produces firewall rules to install. - pub rules_will_be_installed: bool, } impl NetworkManager { pub fn new() -> Self { Self { - created_rule_names: Vec::new(), - wsa_initialized: false, proxy_coordinator: ProxyCoordinator::new(), - firewall_apply_ok: None, } } - /// Decide the effective default policy and whether firewall rules will be - /// installed for `policy` — the pure core of [`Self::initialize_policy`], - /// factored out so callers that only need the *decision* (e.g. audit - /// records) do not have to log an "Applying network firewall rules" line as - /// a side effect of asking. - pub fn describe_policy(policy: &ContainerPolicy) -> NetworkPolicyPlan { - let firewall_mode_selected = matches!( - policy.network_enforcement_mode, - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both - ); - - let default_policy = - if firewall_mode_selected && policy.default_network_policy == NetworkPolicy::Block { - DefaultPolicy::Block - } else { - DefaultPolicy::Allow - }; - - NetworkPolicyPlan { - default_policy, - firewall_mode_selected, - // `apply_firewall_rules_inner` always falls through to install a - // default Allow/BlockAll rule whenever firewall mode is - // selected, even with no explicit host list and an allow - // default — see - // `firewall_mode_default_allow_installs_default_rule_without_log_line`. - // So this is unconditional on `firewall_mode_selected`, not on - // the narrower "has hosts, or blocks by default" condition - // `initialize_policy` uses below, which governs only the legacy - // startup log line. - rules_will_be_installed: firewall_mode_selected, - } - } - - pub fn initialize_policy( - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> (DefaultPolicy, bool) { - let plan = Self::describe_policy(policy); - // The legacy log line is reserved for the case where the caller gave - // firewall enforcement something explicit to do (a host list) or - // asked to block by default; the coincidental default-rule - // fallthrough for the plain-allow, no-hosts case stays silent, as - // documented on `NetworkPolicyPlan::rules_will_be_installed` above. - if plan.firewall_mode_selected - && (!policy.allowed_hosts.is_empty() - || !policy.blocked_hosts.is_empty() - || policy.default_network_policy == NetworkPolicy::Block) - { - logger.log_line("Applying network firewall rules..."); - } - (plan.default_policy, plan.firewall_mode_selected) - } - - /// Number of firewall rules currently created by this manager. - /// - /// The *count* is deliberately what the audit record carries: rule names - /// embed the AppContainer principal id and a timestamp, which is - /// high-cardinality and semi-identifying. - pub fn rule_count(&self) -> usize { - self.created_rule_names.len() - } - - /// Actual apply outcome of the most recent `apply_firewall_rules` call. - /// - /// * `None` — apply was never attempted (e.g. `start` returned early on a - /// proxy failure). - /// * `Some(true)` — apply succeeded, meaning either all requested rules - /// were installed or the policy required none. - /// * `Some(false)` — apply failed. - /// - /// Used by the caller to derive the `NetworkPolicyApplied` audit record's - /// `firewall_applied` field and aggregate `status` from the *observed* - /// outcome, not from the policy plan. - pub fn firewall_apply_ok(&self) -> Option { - self.firewall_apply_ok - } - - /// Whether firewall rules were actually installed by the most recent apply. - /// `false` when apply was never attempted, failed, or the policy required - /// no rules. This is the truth value the `firewall_applied` audit field - /// should carry — the policy-plan value can be true while the actual - /// installed rule count is zero. - pub fn firewall_applied(&self) -> bool { - matches!(self.firewall_apply_ok, Some(true)) && !self.created_rule_names.is_empty() - } - - pub fn apply_firewall_rules( - &mut self, - principal_id: &str, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result<(), WxcError> { - self.firewall_apply_ok = Some(true); - let outcome = self.apply_firewall_rules_inner(principal_id, policy, logger); - if outcome.is_err() { - self.firewall_apply_ok = Some(false); - } - outcome - } - - fn apply_firewall_rules_inner( - &mut self, - principal_id: &str, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result<(), WxcError> { - let (default_policy, use_firewall_rules) = Self::initialize_policy(policy, logger); - if !use_firewall_rules { - return Ok(()); - } - - // Open a COM apartment and create the firewall interface for the - // duration of *this* call only — nothing is cached on `self`. See - // [`ComApartment`] for why this self-containment matters. - let _com = ComApartment::new()?; - let fw_policy: INetFwPolicy2 = - unsafe { CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER) } - .map_err(|e| WxcError::Firewall(format!("Failed to create NetFwPolicy2: {e}")))?; - self.ensure_wsa_initialized(logger)?; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let millis = now.as_millis(); - let mut sanitized_principal_id: String = principal_id - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect(); - const MAX_PRINCIPAL_ID_LEN: usize = 64; - if sanitized_principal_id.len() > MAX_PRINCIPAL_ID_LEN { - sanitized_principal_id.truncate(MAX_PRINCIPAL_ID_LEN); - } - let rule_prefix = format!("WXC_{}_{}", sanitized_principal_id, millis); - let ctx = RuleContext { - fw_policy: &fw_policy, - principal_id, - }; - - if default_policy == DefaultPolicy::Block { - let block_all_name = format!("{}_BlockAll", rule_prefix); - if !self.create_rule(&ctx, &block_all_name, NET_FW_ACTION_BLOCK, "", logger)? { - self.firewall_apply_ok = Some(false); - return Ok(()); - } - self.created_rule_names.push(block_all_name); - self.process_host_list( - &ctx, - &policy.allowed_hosts, - &rule_prefix, - NET_FW_ACTION_ALLOW, - "Allow", - logger, - )?; - } else { - let allow_all_name = format!("{}_AllowAll", rule_prefix); - if !self.create_rule(&ctx, &allow_all_name, NET_FW_ACTION_ALLOW, "*", logger)? { - self.firewall_apply_ok = Some(false); - return Ok(()); - } - self.created_rule_names.push(allow_all_name); - self.process_host_list( - &ctx, - &policy.blocked_hosts, - &rule_prefix, - NET_FW_ACTION_BLOCK, - "Block", - logger, - )?; - } - - Ok(()) - } - - fn process_host_list( - &mut self, - ctx: &RuleContext, - hosts: &[String], - rule_prefix: &str, - action: NET_FW_ACTION, - action_name: &str, - logger: &mut Logger, - ) -> Result<(), WxcError> { - for (index, host) in hosts.iter().enumerate() { - let ip_address = if validate_ip_or_cidr(host) { - host.clone() - } else { - match resolve_hostname(host) { - Ok(ip) => ip, - Err(_) => { - logger.log_line(&format!("Warning: Could not resolve {}", host)); - self.firewall_apply_ok = Some(false); - continue; - } - } - }; - - let rule_name = format!("{}_{}_{}", rule_prefix, action_name, index); - match self.create_rule(ctx, &rule_name, action, &ip_address, logger) { - Ok(true) => { - self.created_rule_names.push(rule_name); - } - Ok(false) => { - self.firewall_apply_ok = Some(false); - } - Err(_) => { - self.firewall_apply_ok = Some(false); - } - } - } - Ok(()) - } - - /// Returns `true` if any firewall rules have been created and are currently active. - pub fn rules_applied(&self) -> bool { - !self.created_rule_names.is_empty() - } - - /// Returns the proxy address if a proxy is active. - pub fn proxy_address(&self) -> Option<&crate::mxc_common::models::ProxyAddress> { + pub fn proxy_address(&self) -> Option<&ProxyAddress> { self.proxy_coordinator.address() } - /// Start the proxy (if configured) and apply firewall rules. - /// - /// This is the single entry point for all network setup. It handles: - /// 1. Launching the builtin test proxy or configuring the external proxy - /// 2. Setting WinHTTP proxy policy via the elevated shim - /// 3. Creating Windows Firewall rules for host allow/block lists + /// Configure the supported runtime proxy. Directional network access on + /// this tier is enforced by AppContainer capabilities, not firewall rules. pub fn start( &mut self, principal_id: &str, container_name: &str, policy: &ContainerPolicy, - script_sid: windows::Win32::Security::PSID, + script_sid: PSID, logger: &mut Logger, ) -> Result<(), WxcError> { + if !policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty() { + return Err(WxcError::Validation( + "network.allowedHosts / network.blockedHosts are retired; \ + use network.egress and network.ingress" + .into(), + )); + } + reject_retired_network_policy(policy) + .map_err(|error| WxcError::Validation(error.error_message))?; + if policy.network_proxy.is_enabled() { self.proxy_coordinator.start( &policy.network_proxy, @@ -440,459 +55,87 @@ impl NetworkManager { )?; } - if let Err(err) = self.apply_firewall_rules(principal_id, policy, logger) { - if self.proxy_coordinator.is_active() { - self.proxy_coordinator.stop(logger); - } - return Err(err); - } - - Ok(()) - } - - /// Stop all network resources: firewall rules, proxy policy, test proxy. - /// - /// Returns what was actually released, so a caller can record an honest - /// teardown record instead of assuming success. Failures remain non-fatal — - /// this is a best-effort cleanup path and the return value is diagnostic. - pub fn stop_all(&mut self, cleanup_policy: bool, logger: &mut Logger) -> NetworkTeardown { - let mut outcome = NetworkTeardown { - rules_removed: 0, - firewall_removal_ok: true, - proxy_stopped: false, - }; - - if self.rules_applied() && cleanup_policy { - match self.remove_firewall_rules(logger) { - Ok(removal) => { - // `rules_removed` counts *successful* removals only; a - // partial success no longer inflates the count to the - // full list length. `firewall_removal_ok` is the aggregate. - outcome.rules_removed = removal.removed; - outcome.firewall_removal_ok = removal.all_success; - } - Err(_) => { - // `remove_firewall_rules` failed before it could remove - // anything, so nothing was removed. - outcome.firewall_removal_ok = false; - } - } - } - outcome.proxy_stopped = self.proxy_coordinator.stop(logger); - outcome - } - - pub fn remove_firewall_rules( - &mut self, - logger: &mut Logger, - ) -> Result { - if self.created_rule_names.is_empty() { - return Ok(FirewallRemoval { - removed: 0, - all_success: true, - }); - } - - // Re-acquire a fresh firewall interface in its own apartment on the - // current thread. Windows Firewall rules persist by name independently - // of the COM client that created them, so removal does not need (and - // must not reuse) the interface or apartment `apply_firewall_rules` - // used — which may have run on a different thread. See [`ComApartment`]. - let _com = ComApartment::new()?; - let fw_policy: INetFwPolicy2 = - unsafe { CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER) } - .map_err(|e| WxcError::Firewall(format!("Failed to create NetFwPolicy2: {e}")))?; - - let rules = unsafe { fw_policy.Rules() } - .map_err(|e| WxcError::Firewall(format!("Failed to get firewall rules: {}", e)))?; - - // Count *successful* removals, not the entry count. Windows Firewall - // `Rules.Remove` returns per-rule success/failure; conflating the two - // makes a partially-failed cleanup indistinguishable from a clean one - // in the audit record. - let mut removed = 0usize; - let mut all_success = true; - for rule_name in &self.created_rule_names { - let bstr_name = BSTR::from(rule_name.as_str()); - if unsafe { rules.Remove(&bstr_name) }.is_ok() { - removed += 1; - } else { - all_success = false; - } - } - self.created_rule_names.clear(); - if !all_success { - logger.log_line("Warning: some firewall rules could not be removed"); - } - Ok(FirewallRemoval { - removed, - all_success, - }) - } - - fn ensure_wsa_initialized(&mut self, _logger: &mut Logger) -> Result<(), WxcError> { - if self.wsa_initialized { - return Ok(()); - } - let mut wsa_data = WSADATA::default(); - let result = unsafe { WSAStartup(0x0202, &mut wsa_data) }; - if result != 0 { - return Err(WxcError::Firewall(format!( - "WSAStartup failed with code {}", - result - ))); - } - self.wsa_initialized = true; Ok(()) } - fn cleanup_wsa(&mut self) { - if self.wsa_initialized { - unsafe { WSACleanup() }; - self.wsa_initialized = false; - } - } - - fn create_rule( - &self, - ctx: &RuleContext, - rule_name: &str, - action: NET_FW_ACTION, - remote_addresses: &str, - _logger: &mut Logger, - ) -> Result { - let rules = unsafe { ctx.fw_policy.Rules() } - .map_err(|e| WxcError::Firewall(format!("Failed to get firewall rules: {}", e)))?; - - let rule: windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule = - unsafe { CoCreateInstance(&NetFwRule, None, CLSCTX_INPROC_SERVER) } - .map_err(|e| WxcError::Firewall(format!("Failed to create NetFwRule: {}", e)))?; - - let rule3: INetFwRule3 = rule.cast().map_err(|e| { - WxcError::Firewall(format!("Failed to get INetFwRule3 interface: {}", e)) - })?; - - unsafe { - rule.SetName(&BSTR::from(rule_name)) - .map_err(|e| WxcError::Firewall(format!("put_Name failed: {}", e)))?; - - rule.SetDescription(&BSTR::from("WXC AppContainer network policy")) - .map_err(|e| WxcError::Firewall(format!("put_Description failed: {}", e)))?; - - rule3 - .SetLocalAppPackageId(&BSTR::from(ctx.principal_id)) - .map_err(|e| WxcError::Firewall(format!("put_LocalAppPackageId failed: {}", e)))?; - - rule.SetDirection(NET_FW_RULE_DIR_OUT) - .map_err(|e| WxcError::Firewall(format!("put_Direction failed: {}", e)))?; - - rule.SetAction(action) - .map_err(|e| WxcError::Firewall(format!("put_Action failed: {}", e)))?; - - let empty_variant = VARIANT::default(); - rule.SetInterfaces(&empty_variant) - .map_err(|e| WxcError::Firewall(format!("put_Interfaces failed: {}", e)))?; - - if !remote_addresses.is_empty() { - rule.SetRemoteAddresses(&BSTR::from(remote_addresses)) - .map_err(|e| { - WxcError::Firewall(format!("put_RemoteAddresses failed: {}", e)) - })?; - } - - rule.SetEnabled(VARIANT_BOOL::from(true)) - .map_err(|e| WxcError::Firewall(format!("put_Enabled failed: {}", e)))?; - - match rules.Add(&rule) { - Ok(()) => Ok(true), - Err(e) => { - _logger.log_line(&format!( - "Failed to add firewall rule '{}': {}", - rule_name, e - )); - Ok(false) - } - } - } - } -} - -impl Default for NetworkManager { - fn default() -> Self { - Self::new() - } -} - -impl Drop for NetworkManager { - fn drop(&mut self) { - // No COM state is cached across calls (each firewall op is - // apartment-self-contained), so there is nothing COM-related to undo - // here. Only the process-global Winsock refcount — which is - // thread-agnostic — needs balancing. - self.cleanup_wsa(); - } -} - -/// Resolve a hostname to an IP address string. -pub fn resolve_hostname(hostname: &str) -> Result { - let addr = (hostname, 0) - .to_socket_addrs() - .map_err(|e| WxcError::Firewall(format!("Failed to resolve '{}': {}", hostname, e)))? - .next() - .ok_or_else(|| WxcError::Firewall(format!("No addresses found for '{}'", hostname)))?; - - Ok(addr.ip().to_string()) -} - -/// Validate whether a string is a valid IP address or CIDR notation. -pub fn validate_ip_or_cidr(address: &str) -> bool { - let (ip_part, cidr_part) = match address.find('/') { - Some(pos) => { - let ip = &address[..pos]; - let cidr = &address[pos + 1..]; - if cidr.is_empty() { - return false; - } - let bits: u32 = match cidr.parse() { - Ok(b) => b, - Err(_) => return false, - }; - (ip, Some(bits)) - } - None => (address, None), - }; - - if let Ok(ip) = ip_part.parse::() { - match (ip, cidr_part) { - (IpAddr::V4(_), Some(bits)) => bits <= 32, - (IpAddr::V6(_), Some(bits)) => bits <= 128, - (_, None) => true, - } - } else { - false + /// Stop the proxy, including when the caller preserves filesystem policy. + pub fn stop_all(&mut self, logger: &mut Logger) -> bool { + self.proxy_coordinator.stop(logger) } } #[cfg(test)] mod tests { use super::*; + use crate::mxc_common::logger::Mode; + use crate::mxc_common::models::{ + NetworkAction, NetworkEgressPolicy, NetworkEnforcementMode, NetworkPolicy, + }; - /// `firewall_applied` in the audit record used to come from the policy - /// *plan* ("rules will be installed"). With apply now propagating its - /// actual outcome, a manager that never even attempted apply reports - /// `firewall_applied() == false` — the truth value the audit field should - /// carry. - #[test] - fn firewall_applied_defaults_to_false_when_apply_not_attempted() { - let mgr = NetworkManager::new(); - assert_eq!(mgr.firewall_apply_ok(), None); - assert!(!mgr.firewall_applied()); - } - - /// `stop_all` on a manager that installed nothing reports zero removals - /// as a clean teardown, and — regression for finding #7 — the - /// `FirewallRemoval` empty-list branch matches. #[test] - fn remove_firewall_rules_with_nothing_installed_is_clean() { - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + fn proxyless_policy_has_no_network_resources_to_stop() { + let mut logger = Logger::new(Mode::Buffer); let mut manager = NetworkManager::new(); - let removal = manager.remove_firewall_rules(&mut logger).unwrap(); - assert_eq!(removal.removed, 0); - assert!(removal.all_success); - } - - /// `FirewallRemoval::default` must read as "nothing to remove, and that - /// is fine". The derived default sets `all_success: false`, which reads - /// as a *failed* removal — the opposite of what an empty removal means. - #[test] - fn firewall_removal_default_is_clean() { - assert_eq!(FirewallRemoval::default().removed, 0); - assert!(FirewallRemoval::default().all_success); - } - - #[test] - fn test_validate_ip_or_cidr_valid_ipv4() { - assert!(validate_ip_or_cidr("192.168.1.1")); - assert!(validate_ip_or_cidr("10.0.0.0/8")); - assert!(validate_ip_or_cidr("172.16.0.0/12")); - assert!(validate_ip_or_cidr("0.0.0.0/0")); - assert!(validate_ip_or_cidr("255.255.255.255/32")); - } - - #[test] - fn test_validate_ip_or_cidr_valid_ipv6() { - assert!(validate_ip_or_cidr("::1")); - assert!(validate_ip_or_cidr("fe80::1/64")); - assert!(validate_ip_or_cidr("::1/128")); - } - - #[test] - fn test_validate_ip_or_cidr_invalid() { - assert!(!validate_ip_or_cidr("not_an_ip")); - assert!(!validate_ip_or_cidr("192.168.1.1/")); - assert!(!validate_ip_or_cidr("192.168.1.1/33")); - assert!(!validate_ip_or_cidr("::1/129")); - assert!(!validate_ip_or_cidr("192.168.1.1/abc")); - assert!(!validate_ip_or_cidr("")); - } - - #[test] - fn test_initialize_policy_firewall_mode_block() { - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }; - let (default_policy, use_fw) = NetworkManager::initialize_policy(&policy, &mut logger); - assert!(use_fw); - assert_eq!(default_policy, DefaultPolicy::Block); - } - - /// `describe_policy` is the pure core of `initialize_policy`; the audit - /// record calls it instead so asking the question does not also emit an - /// "Applying network firewall rules..." line as a side effect. - #[test] - fn describe_policy_matches_initialize_policy_but_is_side_effect_free() { - let cases = [ - ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }, - ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Capabilities, - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }, - ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Both, - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - }, - ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - default_network_policy: NetworkPolicy::Allow, + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, ..Default::default() - }, - ]; - for policy in cases { - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let (default_policy, use_fw) = NetworkManager::initialize_policy(&policy, &mut logger); - let plan = NetworkManager::describe_policy(&policy); - assert_eq!(default_policy, plan.default_policy, "policy: {policy:?}"); - assert_eq!(use_fw, plan.firewall_mode_selected, "policy: {policy:?}"); - // The operator-visible line is emitted only when the legacy - // startup path announces rule installation. - assert_eq!( - logger - .get_buffer() - .contains("Applying network firewall rules"), - plan.firewall_mode_selected - && (!policy.allowed_hosts.is_empty() - || !policy.blocked_hosts.is_empty() - || policy.default_network_policy == NetworkPolicy::Block), - "log line must track rule installation; policy: {policy:?}" - ); - } - } - - /// With `enforcementMode: firewall`, no host lists, and - /// `defaultPolicy: allow`, the legacy startup log stays silent even - /// though the apply path still installs its default allow rule. - #[test] - fn firewall_mode_default_allow_installs_default_rule_without_log_line() { - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - default_network_policy: NetworkPolicy::Allow, + }), ..Default::default() }; - let plan = NetworkManager::describe_policy(&policy); - assert!(plan.firewall_mode_selected); - assert!(plan.rules_will_be_installed); + manager + .start( + "principal", + "container", + &policy, + PSID::default(), + &mut logger, + ) + .unwrap(); - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let (_, use_fw) = NetworkManager::initialize_policy(&policy, &mut logger); - assert!(use_fw, "firewall mode selection must be preserved"); - assert!( - !logger - .get_buffer() - .contains("Applying network firewall rules"), - "must not claim rules are being applied; buffer: {}", - logger.get_buffer() - ); + assert!(manager.proxy_address().is_none()); + assert!(!manager.stop_all(&mut logger)); } - /// `stop_all` on a manager that installed nothing must report a clean, - /// empty teardown — not a failure. The derived `Default` for - /// `NetworkTeardown` would get this backwards, which is why it is - /// hand-written. #[test] - fn stop_all_with_nothing_installed_reports_a_clean_teardown() { - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + fn direct_retired_policy_is_rejected_before_proxy_setup() { + let mut logger = Logger::new(Mode::Buffer); let mut manager = NetworkManager::new(); - let outcome = manager.stop_all(true, &mut logger); - assert_eq!(outcome, NetworkTeardown::default()); - assert_eq!(outcome.rules_removed, 0); - assert!(outcome.firewall_removal_ok); - assert!(!outcome.proxy_stopped); - } - - /// Skipping cleanup (`preservePolicy`) must not be reported as a failed - /// removal. - #[test] - fn stop_all_without_cleanup_reports_no_removal_failure() { - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let mut manager = NetworkManager::new(); - let outcome = manager.stop_all(false, &mut logger); - assert!(outcome.firewall_removal_ok); - assert_eq!(outcome.rules_removed, 0); - } - - #[test] - fn test_initialize_policy_capabilities_mode() { - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Capabilities, - ..Default::default() - }; - let (default_policy, use_fw) = NetworkManager::initialize_policy(&policy, &mut logger); - assert!(!use_fw); - assert_eq!(default_policy, DefaultPolicy::Allow); - } - - #[test] - fn test_initialize_policy_firewall_with_allowed_hosts() { - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Both, - default_network_policy: NetworkPolicy::Allow, - allowed_hosts: vec!["example.com".to_string()], - ..Default::default() - }; - let (default_policy, use_fw) = NetworkManager::initialize_policy(&policy, &mut logger); - assert!(use_fw); - assert_eq!(default_policy, DefaultPolicy::Allow); - } - - #[test] - fn test_default_creates_new_manager() { - let mgr = NetworkManager::default(); - assert!(mgr.created_rule_names.is_empty()); - assert!(!mgr.wsa_initialized); - } - - #[test] - fn test_resolve_hostname_localhost() { - let result = resolve_hostname("localhost"); - assert!(result.is_ok()); - let ip = result.unwrap(); - assert!(ip == "127.0.0.1" || ip == "::1"); - } - - #[test] - fn test_resolve_hostname_invalid() { - let result = resolve_hostname("this.host.definitely.does.not.exist.invalid"); - assert!(result.is_err()); + let mut policy = ContainerPolicy::default(); + policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".into(), 8080)); + policy.allowed_hosts.push("example.com".into()); + assert!(matches!( + manager.start("principal", "container", &policy, PSID::default(), &mut logger), + Err(WxcError::Validation(message)) if message.contains("allowedHosts") + )); + + policy.allowed_hosts.clear(); + policy.blocked_hosts.push("example.com".into()); + assert!(matches!( + manager.start("principal", "container", &policy, PSID::default(), &mut logger), + Err(WxcError::Validation(message)) if message.contains("blockedHosts") + )); + + policy.blocked_hosts.clear(); + policy.default_network_policy = NetworkPolicy::Allow; + assert!(matches!( + manager.start("principal", "container", &policy, PSID::default(), &mut logger), + Err(WxcError::Validation(message)) if message.contains("defaultPolicy") + )); + + policy.default_network_policy = NetworkPolicy::Block; + policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; + assert!(matches!( + manager.start("principal", "container", &policy, PSID::default(), &mut logger), + Err(WxcError::Validation(message)) if message.contains("enforcementMode") + )); + + policy.network_enforcement_mode = NetworkEnforcementMode::Capabilities; + policy.allow_local_network = true; + assert!(matches!( + manager.start("principal", "container", &policy, PSID::default(), &mut logger), + Err(WxcError::Validation(message)) if message.contains("allowLocalNetwork") + )); + assert!(manager.proxy_address().is_none()); } } diff --git a/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs b/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs index a66972869..368112459 100644 --- a/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs +++ b/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs @@ -4,11 +4,12 @@ //! Shared ProcessContainer network-policy helpers. use crate::mxc_common::models::{ - ContainerPolicy, NetworkAction, NetworkEnforcementMode, NetworkPolicy, + ContainerPolicy, NetworkAction, NetworkEnforcementMode, NetworkPolicy, ScriptResponse, }; pub(crate) const INTERNET_CLIENT_CAPABILITY: &str = "internetClient"; pub(crate) const PRIVATE_NETWORK_CAPABILITY: &str = "privateNetworkClientServer"; +pub(crate) const CAPABILITIES_ENFORCEMENT_MODE: &str = "capabilities"; const POLICY_OWNED_NETWORK_CAPABILITIES: [&str; 4] = [ INTERNET_CLIENT_CAPABILITY, "internetClientServer", @@ -17,10 +18,41 @@ const POLICY_OWNED_NETWORK_CAPABILITIES: [&str; 4] = [ ]; pub(crate) fn allows_network_egress(policy: &ContainerPolicy) -> bool { - policy.network_egress.as_ref().map_or( - policy.default_network_policy == NetworkPolicy::Allow, - |egress| egress.default == NetworkAction::Allow || !egress.allow.is_empty(), - ) + policy + .network_egress + .as_ref() + .is_some_and(|egress| egress.default == NetworkAction::Allow || !egress.allow.is_empty()) +} + +pub(crate) fn audit_egress_default(policy: &ContainerPolicy) -> &'static str { + match policy + .network_egress + .as_ref() + .map_or(NetworkAction::Deny, |egress| egress.default) + { + NetworkAction::Allow => "allow", + NetworkAction::Deny => "block", + } +} + +pub(crate) fn reject_retired_network_policy( + policy: &ContainerPolicy, +) -> Result<(), ScriptResponse> { + let field = if policy.default_network_policy != NetworkPolicy::Block { + Some("network.defaultPolicy") + } else if policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities { + Some("network.enforcementMode") + } else if policy.allow_local_network { + Some("network.allowLocalNetwork") + } else { + None + }; + if let Some(field) = field { + return Err(ScriptResponse::rejected(&format!( + "{field} is retired; use network.egress and network.ingress" + ))); + } + Ok(()) } pub(crate) fn ensure_capability(capabilities: &mut Vec, capability: &str) { @@ -32,17 +64,6 @@ pub(crate) fn ensure_capability(capabilities: &mut Vec, capability: &str } } -pub(crate) fn uses_network_capabilities(policy: &ContainerPolicy) -> bool { - // Directional networking has no enforcementMode field. Its capability - // gates are always required; the legacy mode applies only to legacy fields. - policy.network_egress.is_some() - || policy.network_ingress.is_some() - || matches!( - policy.network_enforcement_mode, - NetworkEnforcementMode::Capabilities | NetworkEnforcementMode::Both - ) -} - pub(crate) fn add_default_network_capabilities( policy: &ContainerPolicy, capabilities: &mut Vec, @@ -55,16 +76,14 @@ pub(crate) fn add_default_network_capabilities( }); } - let uses_capabilities = uses_network_capabilities(policy); - if uses_capabilities && allows_network_egress(policy) { + if allows_network_egress(policy) { ensure_capability(capabilities, INTERNET_CLIENT_CAPABILITY); } - if uses_capabilities - && policy - .network_ingress - .as_ref() - .is_some_and(|ingress| ingress.default == NetworkAction::Allow) + if policy + .network_ingress + .as_ref() + .is_some_and(|ingress| ingress.default == NetworkAction::Allow) { ensure_capability(capabilities, PRIVATE_NETWORK_CAPABILITY); } @@ -76,9 +95,8 @@ mod tests { use crate::mxc_common::models::{NetworkEgressPolicy, NetworkIngressPolicy}; #[test] - fn directional_egress_default_overrides_legacy_default() { + fn directional_egress_defaults_and_rules_select_capability() { let mut policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, network_egress: Some(NetworkEgressPolicy { default: NetworkAction::Deny, ..Default::default() @@ -87,7 +105,6 @@ mod tests { }; assert!(!allows_network_egress(&policy)); - policy.default_network_policy = NetworkPolicy::Block; policy.network_egress = Some(NetworkEgressPolicy { default: NetworkAction::Allow, ..Default::default() @@ -105,7 +122,10 @@ mod tests { #[test] fn default_network_capabilities_are_deduplicated_case_insensitively() { let policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), network_ingress: Some(NetworkIngressPolicy { default: NetworkAction::Allow, ..Default::default() @@ -123,9 +143,8 @@ mod tests { } #[test] - fn directional_networking_ignores_legacy_enforcement_mode() { + fn directional_networking_uses_capabilities_for_both_directions() { let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, network_egress: Some(NetworkEgressPolicy { default: NetworkAction::Allow, ..Default::default() @@ -149,6 +168,51 @@ mod tests { ); } + #[test] + fn directly_built_requests_cannot_activate_retired_network_fields() { + let mut policy = ContainerPolicy { + default_network_policy: NetworkPolicy::Allow, + ..Default::default() + }; + assert!(reject_retired_network_policy(&policy) + .unwrap_err() + .error_message + .contains("defaultPolicy")); + + policy.default_network_policy = NetworkPolicy::Block; + policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; + assert!(reject_retired_network_policy(&policy) + .unwrap_err() + .error_message + .contains("enforcementMode")); + + policy.network_enforcement_mode = NetworkEnforcementMode::Capabilities; + policy.allow_local_network = true; + assert!(reject_retired_network_policy(&policy) + .unwrap_err() + .error_message + .contains("allowLocalNetwork")); + } + + #[test] + fn audit_default_describes_directional_egress_not_allowed_exceptions() { + let mut policy = ContainerPolicy::default(); + assert_eq!(audit_egress_default(&policy), "block"); + + policy.network_egress = Some(NetworkEgressPolicy { + default: NetworkAction::Deny, + allow: vec![Default::default()], + ..Default::default() + }); + assert_eq!(audit_egress_default(&policy), "block"); + + policy.network_egress = Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }); + assert_eq!(audit_egress_default(&policy), "allow"); + } + #[test] fn directional_networking_replaces_caller_owned_network_capabilities() { let policy = ContainerPolicy { @@ -182,7 +246,7 @@ mod tests { } #[test] - fn legacy_networking_preserves_caller_owned_network_capabilities() { + fn omitted_network_sections_preserve_caller_owned_capabilities() { let policy = ContainerPolicy::default(); let mut capabilities = vec![ INTERNET_CLIENT_CAPABILITY.to_string(), diff --git a/src/mxc-sdk/src/backends/seatbelt/common/default_env.rs b/src/mxc-sdk/src/backends/seatbelt/common/default_env.rs index 495362fe5..0fbdfbf1e 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/default_env.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/default_env.rs @@ -50,13 +50,8 @@ pub use crate::mxc_common::default_env::env_pairs; #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::DefaultEnvCompatibility; - - fn request(compatibility: DefaultEnvCompatibility) -> ExecutionRequest { - ExecutionRequest { - default_env_compatibility: compatibility, - ..Default::default() - } + fn request() -> ExecutionRequest { + ExecutionRequest::default() } fn value<'a>(entries: &'a [String], key: &str) -> Option<&'a str> { @@ -78,7 +73,7 @@ mod tests { #[test] fn an_omitted_env_gets_the_default_block() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; let entries = resolved_env(&r, Some("/workspace")); assert_eq!(value(&entries, "PATH"), Some(DEFAULT_SANDBOX_PATH)); @@ -88,7 +83,7 @@ mod tests { #[test] fn an_explicitly_empty_env_stays_empty() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec![]); assert!(resolved_env(&r, None).is_empty()); } @@ -96,14 +91,14 @@ mod tests { #[test] fn a_supplied_env_is_used_verbatim() { // The 0.9 behavior change: no implicit PATH under a supplied env. - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FOO=bar".into()]); assert_eq!(resolved_env(&r, None), vec!["FOO=bar".to_string()]); } #[test] fn inherit_default_env_layers_over_the_default_block() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FOO=bar".into(), "PATH=/only/mine".into()]); r.inherit_default_env = true; let entries = resolved_env(&r, None); @@ -122,7 +117,7 @@ mod tests { /// deliberately does not re-derive it from the request. #[test] fn home_follows_the_directory_the_child_starts_in() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; assert_eq!( value(&resolved_env(&r, Some("/workspace")), "HOME"), @@ -132,7 +127,7 @@ mod tests { #[test] fn an_unresolved_working_directory_leaves_home_unset() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; // Set on the request but never resolved by the runner: only what the // runner passes in counts. @@ -147,7 +142,7 @@ mod tests { #[test] fn a_caller_entry_without_a_value_is_dropped_by_both_modes() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FEATURE_FLAG".into(), "FOO=bar".into()]); let verbatim = resolved_env(&r, None); diff --git a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs index 4e4e91c50..11846b026 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs @@ -340,7 +340,7 @@ fn write_filesystem_allow(out: &mut String, paths: &ResolvedPaths) { // // This deny survives the unfiltered `(allow network-outbound)` // that `write_network_rules` emits below under - // `defaultPolicy: "allow"`: last-match-wins applies between + // `egress.default: "allow"`: last-match-wins applies between // rules that carry a filter, and an unfiltered rule does not // override a path-filtered one. Pinned by // `readonly_socket_strip_survives_a_default_allow_outbound`. @@ -360,8 +360,7 @@ fn write_filesystem_deny(out: &mut String, paths: &ResolvedPaths) { // `network-outbound` is denied here for two reasons. A denied path can // sit inside a broader `readwritePaths` subtree, whose allow covers it; // and `write_outbound_allow_rules` emits an *unfiltered* `(allow - // network-outbound)` under `defaultPolicy: "allow"` and the - // remote-proxy fallback. Either would otherwise let the sandbox + // network-outbound)` under `egress.default: "allow"`. Either would otherwise let the sandbox // `connect()` to a UNIX socket inside a denied subtree and talk to // whatever listens there. A Docker / ssh-agent / gpg-agent socket is a // control plane, so that would be an escape. @@ -390,39 +389,15 @@ fn write_network_rules( ) { let policy = &request.policy; let allow_outbound = seatbelt_policy::egress_allowed(policy); - let has_allowed_hosts = !policy.allowed_hosts.is_empty(); - - // blocked_hosts is rejected at the runner level before reaching the - // profile builder, so it isn't handled here. - match (allow_outbound, has_allowed_hosts) { - (false, false) => { + match allow_outbound { + false => { // Pure deny — implicit from `(deny default)`. out.push_str(";; --- network: default-deny (no allow-network rules emitted) ---\n"); } - (false, true) => { - // An allowlist under deny must never widen to allow-all. This is - // reached only with builtinTestServer — the one proxy MXC hands the - // host list to — so the profile keeps the deny baseline plus - // port-scoped proxy reachability, making the proxy the only way out. - out.push_str(";; --- network: default-deny; allowedHosts enforced by the MXC-run\n"); - out.push_str(";; builtin test proxy, not the profile (Seatbelt cannot filter\n"); - out.push_str(";; by host) ---\n"); - } - (true, false) => { + true => { out.push_str(";; --- network: outbound allowed (any host) ---\n"); write_outbound_allow_rules(out); } - (true, true) => { - // Seatbelt only accepts `*` or `localhost` in `(remote ...)` filters — - // per-hostname filtering isn't possible. The default is already - // allow-all here, so the allowlist is a no-op superset rather than a - // weakening, and allow-all remains the honest rendering. - out.push_str( - ";; --- network: allowedHosts requested but Seatbelt cannot filter by host;\n", - ); - out.push_str(";; default is already allow, so all outbound stays allowed ---\n"); - write_outbound_allow_rules(out); - } } write_host_loopback_rules(out, policy, allow_outbound); @@ -455,10 +430,7 @@ fn write_network_rules( /// deny-under-deny is already covered by `(deny default)`, and allow-under-allow /// is already covered by the blanket outbound allow. fn write_host_loopback_rules(out: &mut String, policy: &ContainerPolicy, allow_outbound: bool) { - // The legacy shape has no hostLoopback concept — leave 0.6/0.7 untouched. - let Some(loopback_allowed) = seatbelt_policy::host_loopback_allowed(policy) else { - return; - }; + let loopback_allowed = seatbelt_policy::host_loopback_allowed(policy); match (loopback_allowed, allow_outbound) { (false, true) => { @@ -527,11 +499,11 @@ fn write_proxy_reachability_rules(out: &mut String, proxy_address: &ProxyAddress /// on macOS — the `network-bind` rule alone is not enough; the kernel rejects /// `listen()` with EPERM without `network-inbound`. Scoped to `(local ip)` so /// it only covers IP sockets, never UNIX-domain or Mach sockets. -fn write_local_network_rules(out: &mut String, allow_local_network: bool) { - if !allow_local_network { +fn write_local_network_rules(out: &mut String, allow_ingress: bool) { + if !allow_ingress { return; } - out.push_str(";; --- network: allowLocalNetwork — accept inbound on local IPs ---\n"); + out.push_str(";; --- network: ingress.default=allow — accept inbound on local IPs ---\n"); out.push_str("(allow network-inbound (local ip))\n"); } @@ -846,7 +818,7 @@ fn escape_for_quotes(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::{NetworkAction, NetworkPolicy, SeatbeltConfig, UiPolicy}; + use crate::mxc_common::models::{NetworkAction, SeatbeltConfig, UiPolicy}; fn build_profile(request: &ExecutionRequest) -> Result { build_profile_with_proxy(request, request.policy.network_proxy.address.as_ref()) @@ -913,37 +885,37 @@ mod tests { #[test] fn default_deny_network_emits_no_allow_network() { let mut r = req(); - // Default policy is Allow per NetworkPolicy::default(); flip it. - r.policy.default_network_policy = NetworkPolicy::Block; + r.policy.network_egress = Some(egress(NetworkAction::Deny)); let p = build_profile(&r).unwrap(); assert!(!p.contains("(allow network-outbound")); assert!(p.contains("network: default-deny")); } #[test] - fn block_with_allowed_hosts_never_widens_to_allow_all() { - // `allowedHosts` under a deny default must not flip the profile to - // allow-all outbound — that would be the inverse of the requested - // policy. `config_parser` rejects this combination except with the - // MXC-run builtin test proxy, which is the only proxy actually given - // the host list, so the profile keeps its deny baseline. + fn unsupported_directional_egress_rule_never_widens_to_allow_all() { + // Backend validation rejects rules, and even a direct profile build + // must not turn a denied default into allow-all. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; - r.policy.allowed_hosts = vec!["api.github.com".into(), "registry.npmjs.org".into()]; + r.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + allow: vec![crate::mxc_common::models::NetworkRule { + to: vec![crate::mxc_common::models::NetworkPeer { + cidr: "192.0.2.1/32".parse().unwrap(), + except: Vec::new(), + }], + ..Default::default() + }], + ..Default::default() + }); let p = build_profile(&r).unwrap(); assert!(!p.contains("(allow network-outbound)")); - assert!(p.contains("enforced by the MXC-run")); - // Should NOT have per-host remote rules. + assert!(p.contains("network: default-deny")); assert!(!p.contains("(remote")); } #[test] - fn block_with_allowed_hosts_and_proxy_keeps_deny_plus_proxy_reachability() { - // The builtin-test-proxy case: the proxy filters the host list, so the - // profile must stay deny-all except port-scoped proxy reachability. + fn deny_with_loopback_proxy_keeps_port_scoped_reachability() { let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; - r.policy.allowed_hosts = vec!["api.github.com".into()]; + r.policy.network_egress = Some(egress(NetworkAction::Deny)); let address = ProxyAddress::new("127.0.0.1".into(), 8080); let p = build_profile_with_proxy(&r, Some(&address)).unwrap(); assert!(!p.contains("(allow network-outbound)")); @@ -966,7 +938,7 @@ mod tests { "LocalHost", ] { let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; + r.policy.network_egress = Some(egress(NetworkAction::Deny)); let address = ProxyAddress::new(host.into(), 8080); let p = build_profile_with_proxy(&r, Some(&address)).unwrap(); @@ -989,7 +961,7 @@ mod tests { fn remote_proxy_under_deny_fails_closed() { for host in ["proxy.corp.example", "10.0.0.5", "[2001:db8::1]"] { let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; + r.policy.network_egress = Some(egress(NetworkAction::Deny)); let address = ProxyAddress::new(host.into(), 8080); let p = build_profile_with_proxy(&r, Some(&address)).unwrap(); assert!( @@ -1003,31 +975,20 @@ mod tests { #[test] fn allow_outbound_no_hosts_emits_open_network_outbound() { let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; + r.policy.network_egress = Some(egress(NetworkAction::Allow)); let p = build_profile(&r).unwrap(); assert!(p.contains("(allow network-outbound)")); } #[test] - fn allow_outbound_with_hosts_emits_per_host_remote_rules() { + fn directional_allow_outbound_still_closes_host_loopback() { let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; - r.policy.allowed_hosts = vec!["api.github.com".into(), "1.2.3.4".into()]; + r.policy.network_egress = Some(egress(NetworkAction::Allow)); + r.policy.network_ingress = Some(ingress(NetworkAction::Deny)); let p = build_profile(&r).unwrap(); - assert!(p.contains("Seatbelt cannot filter by host")); assert!(p.contains("(allow network-outbound)")); - assert!(!p.contains("(remote")); - } - - #[test] - fn blocked_hosts_not_emitted_in_profile() { - // blocked_hosts is rejected at the runner level, but verify the - // profile builder doesn't crash if called with them anyway. - let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; - r.policy.blocked_hosts = vec!["evil.example.com".into()]; - let p = build_profile(&r).unwrap(); - assert!(!p.contains("(deny network-outbound")); + assert!(p.contains("(deny network-outbound (remote ip \"localhost:*\"))")); + assert!(!p.contains("(allow network-inbound")); } #[test] @@ -1036,7 +997,7 @@ mod tests { // is default-deny — but only the proxy's exact port, not all of // loopback and not the whole network. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; + r.policy.network_egress = Some(egress(NetworkAction::Deny)); let addr = ProxyAddress::new("127.0.0.1".into(), 8080); let p = build_profile_with_proxy(&r, Some(&addr)).unwrap(); assert!(p.contains("(allow network-outbound (remote ip \"localhost:8080\"))")); @@ -1045,26 +1006,12 @@ mod tests { assert!(!p.contains("(allow network-outbound)\n")); } - #[test] - fn builtin_test_proxy_under_default_deny_scopes_outbound_to_proxy_port() { - // builtinTestServer binds a loopback port at runtime; the runner passes - // that *resolved* address here, so the rule is scoped to the real port - // just like an explicit loopback proxy. - let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; - let addr = ProxyAddress::new("127.0.0.1".into(), 54321); - let p = build_profile_with_proxy(&r, Some(&addr)).unwrap(); - assert!(p.contains("(allow network-outbound (remote ip \"localhost:54321\"))")); - assert!(!p.contains("localhost:*")); - assert!(!p.contains("(allow network-outbound)\n")); - } - #[test] fn remote_proxy_under_default_deny_emits_no_outbound_allow() { // A remote proxy can't be expressed as a reachability rule, so it fails // closed. Rejected upstream, so this only pins the fallback behavior. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; + r.policy.network_egress = Some(egress(NetworkAction::Deny)); let addr = ProxyAddress::from_url( "http://proxy.example.com:8080", "proxy.example.com".into(), @@ -1077,14 +1024,15 @@ mod tests { #[test] fn proxy_under_default_allow_does_not_add_scoped_rule() { - // When outbound is already open (defaultPolicy allow), the proxy + // When outbound is already open (egress.default allow), the proxy // reachability rule is redundant and must not be emitted. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; + r.policy.network_egress = Some(egress(NetworkAction::Allow)); let addr = ProxyAddress::new("127.0.0.1".into(), 8080); let p = build_profile_with_proxy(&r, Some(&addr)).unwrap(); assert!(p.contains("(allow network-outbound)\n")); - assert!(!p.contains("localhost:")); + assert!(!p.contains("(allow network-outbound (remote ip \"localhost:8080\"))")); + assert!(p.contains("(deny network-outbound (remote ip \"localhost:*\"))")); } #[test] @@ -1093,10 +1041,10 @@ mod tests { // configured proxy address (localhost:), scoping the reachability // rule to that exact port under default-deny — not silently dropping it. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; + r.policy.network_egress = Some(egress(NetworkAction::Deny)); r.policy.network_proxy = crate::mxc_common::models::ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".into(), 9091)), - builtin_test_server: false, + ..Default::default() }; let p = build_profile(&r).unwrap(); assert!(p.contains("(allow network-outbound (remote ip \"localhost:9091\"))")); @@ -1104,34 +1052,38 @@ mod tests { } #[test] - fn allow_local_network_emits_inbound_rule() { + fn directional_ingress_allow_emits_inbound_rule() { // server.listen() on macOS is governed by `network-inbound`, not // `network-bind` — with only `network-bind (local ip)` the bind() // succeeds and the kernel then rejects listen() with EPERM. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; - r.policy.allow_local_network = true; + r.policy.network_egress = Some(egress(NetworkAction::Allow)); + r.policy.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Deny, + }); let p = build_profile(&r).unwrap(); assert!(p.contains("(allow network-inbound (local ip))")); - assert!(p.contains("allowLocalNetwork")); + assert!(p.contains("ingress.default=allow")); } #[test] - fn allow_local_network_default_omits_inbound_rule() { - // Default (allow_local_network=false) must not emit any inbound rule. + fn omitted_ingress_default_omits_inbound_rule() { let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; + r.policy.network_egress = Some(egress(NetworkAction::Allow)); let p = build_profile(&r).unwrap(); assert!(!p.contains("network-inbound")); } #[test] - fn allow_local_network_works_with_default_deny_outbound() { - // allow_local_network is independent of outbound: a process can be + fn ingress_allow_works_with_default_deny_outbound() { + // Ingress is independent of outbound: a process can be // a pure server (no client traffic) and still accept local inbound. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Block; - r.policy.allow_local_network = true; + r.policy.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Deny, + }); let p = build_profile(&r).unwrap(); assert!(p.contains("(allow network-inbound (local ip))")); assert!(!p.contains("(allow network-outbound)")); @@ -1139,11 +1091,7 @@ mod tests { #[test] fn directional_egress_deny_emits_no_allow_network() { - // Schema-0.8 directional shape: network_egress.default is consulted - // instead of the legacy default_network_policy field (which stays at - // its Block default under this shape and must not be read directly). let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; // must be ignored r.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { default: NetworkAction::Deny, ..Default::default() @@ -1166,10 +1114,8 @@ mod tests { #[test] fn directional_ingress_default_allow_emits_inbound_rule() { - // Seatbelt maps ingress.default (not hostLoopback) to the existing - // allowLocalNetwork behavior — see network_parser and validate(). + // Seatbelt maps ingress.default (not hostLoopback) to the inbound rule. let mut r = req(); - r.policy.allow_local_network = false; // must be ignored r.policy.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy { default: NetworkAction::Allow, host_loopback: NetworkAction::Allow, @@ -1181,7 +1127,6 @@ mod tests { #[test] fn directional_ingress_default_deny_omits_inbound_rule() { let mut r = req(); - r.policy.allow_local_network = true; // must be ignored r.policy.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy { default: NetworkAction::Deny, host_loopback: NetworkAction::Deny, @@ -1299,18 +1244,11 @@ mod tests { } #[test] - fn legacy_shape_emits_no_host_loopback_rule() { - // 0.6/0.7 configs have no hostLoopback concept and must be untouched. - for allow_local in [false, true] { - let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; - r.policy.allow_local_network = allow_local; - let p = build_profile(&r).unwrap(); - assert!( - !p.contains("localhost:*"), - "legacy shape must not gain a host-loopback rule" - ); - } + fn omitted_ingress_under_open_egress_closes_host_loopback() { + let mut r = req(); + r.policy.network_egress = Some(egress(NetworkAction::Allow)); + let p = build_profile(&r).unwrap(); + assert!(p.contains("(deny network-outbound (remote ip \"localhost:*\"))")); } #[test] @@ -1358,9 +1296,7 @@ mod tests { #[test] fn directional_deny_with_loopback_proxy_scopes_outbound_to_proxy_port() { - // Same proxy-reachability behavior as the legacy shape must be - // preserved when the directional shape selects deny + loopback proxy - // (the only combination the GA schema allows with a runtime proxy). + // Deny + loopback proxy must grant only the selected proxy port. let mut r = req(); r.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { default: NetworkAction::Deny, @@ -1464,11 +1400,10 @@ mod tests { #[test] fn readwrite_unix_socket_ops_are_independent_of_network_policy() { // AF_UNIX bind/connect follow the filesystem policy, so a default-deny - // network policy with allowLocalNetwork off must still permit them. + // network policy with denied ingress must still permit them. let mut r = req(); r.policy.readwrite_paths = vec!["/tmp/output".into()]; - r.policy.default_network_policy = NetworkPolicy::Block; - r.policy.allow_local_network = false; + r.policy.network_egress = Some(egress(NetworkAction::Deny)); let p = build_profile(&r).unwrap(); assert!(p.contains(RW_RULE)); assert!(!p.contains("network-inbound")); @@ -1477,7 +1412,7 @@ mod tests { #[test] fn denied_paths_deny_outbound_under_default_allow() { - // `defaultPolicy: allow` emits a bare `(allow network-outbound)`, which + // `egress.default: allow` emits a bare `(allow network-outbound)`, which // on its own grants AF_UNIX `connect()`. A denied subtree must still // deny it. Position relative to that unfiltered allow is deliberately // not asserted: an unfiltered rule cannot override a path-filtered one @@ -1487,7 +1422,7 @@ mod tests { // `denied_paths_appear_after_allows_to_override` and // `denied_paths_deny_unix_socket_ops_after_allows`. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; + r.policy.network_egress = Some(egress(NetworkAction::Allow)); r.policy.denied_paths = vec!["/tmp/secret".into()]; let p = build_profile(&r).unwrap(); let deny_idx = p.find(DENY_RULE).expect("deny must cover network-outbound"); @@ -1615,7 +1550,7 @@ mod tests { #[test] fn readonly_socket_strip_survives_a_default_allow_outbound() { - // `defaultPolicy: "allow"` emits an unfiltered `(allow + // `egress.default: "allow"` emits an unfiltered `(allow // network-outbound)`. The read-only strip still governs AF_UNIX // `connect()` under that subtree, because an unfiltered rule does not // override a path-filtered one. Verified end-to-end against @@ -1626,7 +1561,7 @@ mod tests { // Emission order relative to the unfiltered allow is deliberately not // asserted — it has no bearing on the outcome. let mut r = req(); - r.policy.default_network_policy = NetworkPolicy::Allow; + r.policy.network_egress = Some(egress(NetworkAction::Allow)); r.policy.readonly_paths = vec!["/tmp/ro".into()]; let p = build_profile(&r).unwrap(); diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs index aec90d142..35895a6ef 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs @@ -44,57 +44,56 @@ pub fn validate_seatbelt_ui_policy(request: &ExecutionRequest) -> Result<(), Str Ok(()) } -/// Effective outbound posture, preferring the directional `network.egress` -/// over the legacy `defaultPolicy` when both are present. +/// Effective outbound posture; omission defaults to deny. pub fn egress_allowed(policy: &ContainerPolicy) -> bool { - match policy.network_egress.as_ref() { - Some(egress) => egress.default == NetworkAction::Allow, - None => matches!(policy.default_network_policy, NetworkPolicy::Allow), - } + policy + .network_egress + .as_ref() + .is_some_and(|egress| egress.default == NetworkAction::Allow) } /// Effective inbound posture. Seatbelt maps `network.ingress.default` onto its /// `(allow network-inbound (local ip))` rule; `hostLoopback` is enforced /// separately on the container-to-host direction, so it plays no part here. pub fn local_network_allowed(policy: &ContainerPolicy) -> bool { - match policy.network_ingress.as_ref() { - Some(ingress) => ingress.default == NetworkAction::Allow, - None => policy.allow_local_network, - } + policy + .network_ingress + .as_ref() + .is_some_and(|ingress| ingress.default == NetworkAction::Allow) } -/// Effective host-loopback posture, or `None` for the legacy shape, which has -/// no `hostLoopback` concept and keeps its 0.6/0.7 behavior untouched. -pub fn host_loopback_allowed(policy: &ContainerPolicy) -> Option { +/// Effective host-loopback posture; omission defaults to deny. +pub fn host_loopback_allowed(policy: &ContainerPolicy) -> bool { policy .network_ingress .as_ref() - .map(|ingress| ingress.host_loopback == NetworkAction::Allow) + .is_some_and(|ingress| ingress.host_loopback == NetworkAction::Allow) } -/// Check every Seatbelt network invariant. Covers both the legacy and the -/// directional shape via [`egress_allowed`]. +/// Check every Seatbelt network invariant before profile construction. pub fn validate_seatbelt_network_policy(policy: &ContainerPolicy) -> Result<(), String> { let proxy_enabled = policy.network_proxy.is_enabled(); let outbound_allowed = egress_allowed(policy); - // No packet-filter layer on macOS, so a firewall mode can't be honored. - if proxy_enabled - && matches!( - policy.network_enforcement_mode, - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both - ) + if policy.default_network_policy != NetworkPolicy::Block + || policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities + || policy.allow_local_network + || !policy.allowed_hosts.is_empty() + || !policy.blocked_hosts.is_empty() { - return Err("Seatbelt: network.proxy cannot be combined with \ - network.enforcementMode='firewall' or 'both'. macOS Seatbelt \ - enforces network policy through the sandbox profile and has no \ - packet-filter layer, so a firewall mode cannot be honored." + return Err("Seatbelt: retired network fields are not supported; use \ + network.egress and network.ingress" .to_string()); } + if policy.network_proxy.builtin_test_server { + return Err( + "Seatbelt: network.proxy.builtinTestServer is retired; supply a caller-managed \ + loopback proxy through runtimeConfig.networkProxy" + .to_string(), + ); + } - // A remote proxy can't be expressed as a reachability rule, so it would - // simply be unreachable. Loopback proxies (including builtinTestServer, - // whose address is resolved at runtime and absent here) stay port-scoped. + // A remote proxy can't be expressed as a reachability rule. if !outbound_allowed && policy .network_proxy @@ -103,16 +102,9 @@ pub fn validate_seatbelt_network_policy(policy: &ContainerPolicy) -> Result<(), .is_some_and(|addr| !host_is_canonical_loopback(addr.host())) { return Err( - "Seatbelt: a remote network.proxy (non-loopback host) cannot be \ - combined with defaultPolicy='block'. Seatbelt cannot express \ - reachability to a specific remote host, so the proxy would be \ - unreachable and no outbound connection could succeed. Note that \ - allowedHosts/blockedHosts are never forwarded to an external \ - proxy, so it cannot enforce them on MXC's behalf. Use a loopback \ - proxy (127.0.0.1, [::1], or localhost) or \ - 'network.proxy.builtinTestServer: true' (testing only, and the \ - only form where MXC enforces the host lists) for port-scoped \ - reachability under deny." + "Seatbelt: runtimeConfig.networkProxy must name a loopback endpoint \ + (127.0.0.1, [::1], or localhost); a remote proxy cannot be reached \ + under network.egress.default='deny'" .to_string(), ); } @@ -120,46 +112,12 @@ pub fn validate_seatbelt_network_policy(policy: &ContainerPolicy) -> Result<(), // Outbound is already unrestricted, so the proxy adds no enforcement and // any intent to route through it is silently ignored. if proxy_enabled && outbound_allowed { - return Err("Seatbelt: network.proxy cannot be combined with \ - defaultPolicy='allow'. Outbound network is already unrestricted \ - under 'allow', so the proxy would have no enforcement effect and \ - any intent to route traffic through it would be silently ignored. \ - Use defaultPolicy='block' with a loopback proxy (or \ - 'network.proxy.builtinTestServer: true') to actually enforce \ - proxy-only egress." - .to_string()); - } - - // Seatbelt's `(remote ...)` filter accepts only `*` / `localhost`, so a - // hostname allowlist can't be expressed. Under deny the only approximations - // are allow-all (the inverse of the request) or deny-all (silently dropping - // it). The MXC-run builtin test proxy is the exception: it does filter hosts. - if !policy.allowed_hosts.is_empty() - && !outbound_allowed - && !policy.network_proxy.builtin_test_server - { - return Err("Seatbelt: allowedHosts cannot be combined with \ - defaultPolicy='block'. macOS Seatbelt has no per-host network \ - filtering primitive, so the allowlist cannot be enforced and \ - would degrade to allow-all outbound -- the inverse of the \ - requested policy. Use 'network.proxy.builtinTestServer: true' \ - (testing only) for MXC-enforced host filtering, remove \ - allowedHosts to keep the deny, or remove allowedHosts and use \ - defaultPolicy='allow' if unrestricted egress is intended." + return Err("Seatbelt: runtimeConfig.networkProxy requires \ + network.egress.default='deny'; an allow default would bypass \ + the proxy and leave direct outbound traffic unrestricted" .to_string()); } - // Seatbelt cannot filter network by hostname -- reject blockedHosts rather - // than silently allowing traffic the user expects to be denied. - if !policy.blocked_hosts.is_empty() { - return Err( - "macOS Seatbelt does not support per-host network filtering. \ - 'blockedHosts' cannot be enforced; remove it. To deny all \ - network, use defaultPolicy: \"block\" without host lists." - .to_string(), - ); - } - // `hostLoopback` is bidirectional and Seatbelt can only enforce its // container-to-host half (see `write_host_loopback_rules`): an inbound // filter cannot be scoped by peer, so there is no rule that admits a @@ -191,23 +149,14 @@ mod tests { use crate::mxc_common::models::{ProxyAddress, ProxyConfig, SeatbeltConfig}; #[test] - fn host_loopback_allowed_is_none_for_the_legacy_shape() { - // 0.6/0.7 has no hostLoopback concept; the caller must not synthesize - // one, or legacy configs would change behavior. - let mut p = policy(); - p.allow_local_network = true; - assert_eq!(host_loopback_allowed(&p), None); - p.allow_local_network = false; - assert_eq!(host_loopback_allowed(&p), None); + fn omitted_host_loopback_defaults_to_deny() { + assert!(!host_loopback_allowed(&policy())); } #[test] fn host_loopback_allowed_reads_the_directional_field() { let mut p = policy(); - for (action, expected) in [ - (NetworkAction::Allow, Some(true)), - (NetworkAction::Deny, Some(false)), - ] { + for (action, expected) in [(NetworkAction::Allow, true), (NetworkAction::Deny, false)] { p.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy { default: action, host_loopback: action, @@ -233,21 +182,19 @@ mod tests { #[test] fn rejects_proxy_with_default_allow() { - // Outbound is already unrestricted under 'allow' — a proxy adds no - // enforcement, so the combination is a config-authoring mistake. let mut p = policy(); - p.default_network_policy = NetworkPolicy::Allow; - p.network_proxy = ProxyConfig { - address: None, - builtin_test_server: true, - }; + p.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }); + p.network_proxy = proxy("127.0.0.1"); let msg = validate_seatbelt_network_policy(&p).unwrap_err(); - assert!(msg.contains("defaultPolicy='allow'"), "got: {msg}"); + assert!(msg.contains("network.egress.default='deny'"), "got: {msg}"); } #[test] - fn rejects_proxy_with_firewall_enforcement_mode() { + fn rejects_retired_firewall_enforcement_mode() { for mode in [ NetworkEnforcementMode::Firewall, NetworkEnforcementMode::Both, @@ -257,22 +204,24 @@ mod tests { p.network_enforcement_mode = mode.clone(); let msg = validate_seatbelt_network_policy(&p).unwrap_err(); - assert!(msg.contains("enforcementMode"), "{mode:?} got: {msg}"); + assert!( + msg.contains("retired network fields"), + "{mode:?} got: {msg}" + ); } } #[test] - fn accepts_builtin_test_proxy_under_block() { - // builtinTestServer binds a loopback port at runtime, so it has no - // address here — port-scoped and therefore safe under a deny default. + fn rejects_retired_builtin_test_proxy() { let mut p = policy(); - p.default_network_policy = NetworkPolicy::Block; p.network_proxy = ProxyConfig { address: None, builtin_test_server: true, }; - assert!(validate_seatbelt_network_policy(&p).is_ok()); + assert!(validate_seatbelt_network_policy(&p) + .unwrap_err() + .contains("builtinTestServer")); } /// The guard compared unbracketed literals only, so `http://[::1]` — the @@ -287,7 +236,6 @@ mod tests { "[0000:0000:0000:0000:0000:0000:0000:0001]", ] { let mut p = policy(); - p.default_network_policy = NetworkPolicy::Block; p.network_proxy = proxy(host); assert!( @@ -312,63 +260,55 @@ mod tests { "0.0.0.0", ] { let mut p = policy(); - p.default_network_policy = NetworkPolicy::Block; p.network_proxy = proxy(host); let msg = validate_seatbelt_network_policy(&p).unwrap_err(); - assert!(msg.contains("non-loopback host"), "{host:?} got: {msg}"); + assert!(msg.contains("loopback endpoint"), "{host:?} got: {msg}"); } } #[test] - fn rejects_allowed_hosts_with_default_block() { - // Seatbelt has no per-host filtering primitive, so an allowlist under a - // deny default cannot be enforced and used to degrade to allow-all. + fn rejects_retired_allowed_hosts() { let mut p = policy(); - p.default_network_policy = NetworkPolicy::Block; p.allowed_hosts = vec!["api.github.com".to_string()]; let msg = validate_seatbelt_network_policy(&p).unwrap_err(); - assert!( - msg.contains("allowedHosts cannot be combined with defaultPolicy='block'"), - "got: {msg}" - ); + assert!(msg.contains("retired network fields"), "got: {msg}"); } #[test] - fn accepts_allowed_hosts_with_builtin_test_proxy() { - // The builtin test proxy is the cooperative-enforcement escape hatch. + fn rejects_retired_allowed_hosts_with_builtin_test_proxy() { let mut p = policy(); - p.default_network_policy = NetworkPolicy::Block; p.allowed_hosts = vec!["api.github.com".to_string()]; p.network_proxy = ProxyConfig { address: None, builtin_test_server: true, }; - assert!(validate_seatbelt_network_policy(&p).is_ok()); + assert!(validate_seatbelt_network_policy(&p) + .unwrap_err() + .contains("retired network fields")); } #[test] fn rejects_allowed_hosts_with_external_proxy() { let mut p = policy(); - p.default_network_policy = NetworkPolicy::Block; p.allowed_hosts = vec!["api.github.com".to_string()]; p.network_proxy = proxy("127.0.0.1"); let msg = validate_seatbelt_network_policy(&p).unwrap_err(); - assert!(msg.contains("allowedHosts"), "got: {msg}"); + assert!(msg.contains("retired network fields"), "got: {msg}"); } #[test] - fn seatbelt_specific_validation_defers_allow_default_allowlist_to_shared_validation() { - // Shared validation rejects this combination before Seatbelt validation. - // This helper owns only the remaining backend-representability checks. + fn seatbelt_rejects_retired_allow_default_and_host_list() { let mut p = policy(); p.default_network_policy = NetworkPolicy::Allow; p.allowed_hosts = vec!["api.github.com".to_string()]; - assert!(validate_seatbelt_network_policy(&p).is_ok()); + assert!(validate_seatbelt_network_policy(&p) + .unwrap_err() + .contains("retired network fields")); } /// `guiAccess` with a `SeatbeltConfig` and the given `ui.disable`. diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index 5c47a3ff0..774c83585 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -43,7 +43,9 @@ use crate::mxc_common::validator::{ }; use crate::mxc_pty::{LivePty, PtySize as UnixPtySize}; -use crate::seatbelt_common::default_env::{env_pairs, resolved_env, DEFAULT_SANDBOX_PATH}; +#[cfg(test)] +use crate::seatbelt_common::default_env::DEFAULT_SANDBOX_PATH; +use crate::seatbelt_common::default_env::{env_pairs, resolved_env}; use crate::seatbelt_common::profile_builder::build_profile_with_proxy; #[link(name = "proc")] @@ -262,7 +264,7 @@ impl SandboxBackend for SeatbeltScriptRunner { fn network_policy_support(&self) -> NetworkPolicySupport { // Seatbelt enforces a single outbound default (no CIDR/port/protocol // rules — `EGRESS_RULES` is intentionally omitted), a single inbound - // default mapped to the existing `allowLocalNetwork` behavior, and the + // default mapped to a local-IP inbound rule, and the // loopback-scoped runtime proxy. It has no per-peer proxy identity // concept (`PROXY_PEER_IDENTITY` is a ProcessContainer-only feature). NetworkPolicySupport::EGRESS_DEFAULT @@ -295,8 +297,7 @@ impl SandboxBackend for SeatbeltScriptRunner { // Start the cooperative network proxy (if configured) before building // the profile and launching the child: the profile's proxy-reachability - // rule is scoped to the proxy's *resolved* address (builtinTestServer - // binds a runtime port), and the child needs that address injected as + // rule is scoped to the proxy's *resolved* address, and the child needs that address injected as // HTTP_PROXY / HTTPS_PROXY. macOS has no WinHTTP-style OS proxy policy, // so — like the Bubblewrap backend — enforcement is cooperative: // well-behaved HTTP clients honor the env vars; raw-socket clients @@ -307,9 +308,9 @@ impl SandboxBackend for SeatbeltScriptRunner { .start( &request.policy.network_proxy, "127.0.0.1", - &request.policy.allowed_hosts, - &request.policy.blocked_hosts, - request.policy.default_network_policy.clone(), + &[], + &[], + crate::mxc_common::models::NetworkPolicy::Block, logger, ) .map_err(|err| { @@ -1229,8 +1230,7 @@ fn build_helper_script( /// Populate `command`'s environment from a cleared baseline: never inherit the /// host environment (matching the bubblewrap `--clearenv` and AppContainer -/// clean-block behaviour). Below schema 0.9 a default `PATH` is set first and -/// the request vars may override it; from 0.9 [`resolved_env`] owns the whole +/// clean-block behaviour). [`resolved_env`] supplies the complete child /// environment. When a proxy is active its `HTTP_PROXY` / `HTTPS_PROXY` / /// `ALL_PROXY` vars are injected and caller-supplied proxy vars stripped (see /// [`resolve_environment`]). `PWD` is set separately alongside the cwd. @@ -1241,11 +1241,6 @@ fn apply_clean_environment( working_directory: Option<&str>, ) { command.env_clear(); - // From 0.9 `resolved_env` carries `PATH`, and an explicitly empty - // `process.env` must stay empty rather than keep a floor under it. - if !request.supplies_default_env() { - command.env("PATH", DEFAULT_SANDBOX_PATH); - } for (key, value) in resolve_environment(request, proxy_address, working_directory) { command.env(key, value); } @@ -1341,8 +1336,7 @@ fn cleanup_files(paths: &[&str]) { mod tests { use super::*; use crate::mxc_common::models::{ - DefaultEnvCompatibility, ExecutionRequest, NetworkAction, NetworkEgressPolicy, - NetworkPolicy, ProxyAddress, SeatbeltConfig, + ExecutionRequest, NetworkAction, NetworkEgressPolicy, ProxyAddress, SeatbeltConfig, }; #[allow(clippy::field_reassign_with_default)] @@ -1389,7 +1383,6 @@ mod tests { // The 0.9 default block must not bypass the proxy stripping in // `resolve_environment`. let mut request = base_request(); - request.default_env_compatibility = DefaultEnvCompatibility::DefaultBlock; request.env = Some(vec!["HTTP_PROXY=http://attacker.example:9999".into()]); request.inherit_default_env = true; let addr = ProxyAddress::new("127.0.0.1".into(), 8888); @@ -1481,55 +1474,34 @@ mod tests { } #[test] - fn rejects_shared_valid_blocked_hosts() { + fn rejects_retired_blocked_hosts_bypassing_the_parser() { let mut request = base_request(); - request.policy.default_network_policy = NetworkPolicy::Allow; request.policy.blocked_hosts = vec!["evil.example.com".into()]; let runner = SeatbeltScriptRunner::new(); let response = runner.validate(&request).unwrap_err(); assert_eq!(response.exit_code, -1); - assert_eq!( - response.error_message, - "macOS Seatbelt does not support per-host network filtering. \ - 'blockedHosts' cannot be enforced; remove it. To deny all \ - network, use defaultPolicy: \"block\" without host lists." - ); + assert!(response.error_message.contains("retired network fields")); } - /// The parser is not a door at all for these rules: `validate` is the only - /// place they live, and `mxc_engine` will happily take an `ExecutionRequest` - /// built by hand. These assert `validate` rejects them without any help from - /// the parser, in both the legacy and directional shape. + /// Directly constructed requests must receive the same rejection as parsed + /// requests, before the profile or any proxy is created. #[test] fn rejects_proxy_with_egress_allow_bypassing_the_parser() { let runner = SeatbeltScriptRunner::new(); - - let mut legacy = base_request(); - legacy.policy.default_network_policy = NetworkPolicy::Allow; - legacy.policy.network_proxy.address = Some(ProxyAddress::from_url( - "http://127.0.0.1:8080", - "127.0.0.1".into(), - 8080, - )); - let err = runner.validate(&legacy).unwrap_err(); - assert!( - err.error_message.contains("no enforcement effect"), - "{err:?}" - ); - - let mut directional = base_request(); - directional.policy.network_egress = Some(NetworkEgressPolicy { + let mut request = base_request(); + request.policy.network_egress = Some(NetworkEgressPolicy { default: NetworkAction::Allow, ..Default::default() }); - directional.policy.network_proxy.address = Some(ProxyAddress::from_url( + request.policy.network_proxy.address = Some(ProxyAddress::from_url( "http://127.0.0.1:8080", "127.0.0.1".into(), 8080, )); - let err = runner.validate(&directional).unwrap_err(); + let err = runner.validate(&request).unwrap_err(); assert!( - err.error_message.contains("no enforcement effect"), + err.error_message + .contains("requires network.egress.default='deny'"), "{err:?}" ); } @@ -1537,27 +1509,27 @@ mod tests { #[test] fn rejects_allowed_hosts_under_deny_bypassing_the_parser() { let mut request = base_request(); - request.policy.default_network_policy = NetworkPolicy::Block; request.policy.allowed_hosts = vec!["example.com".into()]; let runner = SeatbeltScriptRunner::new(); let err = runner.validate(&request).unwrap_err(); - assert!(err.error_message.contains("allowedHosts"), "{err:?}"); + assert!( + err.error_message.contains("retired network fields"), + "{err:?}" + ); } #[test] - fn accepts_allowed_hosts_under_deny_with_builtin_test_server() { + fn rejects_retired_builtin_test_proxy() { let mut request = base_request(); - request.policy.default_network_policy = NetworkPolicy::Block; - request.policy.allowed_hosts = vec!["example.com".into()]; request.policy.network_proxy.builtin_test_server = true; let runner = SeatbeltScriptRunner::new(); - assert!(runner.validate(&request).is_ok()); + let err = runner.validate(&request).unwrap_err(); + assert!(err.error_message.contains("builtinTestServer"), "{err:?}"); } #[test] fn rejects_remote_proxy_under_deny_bypassing_the_parser() { let mut request = base_request(); - request.policy.default_network_policy = NetworkPolicy::Block; request.policy.network_proxy.address = Some(ProxyAddress::from_url( "http://proxy.corp:3128", "proxy.corp".into(), @@ -1576,7 +1548,6 @@ mod tests { let runner = SeatbeltScriptRunner::new(); for host in ["127.0.0.1", "localhost", "[::1]", "::1"] { let mut request = base_request(); - request.policy.default_network_policy = NetworkPolicy::Block; request.policy.network_proxy.address = Some(ProxyAddress::from_url( "http://proxy:8080", host.into(), @@ -1637,11 +1608,9 @@ mod tests { } /// The two divergent-pair decisions must survive the exact-contract parse, - /// not just a hand-built `ContainerPolicy`. The v0.9 cutover rebuilt the - /// network adapter, and an adapter that dropped `ingress` (or folded it - /// into the legacy `allowLocalNetwork` flag) would leave every other test - /// in this module green while the backend silently stopped seeing the - /// posture the caller asked for. + /// not just a hand-built `ContainerPolicy`. An adapter that dropped + /// `ingress` would leave other backend tests green while silently losing + /// the posture the caller asked for. fn validate_parsed(json: &str) -> Result<(), String> { use crate::mxc_common::config_parser::load_mxc_request_from_json; use crate::mxc_common::logger::Mode; @@ -1909,16 +1878,7 @@ mod tests { assert_eq!( env_value(&pairs, "HOME"), None, - "below 0.9 supplies no HOME" - ); - - let mut modern = base_request(); - modern.default_env_compatibility = DefaultEnvCompatibility::DefaultBlock; - let pairs = resolve_environment(&modern, None, None); - assert_eq!( - env_value(&pairs, "HOME"), - None, - "0.9 leaves HOME unset when no directory resolves" + "HOME stays unset when no directory resolves" ); } @@ -1928,7 +1888,6 @@ mod tests { #[test] fn home_follows_the_directory_the_child_starts_in() { let mut request = base_request(); - request.default_env_compatibility = DefaultEnvCompatibility::DefaultBlock; let pairs = resolve_environment(&request, None, Some("/Users/someone/work")); assert_eq!(env_value(&pairs, "HOME"), Some("/Users/someone/work")); } diff --git a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/one_shot.rs b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/one_shot.rs index 4077ce685..91f6fc6f8 100644 --- a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/one_shot.rs +++ b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/one_shot.rs @@ -129,7 +129,7 @@ impl WindowsSandboxRunner { impl ScriptRunner for WindowsSandboxRunner { fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - validate_network_policy_support(request, NetworkPolicySupport::LEGACY)?; + validate_network_policy_support(request, NetworkPolicySupport::default())?; Ok(()) } diff --git a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/policy.rs b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/policy.rs index 77e65ac33..aa05523c9 100644 --- a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/policy.rs +++ b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/policy.rs @@ -10,7 +10,9 @@ use std::path::Path; -use crate::mxc_common::models::{ExecutionRequest, NetworkPolicy}; +use crate::mxc_common::models::{ + ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, +}; use crate::windows_sandbox_lifecycle::error::OneShotError; use crate::windows_sandbox_lifecycle::vm::MappedFolder; @@ -30,9 +32,8 @@ pub(crate) fn plan_policy(request: &ExecutionRequest) -> Result Result<(), OneShotError> { let policy = &request.policy; @@ -49,14 +50,33 @@ fn validate_network(request: &ExecutionRequest) -> Result<(), OneShotError> { )); } - match policy.default_network_policy { - NetworkPolicy::Block => Ok(()), - NetworkPolicy::Allow => Err(OneShotError::Policy( - "outbound network access (network policy 'allow') is not supported by the Windows \ - Sandbox backend; the guest agent enforces network isolation" + if policy.default_network_policy != NetworkPolicy::Block + || policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities + || policy.allow_local_network + { + return Err(OneShotError::Policy( + "retired network fields are not supported by the Windows Sandbox backend; use \ + network.egress and network.ingress" + .to_string(), + )); + } + if policy.network_mode_specified + || policy.network_egress.as_ref().is_some_and(|egress| { + egress.default == NetworkAction::Allow + || !egress.allow.is_empty() + || !egress.deny.is_empty() + }) + || policy.network_ingress.as_ref().is_some_and(|ingress| { + ingress.default == NetworkAction::Allow || ingress.host_loopback == NetworkAction::Allow + }) + { + return Err(OneShotError::Policy( + "directional network policy is not supported by the Windows Sandbox backend; \ + the guest agent enforces network isolation" .to_string(), - )), + )); } + Ok(()) } /// A mapped root in normalized form: the cleaned absolute string used for the @@ -295,7 +315,10 @@ fn is_descendant(child: &[String], ancestor: &[String]) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::{ContainerPolicy, ProxyAddress, ProxyConfig}; + use crate::mxc_common::models::{ + ContainerPolicy, NetworkEgressPolicy, NetworkIngressPolicy, NetworkPeer, NetworkRule, + ProxyAddress, ProxyConfig, + }; fn request_with(policy: ContainerPolicy) -> ExecutionRequest { ExecutionRequest { @@ -313,23 +336,62 @@ mod tests { } } + fn assert_rejects_direct_network(policy: ContainerPolicy) { + assert!(!policy.network_mode_specified); + let err = plan_policy(&request_with(policy)).unwrap_err(); + assert_policy_err_contains(err, "directional network policy"); + } + + fn sample_rule() -> NetworkRule { + NetworkRule { + to: vec![NetworkPeer { + cidr: "192.0.2.1/32".parse().unwrap(), + except: Vec::new(), + }], + ..Default::default() + } + } + // ===== network ===== #[test] - fn default_policy_blocks_network_and_maps_nothing() { - // Schema default is Block, which is honored natively (guest enforces). + fn omitted_network_defaults_to_guest_firewall_isolation() { let plan = plan_policy(&ExecutionRequest::default()).unwrap(); assert!(plan.mapped_folders.is_empty()); } + #[test] + fn explicitly_supplied_network_posture_is_not_silently_ignored() { + let err = plan_policy(&request_with(ContainerPolicy { + network_mode_specified: true, + network_egress: Some(NetworkEgressPolicy::default()), + ..Default::default() + })) + .unwrap_err(); + assert_policy_err_contains(err, "directional network policy"); + } + #[test] fn allow_network_rejected() { + let err = plan_policy(&request_with(ContainerPolicy { + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), + ..Default::default() + })) + .unwrap_err(); + assert_policy_err_contains(err, "directional network policy"); + } + + #[test] + fn retired_outbound_default_is_rejected() { let err = plan_policy(&request_with(ContainerPolicy { default_network_policy: NetworkPolicy::Allow, ..Default::default() })) .unwrap_err(); - assert_policy_err_contains(err, "outbound network access"); + assert_policy_err_contains(err, "retired network fields"); } #[test] @@ -352,6 +414,50 @@ mod tests { assert_policy_err_contains(err, "per-host network filtering"); } + #[test] + fn direct_egress_allow_rule_rejected_without_presence_flag() { + assert_rejects_direct_network(ContainerPolicy { + network_egress: Some(NetworkEgressPolicy { + allow: vec![sample_rule()], + ..Default::default() + }), + ..Default::default() + }); + } + + #[test] + fn direct_egress_deny_rule_rejected_without_presence_flag() { + assert_rejects_direct_network(ContainerPolicy { + network_egress: Some(NetworkEgressPolicy { + deny: vec![sample_rule()], + ..Default::default() + }), + ..Default::default() + }); + } + + #[test] + fn direct_ingress_allow_rejected_without_presence_flag() { + assert_rejects_direct_network(ContainerPolicy { + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), + ..Default::default() + }); + } + + #[test] + fn direct_host_loopback_allow_rejected_without_presence_flag() { + assert_rejects_direct_network(ContainerPolicy { + network_ingress: Some(NetworkIngressPolicy { + host_loopback: NetworkAction::Allow, + ..Default::default() + }), + ..Default::default() + }); + } + #[test] fn enumerate_paths_rejected() { let err = plan_policy(&request_with(ContainerPolicy { diff --git a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs index 63f04948a..94bdd46d0 100644 --- a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs +++ b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs @@ -226,6 +226,10 @@ fn reject_post_provision_policy(request: &ExecutionRequest) -> Result<(), MxcErr || !p.denied_paths.is_empty() || !p.allowed_hosts.is_empty() || !p.blocked_hosts.is_empty() + || p.default_network_policy != crate::mxc_common::models::NetworkPolicy::Block + || p.network_enforcement_mode + != crate::mxc_common::models::NetworkEnforcementMode::Capabilities + || p.allow_local_network || p.network_proxy.is_enabled() || p.network_mode_specified || p.network_egress.is_some() @@ -1013,7 +1017,7 @@ impl StatefulSandboxBackend for WindowsSandboxRunner { request: &ExecutionRequest, _config: Option<&()>, ) -> Result<(), MxcError> { - validate_state_aware_network_policy_support(request, NetworkPolicySupport::LEGACY)?; + validate_state_aware_network_policy_support(request, NetworkPolicySupport::default())?; policy::plan_policy(request) .map(|_| ()) .map_err(map_policy_error) diff --git a/src/mxc-sdk/src/backends/wslc/common/policy.rs b/src/mxc-sdk/src/backends/wslc/common/policy.rs index 703b16b8d..156a2f5b9 100644 --- a/src/mxc-sdk/src/backends/wslc/common/policy.rs +++ b/src/mxc-sdk/src/backends/wslc/common/policy.rs @@ -13,13 +13,9 @@ //! | `readwrite` / `readonly` | honoured (volume mounts) | rejected (immutable) | rejected (immutable) | //! | `denied_paths` | rejected if overlapping [^1] | rejected | rejected | //! | `ui` | rejected (no UI primitive) | rejected | rejected | -//! | `allowed` / `blocked` hosts | rejected (no host filtering) | rejected | rejected | -//! | `allow_local_network` | rejected if `true` | rejected | rejected | -//! | `network_enforcement_mode` | rejected if not `capabilities` | rejected | rejected | //! | `network.egress.default` | honoured (None / Bridged) | rejected (immutable) | rejected (immutable) | //! | `network.ingress.*` | must match egress (deny all / allow all) | rejected | rejected | //! | `runtimeConfig.networkProxy` | rejected (applies at exec) | rejected | honoured (cooperative env) | -//! | legacy `default_network_policy` / `network.proxy` | compatibility inputs only | rejected | proxy URL only | //! //! [^1]: a standalone `denied_path` is honoured by container isolation (unlisted //! host paths are simply never mounted); only a denied path nested under a @@ -28,9 +24,8 @@ //! //! Checks run filesystem → ui → network, so a request that trips several gets a //! stable message rather than one that depends on field order (asserted by -//! tests). [`reject_ui_policy`] and [`reject_unsupported_enforcement_mode`] -//! describe the backend rather than a phase, so the one-shot `validate_runner` -//! calls them too. +//! tests). [`reject_ui_policy`] and [`reject_retired_network_fields`] apply +//! to both one-shot and state-aware validation. use crate::mxc_common::models::{ ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, @@ -46,36 +41,23 @@ const ERR_FILESYSTEM_IMMUTABLE: &str = phase and cannot be changed by the WSLc backend after provisioning"; const ERR_ENUMERATE_PATHS: &str = "processContainer.filesystem.enumeratePaths is not supported by the WSLc backend"; -const ERR_HOST_FILTERING: &str = - "per-host network filtering (allowedHosts / blockedHosts) is not supported by the WSLc backend"; const ERR_NETWORK_IMMUTABLE: &str = "network mode is bound to the provision phase and cannot be changed by the WSLc backend after \ provisioning"; const ERR_PROXY_AT_PROVISION: &str = - "runtimeConfig.networkProxy (legacy network.proxy) is applied per-exec by the WSLc backend; set it on the exec phase, not provision"; + "runtimeConfig.networkProxy is applied per-exec by the WSLc backend; set it on the exec phase, not provision"; const ERR_PROXY_AT_PHASE: &str = - "runtimeConfig.networkProxy (legacy network.proxy) is only honoured on the exec phase by the WSLc backend"; + "runtimeConfig.networkProxy is only honoured on the exec phase by the WSLc backend"; const ERR_PROXY_URL_FORM: &str = - "WSLc: network.proxy requires the 'url' form (a routable proxy URL); the localhost and \ - builtinTestServer forms are not supported because a WSL container runs in its own network \ - namespace"; + "WSLc: runtimeConfig.networkProxy requires a proxy url reachable from inside the container; \ + a host-loopback proxy is not reachable from the container's own network namespace"; const ERR_UI_POLICY: &str = "WSLc: the ui section is not supported. The backend has no mechanism to enforce UI \ restrictions on a container, so no ui posture is truthful here. Omitting the ui section is \ accepted but applies no restriction — it is not the lockdown the schema's default implies. \ Use a backend that enforces UI policy if you need one"; -const ERR_ALLOW_LOCAL_NETWORK_STATE_AWARE: &str = - "WSLc: network.allowLocalNetwork=true is not supported by the state-aware WSLc backend. The \ - container's network is all-or-nothing (defaultPolicy 'block' → isolated, 'allow' → bridged \ - NAT), and the state-aware provision phase has no port-mapping primitive to expose an \ - inbound port"; -const ERR_ENFORCEMENT_MODE: &str = - "WSLc: network.enforcementMode 'firewall' and 'both' are not supported. A WSL container has \ - no CAP_NET_ADMIN for in-container firewall rules, and VM-level enforcement is not available \ - without breaking other security guarantees (e.g. MDE). Remove the field or set it to \ - 'capabilities' — WSLc's network is all-or-nothing at the container level"; const ERR_PROXY_CREDENTIALS_IN_ARGV: &str = - "WSLc: runtimeConfig.networkProxy (legacy network.proxy) must not carry credentials when \ + "WSLc: runtimeConfig.networkProxy must not carry credentials when \ process.env is supplied without process.inheritDefaultEnv. That combination replaces the \ container image's environment, which WSLc performs by prefixing the command line with \ 'env -i NAME=VALUE', so the proxy URL becomes a process argument readable through \ @@ -92,12 +74,13 @@ pub(crate) fn network_policy_support() -> NetworkPolicySupport { | NetworkPolicySupport::RUNTIME_PROXY } -/// Read the authoritative directional posture, falling back only for legacy input. +/// Read the directional posture; absent egress defaults to deny. pub(crate) fn network_is_isolated(request: &ExecutionRequest) -> bool { - request.policy.network_egress.as_ref().map_or_else( - || request.policy.default_network_policy == NetworkPolicy::Block, - |egress| egress.default == NetworkAction::Deny, - ) + request + .policy + .network_egress + .as_ref() + .is_none_or(|egress| egress.default == NetworkAction::Deny) } /// No firewall is installed inside or outside a WSLc container. NONE denies all @@ -140,7 +123,7 @@ pub(crate) fn validate_directional_network(request: &ExecutionRequest) -> Result } /// Validate the request for the provision phase. `rw` / `ro` paths become -/// volume mounts and `default_network_policy` selects the container network +/// volume mounts and `network.egress.default` selects the container network /// mode; both are honoured here. Everything else in the module table is /// rejected. pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> { @@ -154,9 +137,7 @@ pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<() ) .map_err(MxcError::policy_validation)?; reject_ui_policy(request)?; - reject_host_filtering(request)?; - reject_provision_allow_local_network(request)?; - reject_unsupported_enforcement_mode(request)?; + reject_retired_network_fields(request)?; validate_directional_network(request)?; if request.policy.network_proxy.is_enabled() { return Err(MxcError::policy_validation(ERR_PROXY_AT_PROVISION)); @@ -170,7 +151,7 @@ pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<() pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> { reject_filesystem_policy(request)?; reject_ui_policy(request)?; - reject_host_filtering(request)?; + reject_retired_network_fields(request)?; reject_post_provision_network_mode(request)?; if request.policy.network_proxy.is_enabled() { return Err(MxcError::policy_validation(ERR_PROXY_AT_PHASE)); @@ -184,7 +165,7 @@ pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Resu pub(crate) fn validate_exec_policy(request: &ExecutionRequest) -> Result<(), MxcError> { reject_filesystem_policy(request)?; reject_ui_policy(request)?; - reject_host_filtering(request)?; + reject_retired_network_fields(request)?; reject_post_provision_network_mode(request)?; if request.policy.network_proxy.is_enabled() && exec_proxy_url(request).is_none() { return Err(MxcError::policy_validation(ERR_PROXY_URL_FORM)); @@ -240,13 +221,6 @@ fn reject_filesystem_policy(request: &ExecutionRequest) -> Result<(), MxcError> Ok(()) } -fn reject_host_filtering(request: &ExecutionRequest) -> Result<(), MxcError> { - if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { - return Err(MxcError::policy_validation(ERR_HOST_FILTERING)); - } - Ok(()) -} - /// Reject any supplied UI policy: WSLc cannot enforce UI restrictions. pub(crate) fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), MxcError> { if request.policy.ui_specified { @@ -255,34 +229,33 @@ pub(crate) fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), MxcErro Ok(()) } -/// Reject an enforcement mode WSLc cannot implement. Value-based: an explicit -/// `capabilities` is accepted, since it describes what WSLc does. -pub(crate) fn reject_unsupported_enforcement_mode( - request: &ExecutionRequest, -) -> Result<(), MxcError> { - match request.policy.network_enforcement_mode { - NetworkEnforcementMode::Capabilities => Ok(()), - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both => { - Err(MxcError::policy_validation(ERR_ENFORCEMENT_MODE)) - } - } -} - -/// Reject inbound local networking at provision. Separate from the one-shot -/// message, which points at `wslc.portMappings` — a primitive the -/// state-aware surface does not have. -fn reject_provision_allow_local_network(request: &ExecutionRequest) -> Result<(), MxcError> { - if request.policy.allow_local_network { - return Err(MxcError::policy_validation( - ERR_ALLOW_LOCAL_NETWORK_STATE_AWARE, - )); +/// Reject retired runtime fields before backend provisioning or execution. +pub(crate) fn reject_retired_network_fields(request: &ExecutionRequest) -> Result<(), MxcError> { + let policy = &request.policy; + let field = if policy.default_network_policy != NetworkPolicy::Block { + Some("network.defaultPolicy") + } else if policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities { + Some("network.enforcementMode") + } else if policy.allow_local_network { + Some("network.allowLocalNetwork") + } else if !policy.allowed_hosts.is_empty() { + Some("network.allowedHosts") + } else if !policy.blocked_hosts.is_empty() { + Some("network.blockedHosts") + } else { + None + }; + if let Some(field) = field { + return Err(MxcError::policy_validation(format!( + "WSLc: {field} is retired; use network.egress and network.ingress" + ))); } Ok(()) } /// Reject any network *mode* field supplied after provision: the posture is /// bound to the provision phase. Presence, not value — an explicit -/// `defaultPolicy: "block"` is indistinguishable from an omitted one by value. +/// `egress.default: "deny"` must still be rejected. /// The cooperative proxy is a separate exec-time concern handled by the callers. fn reject_post_provision_network_mode(request: &ExecutionRequest) -> Result<(), MxcError> { if request.policy.network_mode_specified @@ -298,7 +271,8 @@ fn reject_post_provision_network_mode(request: &ExecutionRequest) -> Result<(), mod tests { use super::*; use crate::mxc_common::models::{ - ContainerPolicy, NetworkPolicy, ProxyAddress, ProxyConfig, UiPolicy, + ContainerPolicy, NetworkEgressPolicy, NetworkIngressPolicy, ProxyAddress, ProxyConfig, + UiPolicy, }; use crate::mxc_common::mxc_error::MxcErrorCode; @@ -481,7 +455,14 @@ mod tests { let req = request_with_policy(ContainerPolicy { readwrite_paths: vec!["C:\\src".to_string()], readonly_paths: vec!["C:\\data".to_string()], - default_network_policy: NetworkPolicy::Allow, + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Allow, + }), ..Default::default() }); validate_provision_policy(&req).unwrap(); @@ -689,7 +670,7 @@ mod tests { /// the field sets `network_mode_specified`, which immutability already /// refuses. Pinned so both rejections can't be dropped as "redundant". #[test] - fn post_provision_rejects_allow_local_network_as_a_mode_change() { + fn post_provision_rejects_retired_allow_local_network() { let req = request_with_policy(ContainerPolicy { allow_local_network: true, network_mode_specified: true, @@ -697,9 +678,9 @@ mod tests { }); assert_policy_validation( validate_post_provision_policy(&req).unwrap_err(), - "network mode", + "allowLocalNetwork", ); - assert_policy_validation(validate_exec_policy(&req).unwrap_err(), "network mode"); + assert_policy_validation(validate_exec_policy(&req).unwrap_err(), "allowLocalNetwork"); } // ---- enforcementMode ---- diff --git a/src/mxc-sdk/src/backends/wslc/common/policy_mapping.rs b/src/mxc-sdk/src/backends/wslc/common/policy_mapping.rs index a39a57dfd..d26035b9e 100644 --- a/src/mxc-sdk/src/backends/wslc/common/policy_mapping.rs +++ b/src/mxc-sdk/src/backends/wslc/common/policy_mapping.rs @@ -466,121 +466,15 @@ fn validate_denied_path_overlap_with( /// - `None` — no network interface, fully isolated /// - `Bridged` — NAT networking through the WSL2 VM's virtual adapter /// -/// Per-host filtering (`allowedHosts`/`blockedHosts` that need enforcement) is -/// rejected before this runs — see `build_iptables_rules`. This only maps the -/// bare-default posture: -/// -/// - `Block` with no host rules → `None` (fully isolated) -/// - `Allow` → `Bridged` (NAT) -pub fn map_network_policy(is_block: bool, has_host_rules: bool) -> WslcContainerNetworkingMode { - if is_block && !has_host_rules { +/// Directional egress selects the all-or-nothing network mode. +pub fn map_network_policy(is_block: bool) -> WslcContainerNetworkingMode { + if is_block { WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_NONE } else { WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_BRIDGED } } -/// Returns true if the policy requests per-host filtering (which WSLc cannot -/// enforce — such configs are rejected before execution). -/// -/// Thin wrapper over [`crate::mxc_common::models::needs_host_filtering`] so the parser -/// and this backend share one definition: -/// - `Block` → only `allowed_hosts` matter (allowlist) -/// - `Allow` → only `blocked_hosts` matter (blocklist) -pub fn needs_host_filtering( - is_default_block: bool, - allowed_hosts: &[String], - blocked_hosts: &[String], -) -> bool { - crate::mxc_common::models::needs_host_filtering(is_default_block, allowed_hosts, blocked_hosts) -} - -/// Validate that a host string is safe for use in an iptables command. -/// Accepts hostnames (a-z, 0-9, dots, hyphens) and IPv4/IPv6 addresses -/// (digits, dots, colons, brackets, slash for CIDR). -/// Rejects empty strings and anything containing shell metacharacters. -fn is_valid_host(host: &str) -> bool { - !host.is_empty() - && host - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b".-:[]/_".contains(&b)) -} - -/// Build iptables commands for per-host network filtering. -/// -/// **NOTE: WSLC per-host filtering is non-functional.** Two independent -/// blockers prevent it from working: -/// 1. WSLC containers lack `CAP_NET_ADMIN` — the SDK's `Privileged` flag does -/// **not** grant it — so the in-container `iptables` exec is rejected. -/// 2. WSLC cannot expose VM-level network enforcement without breaking other -/// security guarantees (e.g. MDE); a host-enforced design is longer-tail. -/// -/// Configs that require per-host filtering are therefore **rejected at -/// config-parse time** (and by `WSLContainerRunner::validate_runner`) before -/// this function is reached. This function is retained for reference but its -/// output is never applied. -/// -/// When `defaultPolicy` is `Block` + `allowedHosts`: -/// - Default DROP all outbound -/// - ACCEPT to each allowed host -/// - ACCEPT established/related (for return traffic) -/// - ACCEPT loopback -/// -/// When `defaultPolicy` is `Allow` + `blockedHosts`: -/// - DROP to each blocked host -/// -/// Returns a shell command string to be exec'd inside the container. -/// -/// Host values are validated to prevent shell command injection. -pub fn build_iptables_rules( - allowed_hosts: &[String], - blocked_hosts: &[String], - is_default_block: bool, -) -> Option { - if allowed_hosts.is_empty() && blocked_hosts.is_empty() { - return None; - } - - let mut rules = Vec::new(); - - if is_default_block && !allowed_hosts.is_empty() { - // Allow loopback and established connections first - rules.push("iptables -A OUTPUT -o lo -j ACCEPT".to_string()); - rules.push("iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT".to_string()); - - // Allow DNS (needed to resolve hostnames) - rules.push("iptables -A OUTPUT -p udp --dport 53 -j ACCEPT".to_string()); - rules.push("iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT".to_string()); - - // Allow each specified host - for host in allowed_hosts { - if !is_valid_host(host) { - continue; - } - rules.push(format!("iptables -A OUTPUT -d {} -j ACCEPT", host)); - } - - // Default drop everything else - rules.push("iptables -A OUTPUT -j DROP".to_string()); - } else if !is_default_block && !blocked_hosts.is_empty() { - // Block specific hosts - for host in blocked_hosts { - if !is_valid_host(host) { - continue; - } - rules.push(format!("iptables -A OUTPUT -d {} -j DROP", host)); - } - } - - if rules.is_empty() { - None - } else { - // Join with shell && so each rule must succeed before the next runs. - // If any iptables command fails, the chain stops and the error propagates. - Some(rules.join(" && ")) - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1190,59 +1084,21 @@ mod tests { // -- Network policy tests -- #[test] - fn network_block_no_hosts_maps_to_none() { + fn denied_egress_maps_to_none() { assert_eq!( - map_network_policy(true, false), + map_network_policy(true), WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_NONE ); } #[test] - fn network_block_with_hosts_maps_to_bridged() { + fn allowed_egress_maps_to_bridged() { assert_eq!( - map_network_policy(true, true), + map_network_policy(false), WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_BRIDGED ); } - #[test] - fn network_allow_maps_to_bridged() { - assert_eq!( - map_network_policy(false, false), - WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_BRIDGED - ); - } - - // -- Host filtering tests -- - - #[test] - fn needs_host_filtering_empty() { - assert!(!needs_host_filtering(true, &[], &[])); - assert!(!needs_host_filtering(false, &[], &[])); - } - - #[test] - fn needs_host_filtering_block_with_allowed() { - assert!(needs_host_filtering(true, &["1.2.3.4".to_string()], &[])); - } - - #[test] - fn needs_host_filtering_allow_with_blocked() { - assert!(needs_host_filtering(false, &[], &["evil.com".to_string()])); - } - - #[test] - fn needs_host_filtering_block_with_blocked_only_is_false() { - // block + blockedHosts makes no sense — blocking is already the default - assert!(!needs_host_filtering(true, &[], &["evil.com".to_string()])); - } - - #[test] - fn needs_host_filtering_allow_with_allowed_only_is_false() { - // allow + allowedHosts makes no sense — everything is already allowed - assert!(!needs_host_filtering(false, &["1.2.3.4".to_string()], &[])); - } - // -- Path edge case tests -- #[test] @@ -1259,76 +1115,4 @@ mod tests { Some("/mnt/c".to_string()) ); } - - #[test] - fn iptables_none_when_no_hosts() { - assert!(build_iptables_rules(&[], &[], true).is_none()); - assert!(build_iptables_rules(&[], &[], false).is_none()); - } - - #[test] - fn iptables_block_with_allowed_hosts() { - let rules = build_iptables_rules( - &["1.2.3.4".to_string(), "example.com".to_string()], - &[], - true, - ) - .unwrap(); - assert!(rules.contains("iptables -A OUTPUT -o lo -j ACCEPT")); - assert!(rules.contains("iptables -A OUTPUT -d 1.2.3.4 -j ACCEPT")); - assert!(rules.contains("iptables -A OUTPUT -d example.com -j ACCEPT")); - assert!(rules.contains("iptables -A OUTPUT -j DROP")); - } - - #[test] - fn iptables_allow_with_blocked_hosts() { - let rules = build_iptables_rules( - &[], - &["evil.com".to_string(), "10.0.0.1".to_string()], - false, - ) - .unwrap(); - assert!(rules.contains("iptables -A OUTPUT -d evil.com -j DROP")); - assert!(rules.contains("iptables -A OUTPUT -d 10.0.0.1 -j DROP")); - assert!(!rules.contains("-j ACCEPT")); - } - - #[test] - fn is_valid_host_accepts_valid_entries() { - assert!(is_valid_host("example.com")); - assert!(is_valid_host("192.168.1.1")); - assert!(is_valid_host("10.0.0.0/8")); - assert!(is_valid_host("my-host.example.com")); - assert!(is_valid_host("::1")); - assert!(is_valid_host("[::1]")); - assert!(is_valid_host("2001:db8::1")); - } - - #[test] - fn is_valid_host_rejects_injection() { - assert!(!is_valid_host("")); - assert!(!is_valid_host("; rm -rf /")); - assert!(!is_valid_host("host && echo pwned")); - assert!(!is_valid_host("host | cat /etc/passwd")); - assert!(!is_valid_host("$(whoami)")); - assert!(!is_valid_host("host`id`")); - assert!(!is_valid_host("host name with spaces")); - } - - #[test] - fn iptables_skips_invalid_hosts() { - let rules = build_iptables_rules( - &[], - &[ - "good.com".to_string(), - "; rm -rf /".to_string(), - "10.0.0.1".to_string(), - ], - false, - ) - .unwrap(); - assert!(rules.contains("good.com")); - assert!(rules.contains("10.0.0.1")); - assert!(!rules.contains("rm")); - } } diff --git a/src/mxc-sdk/src/backends/wslc/common/process_env.rs b/src/mxc-sdk/src/backends/wslc/common/process_env.rs index ed995f9bb..5f8c1b86b 100644 --- a/src/mxc-sdk/src/backends/wslc/common/process_env.rs +++ b/src/mxc-sdk/src/backends/wslc/common/process_env.rs @@ -88,11 +88,9 @@ pub fn sdk_entries(scope: EnvScope, entries: &[String]) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::DefaultEnvCompatibility; fn request(env: Option>, inherit_default_env: bool) -> ExecutionRequest { ExecutionRequest { - default_env_compatibility: DefaultEnvCompatibility::DefaultBlock, env: env.map(|e| e.into_iter().map(String::from).collect()), inherit_default_env, ..Default::default() diff --git a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs index 4edc796cc..3a50b42f2 100644 --- a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs @@ -22,8 +22,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::mxc_common::logger::{Logger, Mode}; -#[cfg(test)] -use crate::mxc_common::models::NetworkPolicy; use crate::mxc_common::models::{ContainerPolicy, ExecutionRequest, WslcProvisionConfig}; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::state_aware_backend::{ @@ -526,6 +524,7 @@ fn build_provision_config( request: &ExecutionRequest, config: Option, ) -> Result { + crate::wslc_common::policy::reject_retired_network_fields(request)?; let image = config .as_ref() .and_then(|c| c.image.clone()) @@ -626,8 +625,8 @@ fn split_env(env: &[String]) -> Vec<(String, String)> { mod tests { use super::*; use crate::mxc_common::models::{ - ContainerPolicy, DefaultEnvCompatibility, NetworkAction, NetworkEgressPolicy, - NetworkIngressPolicy, ProxyAddress, ProxyConfig, + ContainerPolicy, NetworkAction, NetworkEgressPolicy, NetworkIngressPolicy, ProxyAddress, + ProxyConfig, }; /// The exit code is unrecoverable once dropped, so failing the call must not @@ -846,13 +845,7 @@ mod tests { #[test] fn map_network_maps_block_to_none() { - let req = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - ..Default::default() - }, - ..Default::default() - }; + let req = ExecutionRequest::default(); assert_eq!(map_network(&req), NetworkMode::None); } @@ -860,7 +853,10 @@ mod tests { fn map_network_maps_allow_to_bridged() { let req = ExecutionRequest { policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), ..Default::default() }, ..Default::default() @@ -1115,7 +1111,6 @@ mod tests { fn the_exec_config_carries_the_scope_each_state_of_process_env_selects() { struct Case { label: &'static str, - compatibility: DefaultEnvCompatibility, env: Option>, inherit_default_env: bool, scope: EnvScope, @@ -1125,7 +1120,6 @@ mod tests { let cases = [ Case { label: "omitted takes the image environment", - compatibility: DefaultEnvCompatibility::DefaultBlock, env: None, inherit_default_env: false, scope: EnvScope::Merge, @@ -1133,7 +1127,6 @@ mod tests { }, Case { label: "explicitly empty leaves the child nothing", - compatibility: DefaultEnvCompatibility::DefaultBlock, env: Some(vec![]), inherit_default_env: false, scope: EnvScope::Replace, @@ -1141,7 +1134,6 @@ mod tests { }, Case { label: "explicitly empty plus inheritDefaultEnv takes the image environment", - compatibility: DefaultEnvCompatibility::DefaultBlock, env: Some(vec![]), inherit_default_env: true, scope: EnvScope::Merge, @@ -1149,7 +1141,6 @@ mod tests { }, Case { label: "supplied is used verbatim", - compatibility: DefaultEnvCompatibility::DefaultBlock, env: Some(vec!["FOO=bar"]), inherit_default_env: false, scope: EnvScope::Replace, @@ -1157,7 +1148,6 @@ mod tests { }, Case { label: "supplied plus inheritDefaultEnv layers over the image environment", - compatibility: DefaultEnvCompatibility::DefaultBlock, env: Some(vec!["FOO=bar"]), inherit_default_env: true, scope: EnvScope::Merge, @@ -1167,7 +1157,6 @@ mod tests { for case in cases { let request = ExecutionRequest { - default_env_compatibility: case.compatibility, env: case.env.map(|e| e.into_iter().map(String::from).collect()), inherit_default_env: case.inherit_default_env, script_code: "echo hi".to_string(), @@ -1196,7 +1185,6 @@ mod tests { #[test] fn the_exec_config_keeps_the_cooperative_proxy_out_of_the_callers_reach() { let request = ExecutionRequest { - default_env_compatibility: DefaultEnvCompatibility::DefaultBlock, env: Some(vec![ "FOO=bar".to_string(), "HTTP_PROXY=http://attacker.invalid:1".to_string(), diff --git a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs index 12727e6c9..faa567d4d 100644 --- a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs +++ b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs @@ -432,27 +432,7 @@ impl ScriptRunner for WSLContainerRunner { policy_mapping::container_working_directory(&request.working_directory) .map_err(|msg| WslcError::Rejected(msg).into_response())?; policy::reject_ui_policy(request).map_err(as_wslc_rejection)?; - if request.policy.needs_host_filtering() { - return Err(WslcError::Rejected( - "WSLc: per-host egress filtering (allowedHosts with \ - defaultPolicy='block', or blockedHosts with defaultPolicy='allow') \ - is not supported. A WSL container has no CAP_NET_ADMIN for in-container \ - iptables, and VM-level enforcement is not available without breaking other \ - security guarantees (e.g. MDE). Use network.proxy (defaultPolicy='allow') \ - for cooperative host filtering, or remove the host lists." - .to_string(), - ) - .into_response()); - } - if request.policy.allow_local_network { - return Err(WslcError::Rejected( - "WSLc: network.allowLocalNetwork=true is not supported. Expose specific \ - ports with wslc portMappings instead." - .to_string(), - ) - .into_response()); - } - policy::reject_unsupported_enforcement_mode(request).map_err(as_wslc_rejection)?; + policy::reject_retired_network_fields(request).map_err(as_wslc_rejection)?; // The shared validator returns an untagged response; retag it so its // rejections reach SDK callers as `policy_validation` like the checks above. validate_network_policy_support(request, policy::network_policy_support()) @@ -600,105 +580,6 @@ impl WSLContainerRunner { )) } - /// Apply iptables rules inside a running container for host filtering. - /// - /// # Safety - /// `sdk` must contain valid function pointers and `container` must be a - /// live container handle for a started container. - unsafe fn apply_iptables_rules( - sdk: &'static WslcSdk, - container: WslcContainer, - ipt_cmd: &str, - logger: &mut Logger, - ) -> Result<(), ScriptResponse> { - let _ = writeln!(logger, "[WSLC] Applying iptables rules for host filtering"); - let mut ipt_settings = std::mem::zeroed::(); - let hr = sdk.WslcInitProcessSettings(&mut ipt_settings); - if hr != S_OK { - return Err(sdk_error( - "WslcInitProcessSettings (iptables) failed", - hr, - "", - )); - } - - let ipt_sh = b"/bin/sh\0"; - let ipt_c = b"-c\0"; - let ipt_script = format!("{}\0", ipt_cmd); - let ipt_script_bytes = ipt_script.as_bytes(); - let ipt_argv: [PCSTR; 3] = [ - ipt_sh.as_ptr() as PCSTR, - ipt_c.as_ptr() as PCSTR, - ipt_script_bytes.as_ptr() as PCSTR, - ]; - let hr = - sdk.WslcSetProcessSettingsCmdLine(&mut ipt_settings, ipt_argv.as_ptr(), ipt_argv.len()); - if hr != S_OK { - return Err(sdk_error( - "WslcSetProcessSettingsCmdLine (iptables) failed", - hr, - "", - )); - } - - let mut ipt_process: WslcProcess = ptr::null_mut(); - let mut err_msg = CoTaskMemPWSTR::null(); - let hr = sdk.WslcCreateContainerProcess( - container, - &mut ipt_settings, - &mut ipt_process, - err_msg.as_mut_ptr(), - ); - if hr != S_OK { - let msg = err_msg.to_string_lossy(); - return Err(sdk_error("Failed to exec iptables rules", hr, &msg)); - } - let ipt_guard = WslcProcessGuard::from_raw(ipt_process, sdk.release_process_fn()); - - // Wait for iptables to complete - let mut ipt_exit_event: HANDLE = ptr::null_mut(); - let hr = sdk.WslcGetProcessExitEvent(ipt_guard.as_raw(), &mut ipt_exit_event); - if hr != S_OK { - return Err(sdk_error( - "WslcGetProcessExitEvent (iptables) failed", - hr, - "", - )); - } - if !ipt_exit_event.is_null() { - let wait_result = windows::Win32::System::Threading::WaitForSingleObject( - windows::Win32::Foundation::HANDLE(ipt_exit_event), - 30_000, - ); - if wait_result == windows::Win32::Foundation::WAIT_TIMEOUT { - return Err( - WslcError::Runtime("iptables rules timed out after 30s".to_string()) - .into_response(), - ); - } - } - - let mut ipt_exit_code: i32 = -1; - let hr = sdk.WslcGetProcessExitCode(ipt_guard.as_raw(), &mut ipt_exit_code); - if hr != S_OK { - return Err(sdk_error( - "WslcGetProcessExitCode (iptables) failed", - hr, - "", - )); - } - if ipt_exit_code != 0 { - return Err(WslcError::Runtime(format!( - "iptables rules failed with exit code {} \ - (image may not have iptables installed)", - ipt_exit_code - )) - .into_response()); - } - let _ = writeln!(logger, "[WSLC] iptables rules applied successfully"); - Ok(()) - } - /// Wait for process exit with timeout enforcement. /// Returns (exit_code, timed_out). /// @@ -918,6 +799,7 @@ impl WSLContainerRunner { logger: &mut Logger, output: OutputMode, ) -> Result { + policy::reject_retired_network_fields(request).map_err(as_wslc_rejection)?; let _ = writeln!(logger, "{START_CONTAINER_BANNER}"); // WSLc provision-time filesystem-policy gate (D6 normalization → D3 @@ -1192,12 +1074,7 @@ impl WSLContainerRunner { } let is_default_block = policy::network_is_isolated(request); - let has_host_rules = policy_mapping::needs_host_filtering( - is_default_block, - &request.policy.allowed_hosts, - &request.policy.blocked_hosts, - ); - let net_mode = policy_mapping::map_network_policy(is_default_block, has_host_rules); + let net_mode = policy_mapping::map_network_policy(is_default_block); let hr = sdk.WslcSetContainerSettingsNetworkingMode(&mut container_settings, net_mode); if hr != S_OK { return Err(sdk_error( @@ -1208,12 +1085,6 @@ impl WSLContainerRunner { } let _ = writeln!(logger, "[WSLC] Networking mode: {:?}", net_mode); - let iptables_cmd = policy_mapping::build_iptables_rules( - &request.policy.allowed_hosts, - &request.policy.blocked_hosts, - is_default_block, - ); - let mut flags = WslcContainerFlags::WSLC_CONTAINER_FLAG_NONE; if request.lifecycle.destroy_on_exit { flags |= WslcContainerFlags::WSLC_CONTAINER_FLAG_AUTO_REMOVE; @@ -1221,9 +1092,6 @@ impl WSLContainerRunner { if self.config.gpu { flags |= WslcContainerFlags::WSLC_CONTAINER_FLAG_ENABLE_GPU; } - if has_host_rules { - flags |= WslcContainerFlags::WSLC_CONTAINER_FLAG_PRIVILEGED; - } let hr = sdk.WslcSetContainerSettingsFlags(&mut container_settings, flags); if hr != S_OK { return Err(sdk_error("WslcSetContainerSettingsFlags failed", hr, "")); @@ -1272,11 +1140,8 @@ impl WSLContainerRunner { // reverse declaration order — freeing the callback context (`io_ctx` / // `io_ctx_guard`) *before* the session is terminated and the DLL // unloaded — so a late callback could dereference freed memory. Every - // failure past this point therefore quiesces the container first. This - // is not a corner case: `apply_iptables_rules` fails for any host-rule - // policy today, since the container is not granted `CAP_NET_ADMIN`. - let post_start = - Self::attach_init_process(sdk, &container_guard, iptables_cmd.as_deref(), logger); + // failure past this point therefore quiesces the container first. + let post_start = Self::attach_init_process(sdk, &container_guard); let process_guard = match post_start { Ok(guard) => guard, Err(e) => { @@ -1306,22 +1171,16 @@ impl WSLContainerRunner { }) } - /// Apply any host-rule `iptables` chain and take the container's init - /// process handle. Split out so every failure between "container started" - /// and "handle in hand" funnels through one caller-side cleanup path. + /// Take the container's init process handle. Split out so every failure + /// between "container started" and "handle in hand" funnels through one + /// caller-side cleanup path. /// /// # Safety /// `sdk` must hold valid function pointers and `container` a live handle. unsafe fn attach_init_process( sdk: &'static WslcSdk, container: &WslcContainerGuard, - iptables_cmd: Option<&str>, - logger: &mut Logger, ) -> Result { - if let Some(ipt_cmd) = iptables_cmd { - Self::apply_iptables_rules(sdk, container.as_raw(), ipt_cmd, logger)?; - } - let mut process: WslcProcess = ptr::null_mut(); let hr = sdk.WslcGetContainerInitProcess(container.as_raw(), &mut process); if hr != S_OK { @@ -1867,7 +1726,7 @@ mod tests { } #[test] - fn validate_runner_rejects_allowlist_host_filtering() { + fn validate_runner_rejects_retired_allowlist() { // block default + allowlist = per-host filtering WSLc can't enforce. let request = ExecutionRequest { containment: crate::mxc_common::models::ContainmentBackend::Wslc, @@ -1880,7 +1739,7 @@ mod tests { }; let runner = WSLContainerRunner::new(&WslcConfig::default()); let err = runner.validate_runner(&request).unwrap_err(); - assert!(err.error_message.contains("per-host egress filtering")); + assert!(err.error_message.contains("allowedHosts")); } #[test] @@ -1914,12 +1773,9 @@ mod tests { assert!(err.error_message.contains("allowLocalNetwork")); } - /// The two surfaces refuse `allowLocalNetwork` with deliberately different - /// remedies: one-shot has `wslc.portMappings` to point at, - /// state-aware has no port-mapping primitive at all. Unifying the messages - /// would send state-aware users after a dead end. + /// Neither surface accepts a retired network field. #[test] - fn both_surfaces_reject_allow_local_network_with_surface_specific_remedies() { + fn both_surfaces_reject_retired_allow_local_network() { let request = ExecutionRequest { containment: crate::mxc_common::models::ContainmentBackend::Wslc, policy: crate::mxc_common::models::ContainerPolicy { @@ -1936,11 +1792,7 @@ mod tests { one_shot.failure_phase, crate::mxc_common::models::FailurePhase::Rejected ); - assert!( - one_shot.error_message.contains("portMappings"), - "one-shot has a port-mapping primitive and must name it; got: {}", - one_shot.error_message - ); + assert!(one_shot.error_message.contains("allowLocalNetwork")); let state_aware = crate::wslc_common::policy::validate_provision_policy(&request).unwrap_err(); @@ -1948,11 +1800,6 @@ mod tests { state_aware.code, crate::mxc_common::mxc_error::MxcErrorCode::PolicyValidation ); - assert!( - !state_aware.message.contains("portMappings"), - "state-aware has no port-mapping primitive to point at; got: {}", - state_aware.message - ); assert!( state_aware.message.contains("allowLocalNetwork"), "got: {}", @@ -1983,12 +1830,22 @@ mod tests { #[test] fn validate_runner_accepts_bare_defaults() { - // Full cutoff / full NAT (no host lists) is enforceable — must pass. - for policy in [NetworkPolicy::Allow, NetworkPolicy::Block] { + // Both supported all-or-nothing postures are enforceable. + for action in [ + crate::mxc_common::models::NetworkAction::Allow, + crate::mxc_common::models::NetworkAction::Deny, + ] { let request = ExecutionRequest { containment: crate::mxc_common::models::ContainmentBackend::Wslc, policy: crate::mxc_common::models::ContainerPolicy { - default_network_policy: policy, + network_egress: Some(crate::mxc_common::models::NetworkEgressPolicy { + default: action, + ..Default::default() + }), + network_ingress: Some(crate::mxc_common::models::NetworkIngressPolicy { + default: action, + host_loopback: action, + }), ..Default::default() }, ..Default::default() diff --git a/src/mxc-sdk/src/configs/process_container.rs b/src/mxc-sdk/src/configs/process_container.rs index 07140cd33..99ec6b2a3 100644 --- a/src/mxc-sdk/src/configs/process_container.rs +++ b/src/mxc-sdk/src/configs/process_container.rs @@ -139,7 +139,6 @@ mod tests { use super::*; use crate::mxc_common::models::{ CaptureDenialsMode as RuntimeCaptureDenialsMode, ContainmentBackend, - NetworkEnforcementCompatibility, }; use crate::policy::{ build_request_with_containment, ContainerPolicy, Containment, NetworkAction, @@ -191,10 +190,6 @@ mod tests { let inner = &request.inner; assert_eq!(inner.source_contract, None); - assert_eq!( - inner.network_enforcement_compatibility, - NetworkEnforcementCompatibility::Strict - ); assert_eq!(inner.containment, ContainmentBackend::ProcessContainer); assert!(!inner.policy.least_privilege_mode); assert!(inner diff --git a/src/mxc-sdk/src/core/mxc_common/common_request_ir.rs b/src/mxc-sdk/src/core/mxc_common/common_request_ir.rs index 7c27dfd43..74d9a8f6b 100644 --- a/src/mxc-sdk/src/core/mxc_common/common_request_ir.rs +++ b/src/mxc-sdk/src/core/mxc_common/common_request_ir.rs @@ -3,7 +3,6 @@ //! Common request intermediate representation assembled by exact contract adapters. -use crate::mxc_common::models::{DefaultEnvCompatibility, NetworkEnforcementCompatibility}; use crate::mxc_common::wire; use crate::mxc_contract::ContractVersion; @@ -12,8 +11,6 @@ pub(crate) struct CommonRequestIR { pub(crate) schema: Option, pub(crate) comment: Option, pub(crate) source_contract: ContractVersion, - pub(crate) network_enforcement_compatibility: NetworkEnforcementCompatibility, - pub(crate) default_env_compatibility: DefaultEnvCompatibility, pub(crate) phase: Option, pub(crate) sandbox_id: Option, pub(crate) container_id: Option, diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/common.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/common.rs index d2e837167..01135fa0d 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/common.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/common.rs @@ -123,12 +123,6 @@ fn convert_network_ingress(value: contract::NetworkIngress) -> wire::NetworkIngr pub(super) fn convert_network(value: contract::Network) -> wire::Network { let contract::Network { egress, ingress } = value; wire::Network { - default_policy: None, - enforcement_mode: None, - allow_local_network: None, - allowed_hosts: None, - blocked_hosts: None, - proxy: None, egress: egress.into_option().map(convert_network_egress), ingress: ingress.into_option().map(convert_network_ingress), } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs index 56da7a56b..31471ea81 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs @@ -286,9 +286,6 @@ pub(super) fn into_common_request_ir( schema: schema.into_option(), comment: comment.into_option(), source_contract: crate::mxc_contract::ContractVersion::V1_1_0Alpha, - default_env_compatibility: crate::mxc_common::models::DefaultEnvCompatibility::DefaultBlock, - network_enforcement_compatibility: - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, phase: None, sandbox_id: None, container_id: container_id.into_option(), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs index fbe06247f..98fd657cd 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs @@ -399,8 +399,6 @@ fn process_container_request_maps_expected_wire_fields() { assert_eq!(fallback.allow_dacl_mutation, Some(true)); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.proxy.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Deny) @@ -498,8 +496,6 @@ fn lxc_request_maps_expected_wire_fields() { ); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Allow) @@ -508,7 +504,6 @@ fn lxc_request_maps_expected_wire_fields() { network.ingress.unwrap().default, Some(super::wire::NetworkAction::Allow) )); - assert!(network.proxy.is_none()); let ui = wire.ui.expect("ui should be populated"); assert_eq!(ui.disable, Some(true)); @@ -567,8 +562,6 @@ fn seatbelt_request_maps_expected_wire_fields() { ); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Allow) @@ -577,7 +570,6 @@ fn seatbelt_request_maps_expected_wire_fields() { network.ingress.unwrap().default, Some(super::wire::NetworkAction::Allow) )); - assert!(network.proxy.is_none()); let seatbelt = wire.seatbelt.expect("seatbelt should be populated"); assert_eq!( @@ -611,12 +603,8 @@ fn empty_optional_sections_map_to_present_empty_wire_sections() { assert!(fallback.allow_dacl_mutation.is_none()); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); - assert!(network.allow_local_network.is_none()); - assert!(network.allowed_hosts.is_none()); - assert!(network.blocked_hosts.is_none()); - assert!(network.proxy.is_none()); + assert!(network.egress.is_none()); + assert!(network.ingress.is_none()); let ui = wire.ui.expect("ui should be populated"); assert!(ui.disable.is_none()); @@ -1023,11 +1011,6 @@ fn directional_network_request_maps_expected_wire_fields() { ingress.host_loopback, Some(super::wire::NetworkAction::Allow) )); - - // The legacy fields stay absent when only directional policy is supplied. - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); - assert!(network.proxy.is_none()); } #[test] fn every_network_action_maps_to_the_expected_wire_value() { diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs index bf11364e5..f22be133d 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs @@ -39,12 +39,6 @@ fn convert_isolation_session_network(value: contract::IsolationSessionNetwork) - host_loopback: contract::IsolationSessionNetworkAllow, } = ingress; wire::Network { - allow_local_network: None, - default_policy: None, - allowed_hosts: None, - enforcement_mode: None, - blocked_hosts: None, - proxy: None, egress: Some(wire::NetworkEgress { default: Some(wire::NetworkAction::Allow), allow: None, @@ -83,9 +77,6 @@ fn state_aware_common( schema: schema.into_option(), comment: comment.into_option(), source_contract: crate::mxc_contract::ContractVersion::V1_1_0Alpha, - default_env_compatibility: crate::mxc_common::models::DefaultEnvCompatibility::DefaultBlock, - network_enforcement_compatibility: - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, phase: None, test_feature: None, windows_sandbox: None, diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/exec.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/exec.rs index 21cf93915..6f5dcb641 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/exec.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/exec.rs @@ -47,9 +47,6 @@ fn exec_preserves_process_network_telemetry_and_empty_values() { network.ingress.unwrap().default, Some(wire::NetworkAction::Deny) )); - assert!(network.allow_local_network.is_none()); - assert!(network.allowed_hosts.is_none()); - assert!(network.blocked_hosts.is_none()); assert_eq!( common.runtime_config.unwrap().network_proxy.as_deref(), Some("http://127.0.0.1:8080") diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs index 61a342ee7..2fe831fce 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs @@ -38,8 +38,6 @@ fn isolation_session_configuration_presence_matches_explicit_values() { assert!(common.filesystem.is_none()); assert!(common.process.is_none()); let network = common.network.unwrap(); - assert!(network.allow_local_network.is_none()); - assert!(network.default_policy.is_none()); assert!(matches!( network.egress.as_ref().unwrap().default, Some(wire::NetworkAction::Allow) @@ -220,10 +218,8 @@ fn provision_common_fields_are_independent_of_backend_payload() { } let (common, _) = adapt(&source("wslc", r#","network":{}"#)); let network = common.network.unwrap(); - assert!(network.default_policy.is_none()); - assert!(network.allowed_hosts.is_none()); - assert!(network.blocked_hosts.is_none()); - assert!(network.proxy.is_none()); + assert!(network.egress.is_none()); + assert!(network.ingress.is_none()); } #[test] diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/common.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/common.rs index 1d64f1722..3bdcb3a06 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/common.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/common.rs @@ -123,12 +123,6 @@ fn convert_network_ingress(value: contract::NetworkIngress) -> wire::NetworkIngr pub(super) fn convert_network(value: contract::Network) -> wire::Network { let contract::Network { egress, ingress } = value; wire::Network { - default_policy: None, - enforcement_mode: None, - allow_local_network: None, - allowed_hosts: None, - blocked_hosts: None, - proxy: None, egress: egress.into_option().map(convert_network_egress), ingress: ingress.into_option().map(convert_network_ingress), } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs index fc75bf168..a80a0cce4 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs @@ -258,9 +258,6 @@ pub(super) fn into_common_request_ir( schema: schema.into_option(), comment: comment.into_option(), source_contract: crate::mxc_contract::ContractVersion::V0_9_0Alpha, - default_env_compatibility: crate::mxc_common::models::DefaultEnvCompatibility::DefaultBlock, - network_enforcement_compatibility: - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, phase: None, sandbox_id: None, container_id: container_id.into_option(), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot_tests/stable_candidate.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot_tests/stable_candidate.rs index f63a78703..be2d540eb 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot_tests/stable_candidate.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot_tests/stable_candidate.rs @@ -426,8 +426,6 @@ fn process_container_request_maps_expected_wire_fields() { assert_eq!(fallback.allow_dacl_mutation, Some(true)); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.proxy.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Deny) @@ -525,8 +523,6 @@ fn lxc_request_maps_expected_wire_fields() { ); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Allow) @@ -535,7 +531,6 @@ fn lxc_request_maps_expected_wire_fields() { network.ingress.unwrap().default, Some(super::wire::NetworkAction::Allow) )); - assert!(network.proxy.is_none()); let ui = wire.ui.expect("ui should be populated"); assert_eq!(ui.disable, Some(true)); @@ -594,8 +589,6 @@ fn seatbelt_request_maps_expected_wire_fields() { ); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Allow) @@ -604,7 +597,6 @@ fn seatbelt_request_maps_expected_wire_fields() { network.ingress.unwrap().default, Some(super::wire::NetworkAction::Allow) )); - assert!(network.proxy.is_none()); let seatbelt = wire.seatbelt.expect("seatbelt should be populated"); assert_eq!( @@ -638,12 +630,8 @@ fn empty_optional_sections_map_to_present_empty_wire_sections() { assert!(fallback.allow_dacl_mutation.is_none()); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); - assert!(network.allow_local_network.is_none()); - assert!(network.allowed_hosts.is_none()); - assert!(network.blocked_hosts.is_none()); - assert!(network.proxy.is_none()); + assert!(network.egress.is_none()); + assert!(network.ingress.is_none()); let ui = wire.ui.expect("ui should be populated"); assert!(ui.disable.is_none()); @@ -1086,11 +1074,6 @@ fn directional_network_request_maps_expected_wire_fields() { ingress.host_loopback, Some(super::wire::NetworkAction::Allow) )); - - // The legacy fields stay absent when only directional policy is supplied. - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); - assert!(network.proxy.is_none()); } #[test] fn every_network_action_maps_to_the_expected_wire_value() { diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs index 1a394023e..b480a6447 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs @@ -39,12 +39,6 @@ fn convert_isolation_session_network(value: contract::IsolationSessionNetwork) - host_loopback: contract::IsolationSessionNetworkAllow, } = ingress; wire::Network { - allow_local_network: None, - default_policy: None, - allowed_hosts: None, - enforcement_mode: None, - blocked_hosts: None, - proxy: None, egress: Some(wire::NetworkEgress { default: Some(wire::NetworkAction::Allow), allow: None, @@ -83,9 +77,6 @@ fn state_aware_common( schema: schema.into_option(), comment: comment.into_option(), source_contract: crate::mxc_contract::ContractVersion::V0_9_0Alpha, - default_env_compatibility: crate::mxc_common::models::DefaultEnvCompatibility::DefaultBlock, - network_enforcement_compatibility: - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, phase: None, test_feature: None, windows_sandbox: None, diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware_tests/exec.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware_tests/exec.rs index 251e02fda..126530dad 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware_tests/exec.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware_tests/exec.rs @@ -47,9 +47,6 @@ fn exec_preserves_process_network_telemetry_and_empty_values() { network.ingress.unwrap().default, Some(wire::NetworkAction::Deny) )); - assert!(network.allow_local_network.is_none()); - assert!(network.allowed_hosts.is_none()); - assert!(network.blocked_hosts.is_none()); assert_eq!( common.runtime_config.unwrap().network_proxy.as_deref(), Some("http://127.0.0.1:8080") diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/common.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/common.rs index 13470a6e6..617e795fc 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/common.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/common.rs @@ -123,12 +123,6 @@ fn convert_network_ingress(value: contract::NetworkIngress) -> wire::NetworkIngr pub(super) fn convert_network(value: contract::Network) -> wire::Network { let contract::Network { egress, ingress } = value; wire::Network { - default_policy: None, - enforcement_mode: None, - allow_local_network: None, - allowed_hosts: None, - blocked_hosts: None, - proxy: None, egress: egress.into_option().map(convert_network_egress), ingress: ingress.into_option().map(convert_network_ingress), } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs index 592405b12..ef87fe4da 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs @@ -258,9 +258,6 @@ pub(super) fn into_common_request_ir( schema: schema.into_option(), comment: comment.into_option(), source_contract: crate::mxc_contract::ContractVersion::V1_0_0, - default_env_compatibility: crate::mxc_common::models::DefaultEnvCompatibility::DefaultBlock, - network_enforcement_compatibility: - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, phase: None, sandbox_id: None, container_id: container_id.into_option(), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot_tests/stable_candidate.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot_tests/stable_candidate.rs index 9bf90b82a..6222f0741 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot_tests/stable_candidate.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot_tests/stable_candidate.rs @@ -391,8 +391,6 @@ fn process_container_request_maps_expected_wire_fields() { assert_eq!(fallback.allow_dacl_mutation, Some(true)); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.proxy.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Deny) @@ -490,8 +488,6 @@ fn lxc_request_maps_expected_wire_fields() { ); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Allow) @@ -500,7 +496,6 @@ fn lxc_request_maps_expected_wire_fields() { network.ingress.unwrap().default, Some(super::wire::NetworkAction::Allow) )); - assert!(network.proxy.is_none()); let ui = wire.ui.expect("ui should be populated"); assert_eq!(ui.disable, Some(true)); @@ -559,8 +554,6 @@ fn seatbelt_request_maps_expected_wire_fields() { ); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); assert!(matches!( network.egress.unwrap().default, Some(super::wire::NetworkAction::Allow) @@ -569,7 +562,6 @@ fn seatbelt_request_maps_expected_wire_fields() { network.ingress.unwrap().default, Some(super::wire::NetworkAction::Allow) )); - assert!(network.proxy.is_none()); let seatbelt = wire.seatbelt.expect("seatbelt should be populated"); assert_eq!( @@ -603,12 +595,8 @@ fn empty_optional_sections_map_to_present_empty_wire_sections() { assert!(fallback.allow_dacl_mutation.is_none()); let network = wire.network.expect("network should be populated"); - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); - assert!(network.allow_local_network.is_none()); - assert!(network.allowed_hosts.is_none()); - assert!(network.blocked_hosts.is_none()); - assert!(network.proxy.is_none()); + assert!(network.egress.is_none()); + assert!(network.ingress.is_none()); let ui = wire.ui.expect("ui should be populated"); assert!(ui.disable.is_none()); @@ -1015,11 +1003,6 @@ fn directional_network_request_maps_expected_wire_fields() { ingress.host_loopback, Some(super::wire::NetworkAction::Allow) )); - - // The legacy fields stay absent when only directional policy is supplied. - assert!(network.default_policy.is_none()); - assert!(network.enforcement_mode.is_none()); - assert!(network.proxy.is_none()); } #[test] fn every_network_action_maps_to_the_expected_wire_value() { diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs index be62a111b..e9dc3763c 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs @@ -39,12 +39,6 @@ fn convert_isolation_session_network(value: contract::IsolationSessionNetwork) - host_loopback: contract::IsolationSessionNetworkAllow, } = ingress; wire::Network { - allow_local_network: None, - default_policy: None, - allowed_hosts: None, - enforcement_mode: None, - blocked_hosts: None, - proxy: None, egress: Some(wire::NetworkEgress { default: Some(wire::NetworkAction::Allow), allow: None, @@ -83,9 +77,6 @@ fn state_aware_common( schema: schema.into_option(), comment: comment.into_option(), source_contract: crate::mxc_contract::ContractVersion::V1_0_0, - default_env_compatibility: crate::mxc_common::models::DefaultEnvCompatibility::DefaultBlock, - network_enforcement_compatibility: - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, phase: None, test_feature: None, windows_sandbox: None, diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware_tests/exec.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware_tests/exec.rs index fbf874b78..38c1de80e 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware_tests/exec.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware_tests/exec.rs @@ -47,9 +47,6 @@ fn exec_preserves_process_network_telemetry_and_empty_values() { network.ingress.unwrap().default, Some(wire::NetworkAction::Deny) )); - assert!(network.allow_local_network.is_none()); - assert!(network.allowed_hosts.is_none()); - assert!(network.blocked_hosts.is_none()); assert_eq!( common.runtime_config.unwrap().network_proxy.as_deref(), Some("http://127.0.0.1:8080") diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index f55adf662..fce3da10c 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1293,8 +1293,6 @@ fn normalize_common_request_ir( let present_backend_sections = present_backend_sections(&cfg); let source_contract = cfg.source_contract; - let network_enforcement_compatibility = cfg.network_enforcement_compatibility; - let default_env_compatibility = cfg.default_env_compatibility; let container_id = cfg.container_id.unwrap_or_default(); // Process section: required for one-shot and state-aware exec; optional for @@ -1626,8 +1624,6 @@ fn normalize_common_request_ir( Ok(ExecutionRequest { source_contract: Some(source_contract), - network_enforcement_compatibility, - default_env_compatibility, container_id, env, inherit_default_env, @@ -1747,16 +1743,11 @@ mod tests { fn assert_exact_contract_bridge( request: ExactOneShotContract, expected_contract: ContractVersion, - expected_compatibility: crate::mxc_common::models::NetworkEnforcementCompatibility, ) { let mut logger = test_logger(); let execution = load_one_shot_request_from_contract(request, &mut logger).unwrap(); assert_eq!(execution.source_contract, Some(expected_contract)); - assert_eq!( - execution.network_enforcement_compatibility, - expected_compatibility - ); assert_eq!( execution.source_contract_version(), expected_contract.as_str() @@ -2146,10 +2137,6 @@ mod tests { match parse_exact_for_test(json).unwrap() { MxcRequest::OneShot(request) => { assert_eq!(request.source_contract, Some(ContractVersion::V1_1_0Alpha)); - assert_eq!( - request.network_enforcement_compatibility, - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict - ); assert_eq!(request.script_code, "echo dev"); } MxcRequest::StateAware(_) => panic!("expected one-shot request"), @@ -2776,7 +2763,6 @@ mod tests { assert_exact_contract_bridge( ExactOneShotContract::V0_9(Box::new(v0_9)), ContractVersion::V0_9_0Alpha, - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, ); let v1_0 = serde_json::from_str::( @@ -2789,7 +2775,6 @@ mod tests { assert_exact_contract_bridge( ExactOneShotContract::V1_0(Box::new(v1_0)), ContractVersion::V1_0_0, - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, ); let dev = serde_json::from_str::( @@ -2802,7 +2787,6 @@ mod tests { assert_exact_contract_bridge( ExactOneShotContract::Dev(Box::new(dev)), ContractVersion::V1_1_0Alpha, - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, ); } diff --git a/src/mxc-sdk/src/core/mxc_common/default_env.rs b/src/mxc-sdk/src/core/mxc_common/default_env.rs index 7594abae6..fd7a576b2 100644 --- a/src/mxc-sdk/src/core/mxc_common/default_env.rs +++ b/src/mxc-sdk/src/core/mxc_common/default_env.rs @@ -94,7 +94,6 @@ pub fn env_pairs(entries: &[String]) -> Vec<(&str, &str)> { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::DefaultEnvCompatibility; fn defaults() -> Vec<(String, String)> { vec![ @@ -103,11 +102,8 @@ mod tests { ] } - fn request(compatibility: DefaultEnvCompatibility) -> ExecutionRequest { - ExecutionRequest { - default_env_compatibility: compatibility, - ..Default::default() - } + fn request() -> ExecutionRequest { + ExecutionRequest::default() } fn resolved(request: &ExecutionRequest) -> Vec { @@ -116,27 +112,27 @@ mod tests { #[test] fn an_omitted_env_takes_the_default_block() { - let r = request(DefaultEnvCompatibility::DefaultBlock); + let r = request(); assert_eq!(resolved(&r), ["PATH=/default/bin", "TERM=xterm-256color"]); } #[test] fn an_explicitly_empty_env_stays_empty() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(Vec::new()); assert!(resolved(&r).is_empty()); } #[test] fn a_supplied_env_is_used_verbatim() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FOO=bar".to_string()]); assert_eq!(resolved(&r), ["FOO=bar"]); } #[test] fn an_overlay_replaces_in_place_and_appends_in_order() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec![ "TERM=vt100".to_string(), "FOO=bar".to_string(), @@ -152,7 +148,7 @@ mod tests { #[test] fn a_repeated_caller_key_collapses_to_the_last_value() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec![ "FOO=first".to_string(), "FOO=second".to_string(), @@ -169,7 +165,7 @@ mod tests { #[test] fn an_empty_caller_value_still_replaces_the_default() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["PATH=".to_string()]); r.inherit_default_env = true; @@ -178,7 +174,7 @@ mod tests { #[test] fn a_caller_entry_without_a_value_is_dropped_by_the_overlay() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = Some(vec!["FEATURE_FLAG".to_string(), "FOO=bar".to_string()]); r.inherit_default_env = true; @@ -189,7 +185,7 @@ mod tests { #[test] fn each_state_of_process_env_resolves_to_its_own_outcome() { - let mut r = request(DefaultEnvCompatibility::DefaultBlock); + let mut r = request(); r.env = None; assert_eq!(EnvResolution::of(&r), EnvResolution::Default); diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index ad035a993..67f066e2f 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -318,15 +318,6 @@ impl NetworkPolicy { } } -impl From for NetworkPolicy { - fn from(p: crate::mxc_common::wire::NetworkPolicy) -> Self { - match p { - crate::mxc_common::wire::NetworkPolicy::Allow => Self::Allow, - crate::mxc_common::wire::NetworkPolicy::Block => Self::Block, - } - } -} - #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum NetworkEnforcementMode { @@ -428,16 +419,6 @@ pub struct NetworkIngressPolicy { pub host_loopback: NetworkAction, } -impl From for NetworkEnforcementMode { - fn from(m: crate::mxc_common::wire::NetworkEnforcement) -> Self { - match m { - crate::mxc_common::wire::NetworkEnforcement::Capabilities => Self::Capabilities, - crate::mxc_common::wire::NetworkEnforcement::Firewall => Self::Firewall, - crate::mxc_common::wire::NetworkEnforcement::Both => Self::Both, - } - } -} - /// A hostname-to-IP mapping that makes a sandbox resolve the proxy to exactly /// the address the firewall authorized. /// @@ -1042,11 +1023,7 @@ pub struct ExecutionRequest { /// Direct typed SDK construction has no external contract attribution. #[serde(serialize_with = "serialize_source_contract")] pub source_contract: Option, - /// Whether backends preserve pre-v0.8 network compatibility behavior or - /// enforce the current strict posture. - pub network_enforcement_compatibility: NetworkEnforcementCompatibility, /// Whether backends supply the default `process.env` block. - pub default_env_compatibility: DefaultEnvCompatibility, /// Externally assigned container identifier. pub container_id: String, /// Environment variables as "KEY=VALUE" strings (from `process.env`). @@ -1070,18 +1047,16 @@ pub struct ExecutionRequest { /// process's variables must merge them in themselves, or set /// [`ExecutionRequest::inherit_default_env`]. /// - /// The Windows process container honors the distinction at every schema - /// version; LXC, Bubblewrap, Seatbelt, and WSLc honor it from 0.9, and - /// below 0.9 treat `None` and `Some(vec![])` alike. IsolationSession starts - /// every process from the agent user's default environment, so it rejects - /// `Some` without [`ExecutionRequest::inherit_default_env`]. + /// IsolationSession starts every process from the agent user's default + /// environment, so it rejects `Some` without + /// [`ExecutionRequest::inherit_default_env`]. pub env: Option>, /// Layer [`ExecutionRequest::env`] on top of the backend's default /// environment instead of replacing it (from `process.inheritDefaultEnv`). /// /// Only meaningful when `env` is `Some`: with `None` the child already gets - /// the default. Rejected below schema 0.9 by the config parser. + /// the default. pub inherit_default_env: bool, pub script_code: String, pub working_directory: String, @@ -1119,25 +1094,6 @@ pub struct ExecutionRequest { pub dry_run: bool, } -/// Backend network behavior after exact contract normalization. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum NetworkEnforcementCompatibility { - /// Enforce the current network posture. - #[default] - Strict, -} - -/// Backend `process.env` behavior for registered contracts and typed requests. -/// Every supported request uses the default block. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum DefaultEnvCompatibility { - /// Supply the default block and keep the four states of `process.env` distinct. - #[default] - DefaultBlock, -} - fn serialize_source_contract( value: &Option, serializer: S, @@ -1150,15 +1106,6 @@ where .serialize(serializer) } -impl NetworkEnforcementCompatibility { - /// Stable diagnostic spelling for policy identity and tests. - pub const fn as_str(self) -> &'static str { - match self { - Self::Strict => "strict", - } - } -} - /// Where a [`ResolvedWorkingDirectory`] came from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkingDirectorySource { @@ -1211,21 +1158,14 @@ impl ExecutionRequest { .unwrap_or_default() } - /// Whether this request's contract supplies the backend default - /// environment block, introduced by `0.9.0-alpha`. - pub fn supplies_default_env(&self) -> bool { - self.default_env_compatibility == DefaultEnvCompatibility::DefaultBlock - } - /// The caller's environment entries, with "not supplied" and "supplied but /// empty" flattened to the same empty slice. /// - /// Only for backends that have no default environment to distinguish them - /// against — every backend below schema 0.9. A backend with a default block - /// must match on [`ExecutionRequest::env`] directly, since `None` means - /// "give the child the default" and `Some(vec![])` means "give the child - /// nothing". A backend whose default MXC cannot enumerate takes the state - /// from `env` and the entries from here: WSLc, whose default is the + /// Backends with an enumerable default must match on + /// [`ExecutionRequest::env`] directly, since `None` means "give the child + /// the default" and `Some(vec![])` means "give the child nothing". A + /// backend whose default MXC cannot enumerate takes the state from `env` + /// and the entries from here: WSLc, whose default is the /// container image's `ENV`, and IsolationSession, which starts every /// process from the agent user's default environment. pub fn env_entries(&self) -> &[String] { diff --git a/src/mxc-sdk/src/core/mxc_common/network_parser.rs b/src/mxc-sdk/src/core/mxc_common/network_parser.rs index dfab3ec08..117dacb84 100644 --- a/src/mxc-sdk/src/core/mxc_common/network_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/network_parser.rs @@ -463,12 +463,6 @@ mod proxy_policy_tests { fn directional_sections(proxy: &str) -> NetworkSections { NetworkSections { network: Some(wire::Network { - default_policy: None, - enforcement_mode: None, - allow_local_network: None, - allowed_hosts: None, - blocked_hosts: None, - proxy: None, egress: Some(wire::NetworkEgress { default: Some(wire::NetworkAction::Allow), allow: None, diff --git a/src/mxc-sdk/src/core/mxc_common/network_parser_ingress_default_tests.rs b/src/mxc-sdk/src/core/mxc_common/network_parser_ingress_default_tests.rs index 574c7676a..daddcccce 100644 --- a/src/mxc-sdk/src/core/mxc_common/network_parser_ingress_default_tests.rs +++ b/src/mxc-sdk/src/core/mxc_common/network_parser_ingress_default_tests.rs @@ -3,9 +3,8 @@ //! Tests the resolved value of an omitted `network.ingress.hostLoopback`. //! -//! Contract source: `docs/sandbox-policy/0.8.0/networking/networking.md` -//! ("Host Loopback and Inbound Policy") and `docs/sandbox-policy/0.8.0/policy.md`: -//! both ingress controls default to `deny`, and `hostLoopback` resolves +//! Contract source: `docs/schema.md` ("Directional networking"): both +//! ingress controls default to `deny`, and `hostLoopback` resolves //! independently of `ingress.default` rather than inheriting it. //! //! The inherit reading and the deny reading agree everywhere except under @@ -17,12 +16,6 @@ use super::*; fn sections(ingress: Option, network_present: bool) -> NetworkSections { NetworkSections { network: network_present.then_some(wire::Network { - default_policy: None, - enforcement_mode: None, - allow_local_network: None, - allowed_hosts: None, - blocked_hosts: None, - proxy: None, egress: Some(wire::NetworkEgress { default: Some(wire::NetworkAction::Allow), allow: None, diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index e11c8678a..819b77a37 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -42,12 +42,10 @@ //! | `capture_denials.output_path` | Only decides where the diagnostic JSON deliverable is written; not enforcement. `capture_denials.mode` remains hashed. | //! | `dry_run`, `testing_features_enabled` | Invocation modes, not policy. | //! | `experimental_enabled` | Authorizes selecting an experimental backend; it does not change the selected backend's enforcement. | -//! | `source_contract` | External JSON provenance used only for diagnostics and telemetry. Normalized network compatibility is hashed separately. | -//! | `default_env_compatibility` | Decides whether a default environment block is supplied, which is process launch behavior rather than enforcement. | +//! | `source_contract` | External JSON provenance used only for diagnostics and telemetry. | //! -//! `network_enforcement_compatibility` is hashed because it changes how the -//! normalized network policy is enforced. WSLC and Windows Sandbox -//! configuration is hashed at the `wslc` and `windowsSandbox` root keys. +//! WSLC and Windows Sandbox configuration is hashed at the `wslc` and +//! `windowsSandbox` root keys. //! //! `ContainerPolicy::network_proxy` is `#[serde(skip)]`, so the proxy's //! credential-bearing URL cannot reach the hash through the blanket policy @@ -191,10 +189,8 @@ fn hash_canonical_json(canonical: &str) -> String { fn policy_projection(request: &ExecutionRequest) -> Value { let ExecutionRequest { // External contract provenance is diagnostics/telemetry attribution, - // not enforcement. The normalized compatibility value below is. + // not enforcement. source_contract: _excluded_source_contract, - network_enforcement_compatibility, - default_env_compatibility: _excluded_default_env_compatibility, container_id, working_directory, script_timeout, @@ -228,10 +224,6 @@ fn policy_projection(request: &ExecutionRequest) -> Value { let mut root = Map::new(); - root.insert( - "networkEnforcementCompatibility".into(), - Value::String(network_enforcement_compatibility.as_str().to_string()), - ); root.insert( "containment".into(), Value::String(containment.wire_name().to_string()), @@ -452,8 +444,6 @@ mod tests { fn request() -> ExecutionRequest { let mut r = ExecutionRequest { source_contract: Some(crate::mxc_contract::ContractVersion::V0_9_0Alpha), - network_enforcement_compatibility: - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict, container_id: "test".to_string(), script_code: "echo hello".to_string(), working_directory: "C:\\work".to_string(), @@ -531,7 +521,6 @@ mod tests { "containment", "lifecycle", "lxc", - "networkEnforcementCompatibility", "policy", "proxy", "scriptTimeout", diff --git a/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs b/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs index bf78a07c1..bb0d530b5 100644 --- a/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs +++ b/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs @@ -568,7 +568,7 @@ pub trait SandboxBackend { /// Return a named feature constant or compose constants with `|`. Use /// [`NetworkPolicySupport::ALL`] only when every feature is enforced. fn network_policy_support(&self) -> NetworkPolicySupport { - NetworkPolicySupport::LEGACY + NetworkPolicySupport::default() } /// Validates shared network support and backend-specific constraints. diff --git a/src/mxc-sdk/src/core/mxc_common/script_runner.rs b/src/mxc-sdk/src/core/mxc_common/script_runner.rs index 11df2e0cb..7e94f4365 100644 --- a/src/mxc-sdk/src/core/mxc_common/script_runner.rs +++ b/src/mxc-sdk/src/core/mxc_common/script_runner.rs @@ -19,7 +19,7 @@ use crate::mxc_common::validator::{ pub trait ScriptRunner { /// Validate shared network support and runner-specific constraints. fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - validate_network_policy_support(request, NetworkPolicySupport::LEGACY)?; + validate_network_policy_support(request, NetworkPolicySupport::default())?; Ok(()) } diff --git a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs index 2626e6ba9..3f460c45c 100644 --- a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs +++ b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs @@ -10,7 +10,6 @@ use crate::mxc_contract::ContractVersion; use crate::mxc_common::common_request_ir::CommonRequestIR; use crate::mxc_common::error::WxcError; -use crate::mxc_common::models::NetworkEnforcementCompatibility; use crate::mxc_common::state_aware_input::StateAwareInput; use crate::mxc_common::state_aware_operation::StateAwareOperation; use crate::mxc_common::wire; @@ -148,9 +147,6 @@ impl SdkStateAwareInput { schema: None, comment: None, source_contract: self.version, - network_enforcement_compatibility: NetworkEnforcementCompatibility::Strict, - default_env_compatibility: - crate::mxc_common::models::DefaultEnvCompatibility::DefaultBlock, phase: None, sandbox_id: None, container_id: None, @@ -191,12 +187,6 @@ impl SdkStateAwareInput { fn map_network(network: SdkNetworkInput) -> wire::Network { wire::Network { - default_policy: None, - enforcement_mode: None, - allow_local_network: None, - allowed_hosts: None, - blocked_hosts: None, - proxy: None, egress: network.egress.map(|egress| wire::NetworkEgress { default: egress.default.map(map_action), allow: egress.allow.map(map_rules), diff --git a/src/mxc-sdk/src/core/mxc_common/validator.rs b/src/mxc-sdk/src/core/mxc_common/validator.rs index 5ac9345a7..07ff2b219 100644 --- a/src/mxc-sdk/src/core/mxc_common/validator.rs +++ b/src/mxc-sdk/src/core/mxc_common/validator.rs @@ -13,9 +13,6 @@ use crate::mxc_common::mxc_error::MxcError; pub struct NetworkPolicySupport(u8); impl NetworkPolicySupport { - /// Support for the legacy network policy without additive 0.8 features. - pub const LEGACY: Self = Self(0); - /// Support for the outbound network default policy. pub const EGRESS_DEFAULT: Self = Self(1 << 4); @@ -404,7 +401,7 @@ mod tests { ..Default::default() }; - for support in [NetworkPolicySupport::LEGACY, NetworkPolicySupport::ALL] { + for support in [NetworkPolicySupport::default(), NetworkPolicySupport::ALL] { assert!( validate_network_policy_support(&request, support).is_ok(), "valid legacy host-list policy was rejected for support {support:?}" @@ -447,7 +444,7 @@ mod tests { ..Default::default() }; - for support in [NetworkPolicySupport::LEGACY, NetworkPolicySupport::ALL] { + for support in [NetworkPolicySupport::default(), NetworkPolicySupport::ALL] { let error = validate_network_policy_support(&request, support).unwrap_err(); assert_eq!(error.error_message, expected); } @@ -463,7 +460,7 @@ mod tests { request.policy.allowed_hosts = vec!["203.0.113.7".to_string()]; let error = - validate_network_policy_support(&request, NetworkPolicySupport::LEGACY).unwrap_err(); + validate_network_policy_support(&request, NetworkPolicySupport::default()).unwrap_err(); assert_eq!( error.error_message, "network.egress.default is not supported by the selected backend" @@ -478,7 +475,7 @@ mod tests { ..Default::default() }); let error = - validate_network_policy_support(&request, NetworkPolicySupport::LEGACY).unwrap_err(); + validate_network_policy_support(&request, NetworkPolicySupport::default()).unwrap_err(); assert!(error.error_message.contains("network.egress.default")); let mut request = ExecutionRequest::default(); @@ -590,7 +587,7 @@ mod tests { request.policy.network_egress = Some(NetworkEgressPolicy::default()); request.policy.default_network_policy = NetworkPolicy::Allow; let error = - validate_network_policy_support(&request, NetworkPolicySupport::LEGACY).unwrap_err(); + validate_network_policy_support(&request, NetworkPolicySupport::default()).unwrap_err(); assert!(error.error_message.contains("legacy outbound policy")); let mut request = ExecutionRequest::default(); @@ -612,12 +609,12 @@ mod tests { } #[test] - fn network_support_accepts_implicit_directional_defaults_for_legacy_backends() { + fn network_support_accepts_implicit_directional_defaults_without_declared_features() { let mut request = ExecutionRequest::default(); request.policy.network_egress = Some(NetworkEgressPolicy::default()); request.policy.network_ingress = Some(NetworkIngressPolicy::default()); - assert!(validate_network_policy_support(&request, NetworkPolicySupport::LEGACY).is_ok()); + assert!(validate_network_policy_support(&request, NetworkPolicySupport::default()).is_ok()); } #[test] @@ -641,7 +638,7 @@ mod tests { }); let error = - validate_state_aware_network_policy_support(&request, NetworkPolicySupport::LEGACY) + validate_state_aware_network_policy_support(&request, NetworkPolicySupport::default()) .unwrap_err(); assert_eq!(error.code, MxcErrorCode::PolicyValidation); diff --git a/src/mxc-sdk/src/core/mxc_common/wire.rs b/src/mxc-sdk/src/core/mxc_common/wire.rs index 8491ecc56..1aa80be8d 100644 --- a/src/mxc-sdk/src/core/mxc_common/wire.rs +++ b/src/mxc-sdk/src/core/mxc_common/wire.rs @@ -134,9 +134,8 @@ pub struct ProcessContainer { /// process's access attempts to a learning-mode ETL trace for later /// inspection. MXC prefers native PSEC plus V2 Learning Mode when that API /// set can fully honor the request. Otherwise it retains the highest - /// compatible legacy containment tier and uses guarded WPR capture, so - /// `leastPrivilege`, `network.proxy`, and deny-path policies can remain - /// enforced without weakening the request. + /// compatible containment tier and uses guarded WPR capture, so + /// `leastPrivilege` and deny-path policies remain enforced. pub capture_denials: Option, /// BaseProcessContainer UI settings (Windows). pub ui: Option, @@ -283,18 +282,6 @@ pub struct Fallback { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Network { - /// Default outbound policy when no host rule matches. - pub default_policy: Option, - /// How the policy is enforced. - pub enforcement_mode: Option, - /// Allow binding/listening on local IPs and accepting inbound connections. - pub allow_local_network: Option, - /// Hosts explicitly allowed. - pub allowed_hosts: Option>, - /// Hosts explicitly blocked. - pub blocked_hosts: Option>, - /// Proxy configuration (one of localhost / builtinTestServer / url). - pub proxy: Option, /// Outbound network policy. pub egress: Option, /// Inbound and host-loopback network policy. @@ -381,26 +368,6 @@ pub struct RuntimeConfig { pub network_proxy: Option, } -/// Default network policy. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum NetworkPolicy { - Allow, - Block, -} - -/// Network enforcement mechanism. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum NetworkEnforcement { - /// Per-process capability-based filtering. - Capabilities, - /// Host firewall rules. - Firewall, - /// Both capability and firewall enforcement. - Both, -} - /// Proxy configuration. Exactly one variant applies. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/src/mxc-sdk/src/core/mxc_engine/run.rs b/src/mxc-sdk/src/core/mxc_engine/run.rs index c2fd51462..f10e51128 100644 --- a/src/mxc-sdk/src/core/mxc_engine/run.rs +++ b/src/mxc-sdk/src/core/mxc_engine/run.rs @@ -143,7 +143,7 @@ fn policy_hash_identity(container_id: &str) -> String { mod attribution_tests { use super::config_schema_version; use crate::mxc_common::logger::{Logger, Mode}; - use crate::mxc_common::models::{ExecutionRequest, NetworkEnforcementCompatibility}; + use crate::mxc_common::models::ExecutionRequest; use crate::mxc_common::state_aware_request::MxcRequest; #[test] @@ -161,10 +161,6 @@ mod attribution_tests { panic!("expected one-shot request"); }; assert_eq!(config_schema_version(&exact), "0.9.0-alpha"); - assert_eq!( - exact.network_enforcement_compatibility, - NetworkEnforcementCompatibility::Strict - ); let direct = ExecutionRequest { script_code: "echo direct".to_string(), @@ -172,10 +168,6 @@ mod attribution_tests { }; assert_eq!(config_schema_version(&direct), ""); assert_eq!(direct.source_contract, None); - assert_eq!( - direct.network_enforcement_compatibility, - NetworkEnforcementCompatibility::Strict - ); } } diff --git a/src/mxc-sdk/src/policy.rs b/src/mxc-sdk/src/policy.rs index b10f81382..b6d4dcb44 100644 --- a/src/mxc-sdk/src/policy.rs +++ b/src/mxc-sdk/src/policy.rs @@ -1065,10 +1065,6 @@ mod tests { execution.source_contract, Some(crate::mxc_contract::ContractVersion::V0_9_0Alpha) ); - assert_eq!( - execution.network_enforcement_compatibility, - crate::mxc_common::models::NetworkEnforcementCompatibility::Strict - ); assert_eq!(execution.script_code, "echo hello"); } diff --git a/tests/configs/bubblewrap_network_directional_proxy.json b/tests/configs/bubblewrap_network_directional_proxy.json index 70108e3ea..c3229b1f9 100644 --- a/tests/configs/bubblewrap_network_directional_proxy.json +++ b/tests/configs/bubblewrap_network_directional_proxy.json @@ -4,7 +4,7 @@ "containerId": "CLI-Bubblewrap-Network-Directional-Proxy", "containment": "bubblewrap", "process": { - "commandLine": "bash -c 'set -u; pu=\"${HTTP_PROXY:-${http_proxy:-}}\"; if [ -z \"$pu\" ]; then echo PARITY_NO_PROXY_ENV; exit 1; fi; echo PARITY_PROXY_ENV_OK; pa=\"${pu#*://}\"; pa=\"${pa%%/*}\"; ph=\"${pa%%:*}\"; pp=\"${pa##*:}\"; if [ \"$pp\" = \"$ph\" ]; then pp=80; fi; if ! timeout 6 bash -c \"exec 3<>/dev/tcp/$ph/$pp\" >/dev/null 2>&1; then echo PARITY_PROXY_UNREACHABLE; exit 1; fi; echo PARITY_PROXY_REACHABLE_OK; timeout 6 bash -c \"exec 3<>/dev/tcp/10.0.2.2/{{CONTROL_PORT}}\" >/dev/null 2>&1; if [ $? = 0 ]; then echo PARITY_DIRECT_LEAKED; exit 1; fi; echo PARITY_DIRECT_BLOCKED_OK; if ! curl -fsSL --max-time 15 http://mxc-test.invalid/ 2>/dev/null | grep -q MXC_TEST_ORIGIN_OK; then echo PARITY_PROXIED_FETCH_FAILED; exit 1; fi; echo PARITY_PROXIED_FETCH_OK'" + "commandLine": "bash -c 'set -u; pu=\"${HTTP_PROXY:-${http_proxy:-}}\"; if [ -z \"$pu\" ]; then echo PARITY_NO_PROXY_ENV; exit 1; fi; echo PARITY_PROXY_ENV_OK; pa=\"${pu#*://}\"; pa=\"${pa%%/*}\"; ph=\"${pa%%:*}\"; pp=\"${pa##*:}\"; if [ \"$pp\" = \"$ph\" ]; then pp=80; fi; if ! timeout 6 bash -c \"exec 3<>/dev/tcp/$ph/$pp\" >/dev/null 2>&1; then echo PARITY_PROXY_UNREACHABLE; exit 1; fi; echo PARITY_PROXY_REACHABLE_OK; timeout 6 bash -c \"exec 3<>/dev/tcp/10.0.2.2/{{CONTROL_PORT}}\" >/dev/null 2>&1; if [ $? = 0 ]; then echo PARITY_DIRECT_LEAKED; exit 1; fi; echo PARITY_DIRECT_BLOCKED_OK; if ! timeout 15 bash -c \"exec 3<>/dev/tcp/$ph/$pp; printf \\\"GET http://mxc-test.invalid/ HTTP/1.1\\\\r\\\\nHost: mxc-test.invalid\\\\r\\\\nConnection: close\\\\r\\\\n\\\\r\\\\n\\\" >&3; grep -q MXC_TEST_ORIGIN_OK <&3\"; then echo PARITY_PROXIED_FETCH_FAILED; exit 1; fi; echo PARITY_PROXIED_FETCH_OK'" }, "network": { "egress": { diff --git a/tests/configs/bubblewrap_network_proxy_parity_legacy.json b/tests/configs/bubblewrap_network_proxy_parity_implicit_ingress.json similarity index 68% rename from tests/configs/bubblewrap_network_proxy_parity_legacy.json rename to tests/configs/bubblewrap_network_proxy_parity_implicit_ingress.json index 6d943aab5..cc708b25b 100644 --- a/tests/configs/bubblewrap_network_proxy_parity_legacy.json +++ b/tests/configs/bubblewrap_network_proxy_parity_implicit_ingress.json @@ -1,10 +1,10 @@ { "$schema": "../../schemas/stable/mxc-config.schema.0.9.0-alpha.json", "version": "0.9.0-alpha", - "containerId": "CLI-Bubblewrap-Network-Proxy-Parity-Legacy", + "containerId": "CLI-Bubblewrap-Network-Proxy-Parity-Implicit-Ingress", "containment": "bubblewrap", "process": { - "commandLine": "bash -c 'set -u; pu=\"${HTTP_PROXY:-${http_proxy:-}}\"; if [ -z \"$pu\" ]; then echo PARITY_NO_PROXY_ENV; exit 1; fi; echo PARITY_PROXY_ENV_OK; pa=\"${pu#*://}\"; pa=\"${pa%%/*}\"; ph=\"${pa%%:*}\"; pp=\"${pa##*:}\"; if [ \"$pp\" = \"$ph\" ]; then pp=80; fi; if ! timeout 6 bash -c \"exec 3<>/dev/tcp/$ph/$pp\" >/dev/null 2>&1; then echo PARITY_PROXY_UNREACHABLE; exit 1; fi; echo PARITY_PROXY_REACHABLE_OK; timeout 6 bash -c \"exec 3<>/dev/tcp/10.0.2.2/{{CONTROL_PORT}}\" >/dev/null 2>&1; if [ $? = 0 ]; then echo PARITY_DIRECT_LEAKED; exit 1; fi; echo PARITY_DIRECT_BLOCKED_OK; if ! curl -fsSL --max-time 15 http://mxc-test.invalid/ 2>/dev/null | grep -q MXC_TEST_ORIGIN_OK; then echo PARITY_PROXIED_FETCH_FAILED; exit 1; fi; echo PARITY_PROXIED_FETCH_OK'" + "commandLine": "bash -c 'set -u; pu=\"${HTTP_PROXY:-${http_proxy:-}}\"; if [ -z \"$pu\" ]; then echo PARITY_NO_PROXY_ENV; exit 1; fi; echo PARITY_PROXY_ENV_OK; pa=\"${pu#*://}\"; pa=\"${pa%%/*}\"; ph=\"${pa%%:*}\"; pp=\"${pa##*:}\"; if [ \"$pp\" = \"$ph\" ]; then pp=80; fi; if ! timeout 6 bash -c \"exec 3<>/dev/tcp/$ph/$pp\" >/dev/null 2>&1; then echo PARITY_PROXY_UNREACHABLE; exit 1; fi; echo PARITY_PROXY_REACHABLE_OK; timeout 6 bash -c \"exec 3<>/dev/tcp/10.0.2.2/{{CONTROL_PORT}}\" >/dev/null 2>&1; if [ $? = 0 ]; then echo PARITY_DIRECT_LEAKED; exit 1; fi; echo PARITY_DIRECT_BLOCKED_OK; if ! timeout 15 bash -c \"exec 3<>/dev/tcp/$ph/$pp; printf \\\"GET http://mxc-test.invalid/ HTTP/1.1\\\\r\\\\nHost: mxc-test.invalid\\\\r\\\\nConnection: close\\\\r\\\\n\\\\r\\\\n\\\" >&3; grep -q MXC_TEST_ORIGIN_OK <&3\"; then echo PARITY_PROXIED_FETCH_FAILED; exit 1; fi; echo PARITY_PROXIED_FETCH_OK'" }, "network": { "egress": { diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index 497eacca2..3d15ed44b 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -1069,7 +1069,7 @@ function Get-LoopbackFetchCommand { # Phase 8 — directional network policy. # # Asserts the documented contract (docs/process-container/networking.md and -# docs/sandbox-policy/0.8.0/networking/networking.md), not the current code, so +# docs/schema.md), not the current code, so # an assertion that outruns the backend fails by design. Every positive is # paired with a negative control on an otherwise identical config: from one run # on a host with no connectivity, "reached it" and "blocked by policy" look the diff --git a/tests/scripts/run_bwrap_directional_test.sh b/tests/scripts/run_bwrap_directional_test.sh index fe1a0933b..1c3f58617 100644 --- a/tests/scripts/run_bwrap_directional_test.sh +++ b/tests/scripts/run_bwrap_directional_test.sh @@ -369,8 +369,9 @@ echo "PASS: directional port narrowing" # 3. Proxy-only policy with implicit and explicit ingress denial # --------------------------------------------------------------------------- # Both fixtures use the supported runtime proxy, but one omits ingress and the -# other explicitly denies it. Both must enforce proxy-only egress. The first -# fixture retains its historical filename; it no longer uses legacy fields. +# other explicitly denies it. Both must enforce proxy-only egress. The +# repository-owned Bash probe sends an absolute-form HTTP request through the +# injected proxy endpoint, so this check does not depend on a guest curl binary. # # The test proxy already running on 127.0.0.1:$LISTENER_PORT doubles as the # proxy here; the parser requires a loopback endpoint, and the backend @@ -391,6 +392,8 @@ run_parity() { printf '%s\n' "$out" >"$WORK_DIR/$label.parity.out" if [ "$rc" -ne 0 ]; then printf '%s\n' "$out" >&2 + echo "External test proxy log:" >&2 + cat "$WORK_DIR/listener.log" >&2 echo "FAIL: proxy-only ingress parity ($label returned $rc)" >&2 return 1 fi @@ -398,7 +401,7 @@ run_parity() { } echo "Running Bubblewrap directional test: proxy-only ingress parity..." -run_parity "implicit-ingress" "bubblewrap_network_proxy_parity_legacy.json" || exit 1 +run_parity "implicit-ingress" "bubblewrap_network_proxy_parity_implicit_ingress.json" || exit 1 run_parity "explicit-ingress" "bubblewrap_network_directional_proxy.json" || exit 1 IMPLICIT_MARKS="$(cat "$WORK_DIR/implicit-ingress.marks")" EXPLICIT_MARKS="$(cat "$WORK_DIR/explicit-ingress.marks")" diff --git a/tests/scripts/run_lxc_network_no_network_test.sh b/tests/scripts/run_lxc_network_no_network_test.sh index 05f92fd0b..586523bb9 100644 --- a/tests/scripts/run_lxc_network_no_network_test.sh +++ b/tests/scripts/run_lxc_network_no_network_test.sh @@ -2,9 +2,8 @@ # LXC omitted-network-section test # # Proves that a request with the `network` section omitted entirely receives -# the directional deny defaults stated in the contract: the workload cannot reach -# the network. The contract is docs/sandbox-policy/0.8.0/policy.md: "A schema -# 0.8 policy with no network fields selects directional deny defaults." +# the directional deny defaults in docs/schema.md: the workload cannot reach +# the network. # # The first run is the positive control. It sends the same workload and probe # with an explicit egress allow rule. If that run cannot reach the destination, @@ -112,7 +111,7 @@ run_config "positive control: request with explicit egress allow to $PROBE_ADDRE assert_allowed "an explicitly allowed destination was unreachable on the positive control. The second run's blocked result would prove nothing, so this test fails rather than proceeding." run_config "case under test: request with network section omitted entirely" "$OMIT_CONFIG" -assert_blocked "the workload reached $PROBE_ADDRESS under a request with no network section. The contract (docs/sandbox-policy/0.8.0/policy.md) states that omitted permissions remain denied and a 0.8 policy with no network fields selects directional deny defaults." +assert_blocked "the workload reached $PROBE_ADDRESS under a request with no network section. The supported contract (docs/schema.md) states that omitted network policy selects directional deny defaults." echo "PASS: a request with the network section omitted cannot reach the network." echo "LXC omitted-network-section test complete."