diff --git a/.github/workflows/lxc-e2e.yml b/.github/workflows/lxc-e2e.yml new file mode 100644 index 000000000..4b1b89b0a --- /dev/null +++ b/.github/workflows/lxc-e2e.yml @@ -0,0 +1,139 @@ +name: LXC E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + lxc-e2e: + name: LXC-Exec Container and Network Policy + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust toolchain + run: rustup update stable + + - name: Point cargo at the MxcDependencies feed + uses: ./.github/actions/setup-cargo-feed + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: src + + - name: Install LXC and firewall tooling + run: | + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ + lxc lxc-templates lxc-utils iptables debootstrap uidmap bridge-utils + + # A bridged veth only reaches the FORWARD chain while br_netfilter is + # delivering bridged packets to iptables. Without it the firewall rules + # install cleanly and never fire, so the network policy tests would pass + # against a firewall that filters nothing. + - name: Enable bridge netfilter + run: | + sudo modprobe br_netfilter + sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 + sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=1 + + # GitHub-hosted runners ship Docker, and Docker sets the IPv4 FORWARD + # policy to DROP. That breaks these tests twice over. + # + # First, it breaks them outright. MXC hooks its chain on traffic leaving + # the container (`-i ` / `--physdev-in `), so an allowed + # request is accepted on the way out -- but the reply arrives in the + # opposite direction, matches no MXC rule, falls through to the policy, + # and is dropped. The connection times out and an explicitly allowed + # destination looks unreachable. Observed exactly that: DNS resolved, + # because dnsmasq on lxcbr0 is host-local and never traverses FORWARD, + # and then `wget: can't connect to remote host (140.82.116.5)`. + # + # Second, and worse, it would make the deny cases meaningless. Under a + # DROP policy a container with NO working MXC hook at all is also + # unreachable, so the enforcement and deny-precedence tests would report + # success against a firewall that filters nothing -- which is the precise + # bug this suite exists to detect, and the reason these tests carry + # positive controls. + # + # Setting the policy to ACCEPT restores the condition the tests were + # written for: the host forwards by default, so the ONLY thing that can + # block container traffic is a rule MXC installed. A missing hook then + # shows up as an unexpected success and fails the deny case loudly. + # A narrower conntrack RELATED,ESTABLISHED rule is not an alternative + # here. The chain already carries return rules in both the interface and + # the physdev form, and both were measured inert on this bridged + # topology: a reply is routed toward lxcbr0, so the bridge port is not + # selected when FORWARD runs and neither form matches. Scoping the return + # direction by the container's address is the fix, and it is deferred -- + # it needs the address plumbed through to the manager and a live bridged + # measurement, not another untested rule. + - name: Let the host forward, so only MXC rules can block + run: | + sudo iptables -P FORWARD ACCEPT + sudo ip6tables -P FORWARD ACCEPT + sudo iptables -S FORWARD | head -5 + + - name: Report the environment these tests depend on + run: | + echo "--- kernel ---" + uname -a + echo "--- lxc ---" + lxc-create --version || echo "MISSING lxc-create" + echo "--- iptables ---" + sudo iptables --version || echo "MISSING iptables" + sudo ip6tables --version || echo "MISSING ip6tables" + echo "--- forward policy (must be ACCEPT, or deny cases pass vacuously) ---" + sudo iptables -S FORWARD | head -1 + sudo ip6tables -S FORWARD | head -1 + echo "--- bridge netfilter ---" + cat /proc/sys/net/bridge/bridge-nf-call-iptables || echo "MISSING bridge-nf-call-iptables" + cat /proc/sys/net/bridge/bridge-nf-call-ip6tables || echo "MISSING bridge-nf-call-ip6tables" + echo "--- host ipv6 ---" + cat /proc/net/if_inet6 || echo "no /proc/net/if_inet6 (IPv6 disabled)" + + - name: Build lxc-exec + working-directory: src + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: cargo build --release -p lxc --bin lxc-exec + + # MXC_LXC_TESTS_REQUIRE_EXECUTION turns an honest skip into a failure. + # On a developer box a missing ip6tables is a reason to run what you can. + # Here the runner is provisioned specifically to execute this suite, so a + # skip means a prerequisite disappeared and the gate would go green while + # testing nothing. + - name: Run LXC E2E suite + env: + MXC_LXC_TESTS_REQUIRE_EXECUTION: "1" + run: sudo --preserve-env=MXC_LXC_TESTS_REQUIRE_EXECUTION bash tests/scripts/run_lxc_all_tests.sh + + - name: Show leftover firewall state on failure + if: failure() + run: | + echo "--- FORWARD chain ---" + sudo iptables -S FORWARD || true + sudo ip6tables -S FORWARD || true + echo "--- MXC chains ---" + sudo iptables -S | grep -E '^-N MXC-' || echo "none" + sudo ip6tables -S | grep -E '^-N MXC-' || echo "none" + + - name: Upload logs on failure + if: failure() || cancelled() + uses: actions/upload-artifact@v6 + with: + name: lxc-e2e-logs-${{ github.event.pull_request.number || github.run_number }} + retention-days: 7 + path: | + logs/ + **/*.log diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index 72c186c57..5a204f2a0 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -116,9 +116,49 @@ Network policies are enforced with parallel `iptables` and `ip6tables` chains sc | `defaultPolicy: "block"` | Final DROP rule in the container chain | | `defaultPolicy: "allow"` | Final ACCEPT rule in the container chain | | `allowedHosts` | ACCEPT rules for IP literals, CIDR blocks, or resolved hostnames | -| `blockedHosts` | DROP rules for IP literals, CIDR blocks, or resolved hostnames | - -`allowedHosts` and `blockedHosts` entries may be bare IPv4/IPv6 literals, IPv4/IPv6 CIDR blocks, or hostnames. Hostnames are resolved to both A and AAAA records; IPv4 destinations are applied to the `iptables` chain and IPv6 destinations are applied to the `ip6tables` chain. Entries whose CIDR prefix is out of range for its family (or otherwise malformed) are reported as unresolved and skipped, leaving the rest of the policy in force. Host-list rules match all ports and protocols; port- and protocol-specific egress rules are not supported. +| `blockedHosts` | DROP rules for IP literals, CIDR blocks, or resolved hostnames, emitted *before* the ACCEPT rules | + +**A deny wins over an overlapping allow.** `iptables` evaluates a chain top to +bottom and stops at the first match, so precedence is decided purely by +emission order. All `blockedHosts` rules are emitted ahead of all +`allowedHosts` rules, which means a destination named by both lists is dropped. +Without that ordering an allow entry broad enough to cover a blocked +destination — `0.0.0.0/0`, or a CIDR containing the blocked address — silently +defeats the block, and the resulting chain looks fully populated while +filtering nothing. + +Two limits on that guarantee are worth stating plainly, because "deny always +wins" is not true without them: + +- **DNS is exempt.** The base chain accepts UDP and TCP destination port 53 + unconditionally and is installed ahead of the generated policy rules, so + port-53 traffic to a blocked destination is accepted before its DROP rule is + reached. Narrowing that rule needs to know which resolver addresses are + legitimate, and no schema field carries them today. +- **A hostname in both lists is resolved twice.** Each list entry is resolved + independently, so a name behind round-robin DNS can return one address for + the `blockedHosts` entry and a different one for the `allowedHosts` entry. + The guarantee holds for *addresses*, not for names. Use literal IPs or CIDRs + when a destination must be denied deterministically. + +`allowedHosts` and `blockedHosts` entries may be bare IPv4/IPv6 literals, IPv4/IPv6 CIDR blocks, or hostnames. Hostnames are resolved to both A and AAAA records; IPv4 destinations are applied to the `iptables` chain and IPv6 destinations are applied to the `ip6tables` chain. Host-list rules match all ports and protocols; port- and protocol-specific egress rules are not supported. + +An entry that resolves to nothing — an unknown hostname, or a CIDR prefix out +of range for its family — cannot be turned into a rule. What that costs +depends on the entry and on `defaultPolicy`: + +| Entry | `defaultPolicy` | Behavior | +|-------|-----------------|----------| +| `allowedHosts` | either | Reported as unresolved and skipped. Failing to write an ACCEPT rule can only make the policy more restrictive | +| `blockedHosts` | `block` | Reported as unresolved and skipped. The closing DROP already denies the destination, so the unwritten rule was redundant | +| `blockedHosts` | `allow` | **Fails firewall setup.** The chain ends in ACCEPT, so the unwritten DROP was the only thing that would have denied that destination, and skipping it silently converts a deny into an allow | + +One gap remains open and is not detected: under `defaultPolicy: "block"`, an +`allowedHosts` entry broad enough to cover a destination whose `blockedHosts` +rule went unwritten still reaches that destination. Detecting it would require +the address the failed entry was *meant* to resolve to, which is by definition +unavailable, so no check over the policy text can be complete — and a partial +check would imply a guarantee this code cannot make. Before programming the IPv6 chain, MXC probes `ip6tables` with a read-only `ip6tables -S` and classifies the result three ways: @@ -130,10 +170,104 @@ Before programming the IPv6 chain, MXC probes `ip6tables` with a read-only `ip6t Host IPv6 activity is read from `/proc/net/if_inet6`: a non-loopback interface with an IPv6 address counts as active, while loopback-only `::1` on `lo` (present even on IPv4-only hosts) does not. If that file cannot be read at all — as opposed to being absent, which means IPv6 is disabled — the state is treated as *unknown* rather than as a confirmed "IPv6 is off", so an unreadable IPv6 state fails closed instead of leaving IPv6 unfiltered. -The chains are hooked into `FORWARD` for container egress by matching the host-side veth as the input interface. If MXC cannot discover the container veth, it skips the `FORWARD` hook with a warning rather than applying host-wide rules. +The chains are hooked into `FORWARD` for container egress with **up to two +rules per family**, because the input interface `FORWARD` sees depends on how +the veth is attached: + +| Attachment | Rule that matches | +|------------|-------------------| +| veth routed directly by the host | `-i ` | +| veth enslaved to a bridge (the default LXC topology) | `-m physdev --physdev-in ` | + +The two are mutually exclusive for any given packet, so nothing is counted +twice. Installing only `-i ` is what previously let a fully populated +deny-all chain sit in the ruleset filtering nothing on the default bridged +topology. + +The `physdev` rule is required only on a bridged veth. On a directly routed +veth a host whose kernel lacks the `physdev` match logs a warning and +continues with the interface rule alone, which is the rule that matches there; +on a bridged veth the same failure is fatal, because `physdev` is the only +rule that could ever match. + +A bridged veth additionally requires `br_netfilter` to be delivering bridged +packets to iptables. With `/proc/sys/net/bridge/bridge-nf-call-iptables` absent +or `0`, both hook rules install cleanly and neither ever fires. MXC reads that +file and **fails firewall setup** rather than reporting success for a chain +that could never be reached. When the IPv6 chain is programmed, +`/proc/sys/net/bridge/bridge-nf-call-ip6tables` is checked separately and to +the same standard. + +If MXC cannot discover the container veth at all, firewall setup **fails** and +the partially created chains are rolled back. An unhooked chain is never +traversed, so reporting success would hand the caller a deny-all chain that +filters nothing — strictly worse than no firewall, because it looks enforced. +Installing the rules host-wide instead is not an option either: unscoped, they +would apply to every container and to the host's own traffic. Firewall state is torn down automatically with best-effort removal of the `FORWARD` hooks and both per-container chains; there is no network-policy opt-out field. Setup failures after partial creation are rolled back before returning an error, so retries do not trip over leftover chains. +### Cooperative proxy + +`network.proxy` puts the container in a "deny all except the proxy" posture: +egress is restricted to the proxy endpoint, and `HTTP_PROXY`/`HTTPS_PROXY` are +injected so a cooperating client uses it. The env vars are the routing hint; +the firewall is the enforcement, so an application that ignores them cannot +reach the internet directly. + +The chain is hooked into `FORWARD`, so what it governs is traffic the host +*routes* on the container's behalf. Traffic addressed to the bridge gateway +itself — where LXC's `dnsmasq` listens, and where a host-local proxy would run +— is delivered locally and traverses `INPUT`, which this chain does not hook. +Closing that path needs an INPUT hook and is tracked separately, so "reaches +nothing" is accurate for forwarded egress and not for host-local destinations. + +Only the `{ "url": "http://proxy.example:8080" }` form is accepted. The LXC +container has its own network namespace, so `{ "localhost": }` names the +*container's* loopback rather than the host's — the injected proxy would be +unreachable and the firewall rule would never match. `{ "builtinTestServer": +true }` is rejected for the same reason, as is a `url` whose host is a loopback +literal. + +Two further constraints are enforced at parse time, both rejections rather than +silent corrections: + +- **`enforcementMode` must be `firewall` or `both`.** Under the default + `capabilities` mode no iptables rules are installed, so the proxy env vars + would be injected while direct egress stayed open — a config that reads as + deny-all-except-proxy and enforces neither half. MXC refuses it rather than + auto-promoting the mode, so a stated enforcement level is never silently + rewritten. +- **The `url` must not carry credentials.** LXC passes the proxy URL to + `lxc-attach` as a `--set-var` argument, and process arguments are + world-readable through `/proc//cmdline`, so inline `user:pass@` would be + visible to every local user for the lifetime of the command. Supply the + credentials to the proxy itself instead. + +The chain a proxied container gets differs from the ordinary one in four ways, +each of which would otherwise be a hole in the posture: + +| Ordinary chain | Proxied chain | Why | +|----------------|---------------|-----| +| Terminal rule follows `defaultPolicy` | Terminal rule is always DROP | An ACCEPT terminal would make the proxy rule above it meaningless | +| Accepts UDP/TCP port 53 | No DNS rule | An unscoped port-53 accept is a standing DNS-tunnel exfil path through a deny-all posture | +| Accepts `-i lo` and `ESTABLISHED,RELATED` | Neither | Neither describes traffic this chain sees, and the conntrack rule would carry flows the proxy never brokered | +| Programs `allowedHosts` and `blockedHosts` | Programs neither | A block entry is redundant under the closing DROP, and an allow entry naming anything but the proxy contradicts the model | + +The IPv6 chain of a proxied container carries its closing DROP and nothing +else, because the proxy rule is emitted with IPv4 `iptables` only. An IPv6 +proxy endpoint is therefore rejected outright rather than silently discarded. + +With DNS closed, a container handed a proxy URL naming a hostname has no +resolver to find it with. MXC resolves the proxy once, when it builds the +firewall rule, and writes that same mapping into the container's `/etc/hosts` +before the script runs — so the name resolves, and it resolves to an address +the chain allows. The URL itself is left alone: rewriting its host to an IP +literal would break SNI and certificate validation for an `https://` proxy. +Every address the proxy host resolved to is opened, since they all belong to +that same proxy. If the hosts entry cannot be written, execution **fails** +rather than running a container whose proxy is unreachable. + ## Usage ### Command Line diff --git a/docs/schema.md b/docs/schema.md index d9cb3dc17..29190ebae 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -58,9 +58,16 @@ production configs and the dev schema when working on experimental features: "proxy": { "localhost": 8080 } // Loopback proxy port (processcontainer; bubblewrap; seatbelt) // (use { "builtinTestServer": true } for the bundled // testing-only proxy; requires --allow-testing-features) - // WSLC supports the cooperative proxy too, but only via - // { "url": "http://proxy.example:8080" } (own-netns: - // localhost/builtinTestServer are unreachable, rejected) + // WSLC and LXC support the cooperative proxy too, but + // only via { "url": "http://proxy.example:8080" } + // (own-netns: localhost/builtinTestServer are + // unreachable, rejected) + // Under LXC the proxy is enforced: forwarded egress is + // restricted to the proxy endpoint and nothing else, so + // the allow/block host lists and DNS are not opened. + // The chain hooks FORWARD, so traffic addressed to the + // bridge gateway itself is delivered locally via INPUT + // and is outside what this chain governs. }, "ui": { diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index d2354eb41..bfd30c602 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -205,7 +205,7 @@ impl BubblewrapScriptRunner { logger, "Bubblewrap: applying iptables rules for host-level network filtering" ); - let mut mgr = NetworkIptablesManager::new(&container_name); + let mut mgr = build_firewall_manager(&container_name); match mgr.apply_firewall_rules(&request.policy, logger) { Ok(true) => {} Ok(false) => { @@ -495,6 +495,23 @@ fn needs_iptables_rules(request: &ExecutionRequest) -> bool { uses_firewall && has_host_rules } +/// Build the iptables manager for a Bubblewrap sandbox. +/// +/// Unprivileged bwrap has no veth to scope a chain to: the sandbox either +/// shares the host network namespace or gets a private one, and neither yields +/// a host-side interface to match on (see `local_network_diagnostic` in +/// `bwrap_command`). A missing veth is therefore structural here, not a failed +/// lookup, so the manager is told not to fail closed on it. Without that, every +/// Bubblewrap sandbox requesting firewall enforcement would refuse to start. +/// +/// This lives in its own function so the declaration is covered by a test; +/// inlined at the call site, deleting it broke nothing that any test could see. +fn build_firewall_manager(container_name: &str) -> NetworkIptablesManager { + let mut mgr = NetworkIptablesManager::new(container_name); + mgr.allow_missing_veth_interface(); + mgr +} + /// 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 { @@ -640,6 +657,21 @@ mod tests { } } + #[test] + fn the_firewall_manager_tolerates_the_veth_bubblewrap_never_has() { + // bwrap never calls set_veth_interface, so the shared manager's + // fail-closed path would refuse every firewall-mode sandbox at startup. + // The manager this backend builds must therefore have declared the + // absence up front. + let mgr = build_firewall_manager("bwrap-cov"); + + assert!( + mgr.veth_scoping_is_optional(), + "Bubblewrap has no veth, so the manager it builds must declare that a \ + missing one is expected -- otherwise firewall-mode sandboxes cannot start" + ); + } + #[test] fn validate_does_not_locally_gate_builtin_test_server() { // The builtinTestServer gate moved to `wxc_common::validator::validate_common` diff --git a/src/backends/lxc/common/src/lxc_bindings.rs b/src/backends/lxc/common/src/lxc_bindings.rs index efbd4ad6e..c6897fb68 100644 --- a/src/backends/lxc/common/src/lxc_bindings.rs +++ b/src/backends/lxc/common/src/lxc_bindings.rs @@ -68,6 +68,16 @@ pub fn resolve_default_lxcpath() -> String { resolve_lxcpath_with_env(|k| std::env::var(k).ok(), current_euid) } +/// Test-only convenience wrapper over [`build_attach_args_with_env_control`] +/// that hardcodes `force_clear_env = false` (the legacy behavior). Kept +/// `#[cfg(test)]`-only because production code always calls the +/// `_with_env_control` variant directly, so compiling this wrapper outside +/// tests would trip the dead-code lint. +#[cfg(test)] +fn build_attach_args(env: &[String], working_directory: &str, command: &str) -> Vec { + build_attach_args_with_env_control(env, working_directory, command, false) +} + /// Build the post-binary argv for `lxc-attach` (the args that follow the /// `-n NAME -P lxcpath` flags already appended by `lxc_command`). /// @@ -75,11 +85,20 @@ pub fn resolve_default_lxcpath() -> String { /// actually spawning `lxc-attach`. See [`LxcContainer::attach_run`] for /// the full contract. /// +/// `force_clear_env` forces `--clear-env` even when `env` is empty, so a +/// fully-scrubbed proxy env can't silently fall back to inheriting the +/// host's variables. +/// /// Gated to Linux + test builds because `attach_run` is a Windows stub /// that never calls this helper, and the workspace clippy lane on /// `windows-latest` would otherwise flag it as dead code. #[cfg(any(target_os = "linux", test))] -fn build_attach_args(env: &[String], working_directory: &str, command: &str) -> Vec { +fn build_attach_args_with_env_control( + env: &[String], + working_directory: &str, + command: &str, + force_clear_env: bool, +) -> Vec { // Loose upper bound; realloc-avoidance hint only. let mut args: Vec = Vec::with_capacity(env.len() + 8); @@ -87,7 +106,7 @@ fn build_attach_args(env: &[String], working_directory: &str, command: &str) -> // slate, even if every entry is malformed. Matches Seatbelt exactly // and is the posture lxc-attach(1) recommends for sandbox callers. // See `attach_run` doc for the full contract. - if !env.is_empty() { + if force_clear_env || !env.is_empty() { args.push("--clear-env".to_string()); for kv in env { // Well-formed = "KEY=VAL" with a non-empty KEY. `"=foo"` and @@ -294,7 +313,11 @@ impl LxcContainer { /// and are outside this function's control. /// /// When `env` is empty, the legacy keep-env behavior is preserved so - /// existing call sites without explicit env are undisturbed. + /// existing call sites without explicit env are undisturbed unless + /// `force_clear_env` is true. The LXC runner uses `force_clear_env` + /// after proxy-env scrubbing removes every caller-supplied proxy entry; + /// that still must clear inherited proxy variables instead of falling + /// back to keep-env mode. /// /// We pass `unblock_signals = [SIGHUP, SIGTERM, SIGINT]` because /// [`crate::signal_cleanup::install`] blocks them in this process so @@ -314,6 +337,7 @@ impl LxcContainer { command: &str, working_directory: &str, env: &[String], + force_clear_env: bool, timeout: Option, ) -> Result<(i32, String, String), String> { use mxc_pty::{run_with_pty, PtyOptions, PtyOutcome, Signal}; @@ -321,7 +345,12 @@ impl LxcContainer { const UNBLOCK: &[Signal] = &[Signal::SIGHUP, Signal::SIGTERM, Signal::SIGINT]; let mut cmd = self.lxc_command("lxc-attach"); - cmd.args(build_attach_args(env, working_directory, command)); + cmd.args(build_attach_args_with_env_control( + env, + working_directory, + command, + force_clear_env, + )); let options = PtyOptions { unblock_signals: UNBLOCK, @@ -348,6 +377,7 @@ impl LxcContainer { _command: &str, _working_directory: &str, _env: &[String], + _force_clear_env: bool, _timeout: Option, ) -> Result<(i32, String, String), String> { Err("LxcContainer::attach_run is only supported on Linux".to_string()) @@ -746,6 +776,12 @@ mod tests { ); } + #[test] + fn build_attach_args_can_force_clear_env_when_env_empty() { + let args = build_attach_args_with_env_control(&[], "", "cmd", true); + assert_eq!(args, vec!["--clear-env", "--", "/bin/sh", "-c", "cmd"]); + } + #[test] fn build_attach_args_clears_env_even_when_all_entries_malformed() { // Caller opted into env control by populating the field. Even if @@ -780,4 +816,101 @@ mod tests { args ); } + + // ── End-to-end: proxy policy → env → attach args ───────────────────────── + // These tests drive apply_proxy_env then build_attach_args_with_env_control + // together so the observable output (the lxc-attach argv) is what is + // asserted, not just an intermediate bool. + + #[test] + fn proxy_disabled_with_empty_request_env_emits_clear_env_in_attach_args() { + // Regression: before the fix, apply_proxy_env returned false for an + // empty env slice, so force_clear_env was false, env was empty, both + // disjuncts of `force_clear_env || !env.is_empty()` were false, and + // --clear-env was never added. lxc-attach then inherited the full MXC + // host process environment — including HTTP_PROXY, HTTPS_PROXY, and + // any credentials or tokens present on CI agents. + use wxc_common::{models::ProxyConfig, proxy_env::apply_proxy_env}; + let mut env: Vec = vec![]; + let force_clear = apply_proxy_env(&mut env, &ProxyConfig::default()); + let args = build_attach_args_with_env_control(&env, "", "cmd", force_clear); + assert!( + args.iter().any(|a| a == "--clear-env"), + "proxy disabled + empty env must emit --clear-env to prevent host \ + environment leak; got {args:?}" + ); + } + + #[test] + fn proxy_disabled_non_proxy_env_emits_clear_env_and_preserves_non_proxy_vars() { + // Non-proxy vars survive the scrub; --clear-env is emitted. + // This was already correct before the fix (non-empty env triggered + // --clear-env via the !env.is_empty() arm) — this test guards against + // regressing that direction. + use wxc_common::{models::ProxyConfig, proxy_env::apply_proxy_env}; + let mut env = vec!["PATH=/usr/bin".to_string()]; + let force_clear = apply_proxy_env(&mut env, &ProxyConfig::default()); + let args = build_attach_args_with_env_control(&env, "", "cmd", force_clear); + assert!( + args.iter().any(|a| a == "--clear-env"), + "proxy disabled + non-proxy env must emit --clear-env; got {args:?}" + ); + assert!( + args.iter().any(|a| a == "--set-var=PATH=/usr/bin"), + "PATH must survive the proxy scrub; got {args:?}" + ); + } + + #[test] + fn proxy_disabled_http_proxy_env_is_removed_and_clear_env_emitted() { + // A caller-supplied HTTP_PROXY must be scrubbed AND --clear-env emitted + // so the sandbox cannot reach an egress path the policy never authorized. + use wxc_common::{models::ProxyConfig, proxy_env::apply_proxy_env}; + let mut env = vec![ + "HTTP_PROXY=http://attacker.example:9999".to_string(), + "PATH=/usr/bin".to_string(), + ]; + let force_clear = apply_proxy_env(&mut env, &ProxyConfig::default()); + let args = build_attach_args_with_env_control(&env, "", "cmd", force_clear); + assert!( + args.iter().any(|a| a == "--clear-env"), + "proxy disabled + HTTP_PROXY must emit --clear-env; got {args:?}" + ); + assert!( + !args.iter().any(|a| a.contains("attacker.example")), + "HTTP_PROXY value must not appear in args; got {args:?}" + ); + assert!( + args.iter().any(|a| a == "--set-var=PATH=/usr/bin"), + "PATH must survive the proxy scrub; got {args:?}" + ); + } + + #[test] + fn proxy_enabled_emits_clear_env_and_proxy_keys_in_attach_args() { + use wxc_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()]; + let force_clear = apply_proxy_env(&mut env, &proxy); + let args = build_attach_args_with_env_control(&env, "", "cmd", force_clear); + 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:?}" + ); + } } diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index 002ce5445..381c1e326 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -20,6 +20,11 @@ use crate::lxc_bindings::LxcContainer; use crate::network_iptables::NetworkIptablesManager; use crate::signal_cleanup; +/// Comment marker on every `/etc/hosts` line this runner writes, so a later +/// run can strip its own previous entries without disturbing the +/// distribution's. +const HOSTS_PIN_MARKER: &str = "#mxc-proxy-pin"; + /// Script runner that executes commands inside an LXC container. pub struct LxcScriptRunner { config: LxcConfig, @@ -126,6 +131,39 @@ impl LxcScriptRunner { } let container_name = self.resolve_container_name(); + // 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 + // runner a policy the parser never saw. Below, `apply_proxy_env` sets + // HTTP(S)_PROXY to `to_url()`, which returns the original URL verbatim, + // and `build_attach_args_with_env_control` turns every environment + // entry into a `--set-var=KEY=VALUE` argument of the `lxc-attach` + // process this backend spawns (lxc_bindings.rs). A process's argv is + // readable through /proc//cmdline by any local user for the + // lifetime of the command. The check sits ahead of container creation + // and firewall programming so a rejected request leaves no state + // behind. + if let Some(url) = request + .policy + .network_proxy + .address + .as_ref() + .map(|address| address.to_url()) + { + if wxc_common::proxy_env::proxy_url_has_credentials(&url) { + // Built from the redacted form so the rejection cannot become + // the leak it is rejecting. + return 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.", + wxc_common::proxy_env::redact_proxy_url(&url) + )); + } + } // Make the name visible to the signal-cleanup watchdog so a fatal // signal during create/start/attach still tears the container down — // but only when the caller actually wants the container destroyed at @@ -193,12 +231,13 @@ impl LxcScriptRunner { } // Wait for network only when the config uses network features (firewall rules - // or allowed/blocked hosts). + // or allowed/blocked hosts), or when the container must reach a proxy. let needs_network = matches!( request.policy.network_enforcement_mode, NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both ) || !request.policy.allowed_hosts.is_empty() - || !request.policy.blocked_hosts.is_empty(); + || !request.policy.blocked_hosts.is_empty() + || request.policy.network_proxy.is_enabled(); if needs_network { Self::wait_for_network(&container_name, Duration::from_secs(10), logger); @@ -234,6 +273,72 @@ impl LxcScriptRunner { } } + // Pin the proxy hostname to the address the firewall just authorized. + // + // A proxied chain opens no port 53, so the container has no resolver to + // find its proxy with, and even with one it could pick an address the + // chain does not allow. Failing here is fatal rather than a warning: + // without the pin the proxy is unreachable, so the script would run + // against a container that can reach nothing. + 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_run(&command, "/", &[], true, None); + let pin_error = match pin_outcome { + Ok((0, _, _)) => None, + Ok((code, _, stderr)) => Some(format!( + "writing /etc/hosts exited with {}: {}", + code, + stderr.trim() + )), + Err(e) => Some(e.to_string()), + }; + if let Some(reason) = pin_error { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return 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 + )); + } + } else if !container_created { + // This run pins nothing, but a container it did not create may + // still carry a pin from an earlier one. Leaving it would let a + // hostname resolve to the address a previous policy authorized + // while this policy is written against whatever it resolves to + // now, so a deny could be programmed for one address and evaded at + // another. Removing it is therefore part of applying the policy, + // and failing to remove it is a failure to apply the policy. + let unpin = Self::build_hosts_unpin_command(); + let unpin_error = match container.attach_run(&unpin, "/", &[], true, None) { + Ok((0, _, _)) => None, + Ok((code, _, stderr)) => Some(format!( + "clearing /etc/hosts exited with {}: {}", + code, + stderr.trim() + )), + Err(e) => Some(e.to_string()), + }; + if let Some(reason) = unpin_error { + if self.destroy_on_exit || container_created { + let _ = container.destroy(); + } + return ScriptResponse::error(&format!( + "Failed to clear a previous run's proxy host pin from the container: {}. \ + A stale pin can redirect a hostname this policy resolved separately, \ + so the script was not run.", + reason + )); + } + } + // Execute the script using lxc-attach (container is already running). // `script_timeout == 0` means "no timeout" per the SDK contract. let timeout = if request.script_timeout == 0 { @@ -242,10 +347,19 @@ impl LxcScriptRunner { Some(Duration::from_millis(u64::from(request.script_timeout))) }; let _ = writeln!(logger, "Executing script inside container..."); + let mut exec_env = request.env.clone(); + // Scrub every inherited proxy variable and, when the policy carries a + // proxy, point HTTP(S)_PROXY at it. The returned flag is what makes the + // scrub effective: with an empty env `lxc-attach` would otherwise fall + // back to keep-env mode and inherit the MXC host process environment, + // proxy variables and credentials included. + let force_clear_env = + wxc_common::proxy_env::apply_proxy_env(&mut exec_env, &request.policy.network_proxy); let result = container.attach_run( &request.script_code, &request.working_directory, - &request.env, + &exec_env, + force_clear_env, timeout, ); @@ -275,6 +389,133 @@ impl LxcScriptRunner { response } + + /// Build the shell command that installs `hosts_line` into the + /// container's `/etc/hosts`. + /// + /// Idempotent: a container reused across runs (`destroy_on_exit = false`) + /// would otherwise accumulate entries, and the *first* match wins in a + /// hosts file, so a stale line would shadow the current pin. Every + /// previously written entry is stripped by its marker before the new one + /// is appended. + /// + /// The file is rewritten in place rather than with `mv`, because LXC may + /// bind-mount `/etc/hosts`; replacing the inode would leave the container + /// still reading the old file. Only `grep` and `printf` are used, so this + /// runs under BusyBox as well as coreutils. + /// + /// The kept lines are staged in a shell variable rather than a scratch + /// file. An earlier form wrote them to `/tmp/.mxc-hosts` first, but `/tmp` + /// belongs to the container: on a container reused across runs, a previous + /// workload can leave that predictable name as a symlink, and `>` follows + /// symlinks. This command runs privileged through `lxc-attach`, so the + /// redirect would truncate and overwrite whatever the link pointed at -- + /// another container file, or a host path exposed through a writable bind + /// mount. A variable has no name in the filesystem to hijack. + /// + /// Staging in a variable also removes a failure window rather than adding + /// one. The substitution completes before the redirect opens `/etc/hosts`, + /// so the only commands running against the truncated file are `printf` + /// builtins operating on text already in memory. + /// + /// The line is single-quoted, which is safe by construction: + /// `ProxyHostPin` can only be built from a validated hostname and a parsed + /// [`std::net::IpAddr`], so it cannot contain a quote, a space, or a + /// newline. + fn build_hosts_pin_command(hosts_line: &str) -> String { + // `$(...)` strips trailing newlines, so the kept text is re-emitted + // with an explicit one and the guard keeps an empty result from + // becoming a blank first line. The group's exit status is the final + // printf's, so a grep that matches nothing and exits 1 does not fail + // the command. + 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 + ) + } + + /// Read the existing `/etc/hosts` into `$kept`, or abort before anything + /// opens the file for writing. + /// + /// `> /etc/hosts` truncates the moment it is opened, so every reason the + /// read could fail has to be settled first. The original form discarded + /// grep's status entirely: with `grep` absent, `/etc/hosts` unreadable, or + /// the binary killed, `$kept` came back empty, the redirect truncated the + /// file, and the closing `printf` exited 0 -- so the runner recorded a + /// successful pin over a hosts file it had just emptied of every entry the + /// image shipped. + /// + /// Only status 0 (lines kept) and status 1 (nothing kept) are outcomes. + /// Status 1 is legitimate and common: an empty file, or a re-pin where + /// every existing line carries the marker. Anything above 1 is a failed + /// read, and `127` additionally covers a missing `grep`. A missing file is + /// separated out first, because grep cannot distinguish "absent" from + /// "unreadable" -- both are status 2 -- and an image that ships no + /// `/etc/hosts` has no content to protect. + /// + /// Two gaps survive this guard, and neither is closable while the command + /// is restricted to `grep` and `printf` for BusyBox: + /// + /// * A successful read still loses NUL bytes, because a shell variable + /// cannot hold them. A hosts file containing one would be rewritten + /// truncated at that byte with status 0, and no assertion here would + /// notice. A NUL in `/etc/hosts` is malformed to begin with, and every + /// alternative -- a scratch file, `sed`, `awk` -- reintroduces either the + /// symlink target this design removed or a dependency BusyBox may lack. + /// + /// * A symlink *swapped in* between the `-h` test and the redirect is + /// still followed. That is a genuine race, and closing it needs an + /// open-once-and-rewrite primitive -- `openat` with `O_NOFOLLOW` inside + /// the container's mount namespace -- which is a Rust-side change rather + /// than a shell one. What the `-h` test does close is the case that + /// needs no race at all: a workload reused across runs can *leave* + /// `/etc/hosts` as a symlink, and a dangling one used to be the worst + /// shape of all, because it failed `-e`, skipped the read, and then had + /// its target created by the redirect -- a write to an attacker-named + /// path, which on a writable host bind mount lands outside the + /// container. + fn hosts_read_prologue() -> String { + format!( + "if [ -h /etc/hosts ]; then \ + printf 'mxc: refusing to rewrite /etc/hosts: it is a symbolic link\\n' >&2; \ + exit 4; \ + fi; \ + kept=''; \ + if [ -e /etc/hosts ]; then \ + kept=$(grep -v '{marker}' /etc/hosts 2>/dev/null); \ + status=$?; \ + if [ \"$status\" -gt 1 ]; then \ + printf 'mxc: refusing to rewrite /etc/hosts: reading it exited %s\\n' \ + \"$status\" >&2; \ + exit \"$status\"; \ + fi; \ + fi; ", + marker = HOSTS_PIN_MARKER + ) + } + + /// Strip every pin this runner has ever written from `/etc/hosts`. + /// + /// Re-pinning is self-cleaning because it filters the marker out before + /// appending, but a run that pins *nothing* never reaches that path. On a + /// container kept alive across runs the previous pin would then survive + /// into a policy that never authorized it, and a hostname the new policy + /// resolves fresh -- to build a DROP rule, say -- would still be reached at + /// the stale address. The deny would be written against one address and + /// evaded at another. + /// + /// Uses the same rewrite-in-place form as the pin, for the same + /// bind-mount reason, and the same variable staging for the same + /// symlink reason. + fn build_hosts_unpin_command() -> String { + format!( + "{}{{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; }} > /etc/hosts", + Self::hosts_read_prologue() + ) + } } impl ScriptRunner for LxcScriptRunner { @@ -326,4 +567,649 @@ mod tests { let name = runner.resolve_container_name(); assert!(name.starts_with("mxc-")); } + + // The pin is worthless if the mapping it was built from is not the one + // that lands in the file. + #[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}" + ); + } + + // A container reused across runs would otherwise accumulate entries, and + // the first match in a hosts file wins -- so a stale line would shadow the + // pin this run just authorized. + #[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}" + ); + assert!( + command.matches(HOSTS_PIN_MARKER).count() >= 2, + "the written line must carry the marker that the strip looks for; got: {command}" + ); + } + + #[test] + fn the_hosts_unpin_command_removes_the_marker_without_writing_a_new_one() { + let command = LxcScriptRunner::build_hosts_unpin_command(); + + assert!( + command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), + "the command must filter out every marked line; got: {command}" + ); + // This used to assert the command contained no `printf` at all, which + // worked only while `printf` was the sole way a line could be written. + // Re-emitting the *kept* lines now needs one, so the ban would fail on + // a command that adds nothing. The marker count below is the invariant + // the ban was standing in for, and states it directly: the marker's one + // appearance is inside the filter, so no marked line can be written. + assert_eq!( + command.matches(HOSTS_PIN_MARKER).count(), + 1, + "the marker should appear only in the filter; got: {command}" + ); + } + + #[test] + fn the_hosts_unpin_command_rewrites_the_file_in_place_rather_than_replacing_it() { + // Same bind-mount reasoning as the pin: replacing the inode would leave + // the container still reading the file that carries the stale pin. + let command = LxcScriptRunner::build_hosts_unpin_command(); + + assert!( + command.contains("> /etc/hosts"), + "the command must rewrite the existing file; got: {command}" + ); + assert!( + !command.contains("mv "), + "the command must not replace the inode; got: {command}" + ); + } + + // LXC may bind-mount /etc/hosts. Replacing the inode with `mv` would leave + // the container reading the file it had before. + #[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}" + ); + } + + // Everything the pin needs must exist in a minimal image; a container + // built on BusyBox has no coreutils to fall back on. + #[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}" + ); + } + } + + // `/tmp` is the container's, and this command runs privileged through + // `lxc-attach`. On a container reused across runs a previous workload can + // pre-create any predictable name there as a symlink, and `>` follows + // symlinks -- so a scratch file would let it aim a privileged truncating + // write at another container file or at a host path exposed through a + // writable bind mount. Neither command may stage anything in a directory + // the container can write. + #[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}" + ); + } + } + + // Staging in a variable is only an improvement if the content is complete + // before the target is truncated. `>` truncates as the redirect opens, so + // any command that still had to *produce* content after that point would + // leave the container with an empty /etc/hosts if it failed. + #[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}" + ); + } + } + + // The parser rejects a credential-bearing proxy URL, but `ExecutionRequest` + // and `ProxyAddress::from_url` are public: a caller can build a request the + // parser never saw and hand it straight to this runner. These tests take + // that path deliberately -- no parser anywhere in them -- because a guard + // that only exists on the parse path does not protect the process spawn. + use wxc_common::models::{ProxyAddress, ProxyConfig}; + + fn request_with_proxy_url(url: &str) -> ExecutionRequest { + let mut request = ExecutionRequest::default(); + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::from_url( + url, + "proxy.example.com".to_string(), + 8080, + )), + builtin_test_server: false, + }; + request + } + + fn runner_for_guard_tests() -> LxcScriptRunner { + let config = LxcConfig { + distribution: "alpine".to_string(), + release: "3.23".to_string(), + }; + LxcScriptRunner::new(&config, "mxc-guard-test", &LifecycleConfig::default()) + } + + #[test] + fn a_directly_built_request_with_proxy_credentials_is_refused() { + let runner = runner_for_guard_tests(); + let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + 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 \ + never saw the request, got: {}", + response.error_message + ); + } + + // The rejection is built from the redacted URL so the guard cannot become + // the leak it exists to prevent -- the message travels to logs and to the + // caller. + #[test] + fn the_runner_refusal_does_not_echo_the_password() { + let runner = runner_for_guard_tests(); + let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let response = runner.run_internal(&request, &mut logger); + + assert!( + !response.error_message.contains("hunter2"), + "the password leaked into the refusal: {}", + response.error_message + ); + assert!( + !response.error_message.contains("alice:hunter2"), + "the userinfo leaked into the refusal: {}", + response.error_message + ); + assert!( + !logger.get_buffer().contains("hunter2"), + "the password leaked into the log buffer" + ); + } + + // Anti-vacuity: without this, a guard that refused every proxy would pass + // both tests above while breaking every legitimate proxy configuration. + // The run cannot succeed here (there is no live container), so the + // assertion is that it does not fail *for this reason*. + #[test] + fn a_credential_free_proxy_url_is_not_refused_by_the_credential_guard() { + let runner = runner_for_guard_tests(); + let request = request_with_proxy_url("http://proxy.example.com:8080"); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + 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 + ); + } + + // The guard runs ahead of container creation and firewall programming, so a + // rejected request leaves nothing to clean up. A container name in the log + // would mean the runner had already started announcing work it must not do. + #[test] + fn the_credential_refusal_happens_before_any_container_work() { + let runner = runner_for_guard_tests(); + let request = request_with_proxy_url("http://alice:hunter2@proxy.example.com:8080"); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let _ = runner.run_internal(&request, &mut logger); + + let log = logger.get_buffer(); + assert!( + !log.contains("Container name:"), + "the guard must return before the runner starts container work, log was: {log}" + ); + assert!( + !log.contains("Creating LXC container"), + "the guard must return before container creation, log was: {log}" + ); + } +} + +/// The generated hosts commands, executed rather than pattern-matched. +/// +/// Every hosts test in `tests` above asserts on the command *string*. No +/// string assertion can separate a command that preserves `/etc/hosts` from +/// one that empties it -- both contain `> /etc/hosts`, and the truncation +/// defect these tests exist to pin was invisible to all six of them. Running +/// the command under a real `/bin/sh` is what makes the difference +/// observable. +#[cfg(all(test, unix))] +mod hosts_command_execution { + use super::*; + use std::path::{Path, PathBuf}; + + /// What a container image ships before anything pins a proxy. + 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"; + + /// A private directory that removes itself, so a failing test cannot leave + /// a hosts fixture behind for the next run to find. + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "mxc-hosts-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the system clock should be after the unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("the scratch directory should be creatable"); + Self { dir } + } + + fn hosts(&self) -> PathBuf { + self.dir.join("hosts") + } + + fn write_hosts(&self, contents: &str) { + std::fs::write(self.hosts(), contents).expect("the fixture should be writable"); + } + + fn read_hosts(&self) -> String { + std::fs::read_to_string(self.hosts()).expect("the fixture should be readable") + } + + /// A `PATH` carrying a `grep` that fails with `status`, so the read can + /// be broken without breaking the shell around it. `printf` and `[` are + /// builtins and survive the override; everything else still resolves + /// through the inherited `PATH` behind the shim. + fn path_with_failing_grep(&self, status: i32) -> 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, format!("#!/bin/sh\nexit {status}\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() + ) + } + + /// A `PATH` with no `grep` on it at all, which is how a BusyBox image + /// missing the applet fails: the shell cannot find the binary and + /// reports 127. + 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"); + empty.display().to_string() + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + /// Point a generated command at a scratch file. Only the path moves: the + /// existence check, the read, the status guard, and the redirect are the + /// generated text unmodified. That the real target is `/etc/hosts` is + /// pinned separately, by the string tests above. + fn retarget(command: &str, hosts: &Path) -> String { + command.replace( + "/etc/hosts", + hosts.to_str().expect("the scratch path should be utf-8"), + ) + } + + fn run(command: &str, path: Option<&str>) -> i32 { + let mut shell = std::process::Command::new("/bin/sh"); + shell.arg("-c").arg(command); + if let Some(path) = path { + shell.env("PATH", path); + } + shell + .output() + .expect("/bin/sh should be executable") + .status + .code() + .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"); + 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}" + ); + } + + // The first match in a hosts file wins, so a pin left over from a previous + // run on a reused container would shadow the one this run authorized. + #[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(); + + 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}" + ); + } + + // The defect this module was written for. `> /etc/hosts` truncates the + // instant it is opened, so a read that failed has to stop the command + // before the redirect -- not merely produce nothing to write back. + #[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" + ); + } + + // A missing `grep` is status 127, not 2, and is the likelier failure on a + // stripped image -- the same class, reached by a different route. + #[test] + fn a_missing_grep_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)); + + assert_eq!( + scratch.read_hosts(), + ORIGINAL, + "a missing grep truncated the file" + ); + assert_ne!(code, 0, "a missing grep must fail the command"); + } + + // Unpinning writes back only what it read, so a failed read there empties + // the file outright rather than reducing it to one line. + #[test] + fn a_failed_read_while_unpinning_leaves_the_file_byte_for_byte_as_it_was() { + let scratch = Scratch::new("failunpin"); + scratch.write_hosts(ORIGINAL); + let path = scratch.path_with_failing_grep(2); + + let code = run(&unpin(&scratch.hosts()), Some(&path)); + + assert_eq!( + scratch.read_hosts(), + ORIGINAL, + "a failed read emptied the file it could not read" + ); + assert_ne!(code, 0, "a failed read must fail the unpin"); + } + + // Status 1 means grep selected nothing, which is an outcome and not a + // failure: an empty file, or a re-pin where every line carried the marker. + // Treating it as an error would make the guard reject the ordinary case. + #[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(); + + 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" + ); + } + + // An image that ships no hosts file has no content to protect, and grep + // cannot tell "absent" from "unreadable" -- both are status 2. The + // existence check is what keeps the guard from refusing to pin here. + #[test] + fn an_image_with_no_hosts_file_is_pinned_rather_than_refused() { + let scratch = Scratch::new("nofile"); + + let code = run(&pin(&scratch.hosts()), None); + + 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}") + ); + } + + #[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); + + let code = run(&unpin(&scratch.hosts()), None); + let after = scratch.read_hosts(); + + assert_eq!(code, 0, "unpinning a readable file should succeed"); + assert!( + !after.contains(HOSTS_PIN_MARKER), + "the pin survived the unpin; file is now:\n{after}" + ); + for line in ORIGINAL.lines() { + assert!( + after.contains(line), + "the unpin dropped {line:?}; file is now:\n{after}" + ); + } + } + // A dangling symlink was the worst shape the guard did not cover: `-e` is + // false, so no read happened, and the redirect then *created* the target. + // On a writable host bind mount that is a write outside the container, at + // a path the workload chose. + #[test] + fn pinning_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); + + assert_ne!(code, 0, "writing through a symlink should be refused"); + assert!( + !target.exists(), + "the refused pin still created {}", + target.display() + ); + } + + // The non-dangling case is the same write to somewhere the workload chose, + // it just does not announce itself by leaving a broken link behind. + #[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" + ); + } + + // Unpinning takes the same prologue, so it has to refuse on the same terms + // -- and it is the more destructive of the two, since it writes back only + // what it read. + #[test] + fn unpinning_refuses_a_symlink_rather_than_emptying_its_target() { + let scratch = Scratch::new("unpinsymlink"); + 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(&unpin(&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 unpin still emptied the symlink target" + ); + } + + // The refusal has to be legible in the container's stderr, or an operator + // sees only a non-zero exit from a destroyed container. + #[test] + fn the_symlink_refusal_says_why() { + let scratch = Scratch::new("symlinkmsg"); + let target = scratch.dir.join("elsewhere"); + std::os::unix::fs::symlink(&target, scratch.hosts()) + .expect("the scratch symlink should be creatable"); + + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(pin(&scratch.hosts())) + .output() + .expect("/bin/sh should be executable"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("symbolic link"), + "the refusal named no reason; stderr was:\n{stderr}" + ); + } } diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 521ab57d2..5aab749dd 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -8,11 +8,26 @@ //! interface. use std::net::{IpAddr, Ipv6Addr, ToSocketAddrs}; +use std::path::Path; use std::process::Command; use sha2::{Digest, Sha256}; use wxc_common::logger::Logger; -use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode, NetworkPolicy}; +use wxc_common::models::{ + ContainerPolicy, NetworkEnforcementMode, NetworkPolicy, ProxyAddress, ProxyHostPin, +}; + +/// One destination the container is allowed to reach when the policy routes +/// egress through a cooperative proxy: an address the proxy host resolved to, +/// and the TCP port the proxy listens on. +/// +/// The address is held as a string because that is what an iptables `-d` +/// argument takes, matching [`ResolvedDestinations`]. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyEndpoint { + ip: String, + port: u16, +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IpFamily { @@ -20,6 +35,16 @@ enum IpFamily { V6, } +/// Where the kernel reports per-interface attributes. Injectable in tests via +/// the `_in` form of the lookup below. +const SYSFS_NET_ROOT: &str = "/sys/class/net"; + +/// Toggles that decide whether bridged packets are handed to iptables and +/// ip6tables at all. A bridged container's chain is unreachable unless the +/// matching one reads `1`. +const BRIDGE_NF_CALL_IPTABLES: &str = "/proc/sys/net/bridge/bridge-nf-call-iptables"; +const BRIDGE_NF_CALL_IP6TABLES: &str = "/proc/sys/net/bridge/bridge-nf-call-ip6tables"; + /// Whether a host-list entry produces an ACCEPT or a DROP rule. Local to this /// backend: it distinguishes `allowedHosts` from `blockedHosts` and is not a /// policy-schema type. @@ -70,6 +95,12 @@ pub(crate) struct CreatedResources { v6_chain: bool, v4_hook: bool, v6_hook: bool, + v4_physdev_hook: bool, + v6_physdev_hook: bool, + v4_return: bool, + v6_return: bool, + v4_physdev_return: bool, + v6_physdev_return: bool, } /// Flush and delete the chain, reporting whether it is still owned afterward. @@ -108,7 +139,16 @@ impl CreatedResources { /// compiled on every target so Windows and macOS CI still type-check it. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn is_empty(&self) -> bool { - !self.v4_chain && !self.v6_chain && !self.v4_hook && !self.v6_hook + !self.v4_chain + && !self.v6_chain + && !self.v4_hook + && !self.v6_hook + && !self.v4_physdev_hook + && !self.v6_physdev_hook + && !self.v4_return + && !self.v6_return + && !self.v4_physdev_return + && !self.v6_physdev_return } /// Test-only constructor so `signal_cleanup`'s tests can build a @@ -122,6 +162,12 @@ impl CreatedResources { v6_chain, v4_hook, v6_hook, + v4_physdev_hook: false, + v6_physdev_hook: false, + v4_return: false, + v6_return: false, + v4_physdev_return: false, + v6_physdev_return: false, } } } @@ -177,9 +223,25 @@ pub struct NetworkIptablesManager { rules_applied: bool, /// The container's veth interface name on the host. veth_interface: Option, + /// Whether a caller that never supplies a veth is expected rather than + /// broken. Defaults to `false`, so a missing veth fails fast. + veth_scoping_optional: bool, + /// Topology the hook logic should assume, bypassing the sysfs probe. + /// + /// Unit tests run on hosts with no `/sys/class/net`, where the honest probe + /// answer is [`VethTopology::Unknown`] and every apply would take the + /// bridged branch. Tests therefore declare a topology rather than inherit + /// the build host's; only tests can set this. + #[cfg(test)] + topology_override: Option, /// Chains and FORWARD hooks this manager successfully created, so teardown /// and rollback remove only resources this attempt actually installed. created: CreatedResources, + /// The hosts-file pin the container needs so it resolves the proxy + /// hostname to the one address this manager authorized. Recorded during + /// apply, because the resolution that produced the firewall rule is the + /// only one the container is allowed to agree with. + proxy_pin: Option, } /// iptables rejects chain names of 29 characters or more, so 28 is the ceiling @@ -261,6 +323,27 @@ pub fn chain_name_for(container_name: &str) -> String { } } +/// What a sysfs lookup was able to establish about a veth's topology. +/// +/// The third state is the point of this type. `Path::exists()` folds every +/// metadata error into `false`, so a masked, unmounted, or permission-denied +/// sysfs used to read as "directly routed" -- and that is the reading which +/// downgrades a failed physdev hook from fatal to a warning. The lookup is +/// independent of how the interface was discovered: `discover_veth_interface` +/// parses `lxc-info`, not sysfs, so a veth can be known to exist while its +/// sysfs entry is unreadable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VethTopology { + /// `master` is present, so the interface is enslaved to a bridge. + Bridged, + /// The interface directory is present and holds no `master`. This is a + /// positive finding, not the absence of one. + DirectlyRouted, + /// The lookup failed, so the topology is not known. Callers must treat + /// this as bridged: that is the branch which keeps a physdev hook + /// failure fatal. + Unknown, +} impl NetworkIptablesManager { /// Create a new manager for the given container name. pub fn new(container_name: &str) -> Self { @@ -268,7 +351,11 @@ impl NetworkIptablesManager { chain_name: chain_name_for(container_name), rules_applied: false, veth_interface: None, + veth_scoping_optional: false, + #[cfg(test)] + topology_override: Some(VethTopology::DirectlyRouted), created: CreatedResources::default(), + proxy_pin: None, } } @@ -282,6 +369,26 @@ impl NetworkIptablesManager { self.rules_applied } + /// The hosts-file pin a proxied container must be given before it runs, or + /// `None` when the policy needs no pin. + /// + /// Populated by [`Self::apply_firewall_rules`] and only meaningful after + /// it succeeds: the pin names the address that apply authorized, and + /// resolving the proxy host a second time to build it could return a + /// different address under round-robin or split-horizon DNS -- one the + /// chain does not allow. + pub fn proxy_host_pin(&self) -> Option<&ProxyHostPin> { + self.proxy_pin.as_ref() + } + + /// Whether this manager has been told a missing veth is expected. + /// + /// Lets a backend that structurally has no veth assert it made the + /// declaration without standing up a real firewall. + pub fn veth_scoping_is_optional(&self) -> bool { + self.veth_scoping_optional + } + /// Discover the host-side veth interface name for a running container. /// Parses the `Link:` line from `lxc-info -n ` output. /// Returns the veth interface name (e.g., "vethXXXXXX") if found. @@ -319,6 +426,306 @@ impl NetworkIptablesManager { self.veth_interface = Some(iface.to_string()); } + /// Declare the topology the hook logic should assume, in place of probing. + #[cfg(test)] + fn set_topology_override(&mut self, topology: VethTopology) { + self.topology_override = Some(topology); + } + + /// Declare that this caller has no veth to scope the chain to, so a missing + /// one is a structural fact rather than a failed lookup. + /// + /// LXC always names a veth once the container is running, so a manager that + /// reaches rule installation without one has lost the interface it needed + /// and must fail fast. Unprivileged Bubblewrap has no veth at all — the + /// sandbox either shares the host network namespace or gets a private one, + /// and neither yields a host-side interface to match on. Failing there would + /// refuse to start every Bubblewrap sandbox that asks for firewall mode. + /// + /// Callers that set this get the pre-existing behavior: the chain is built, + /// the FORWARD hook is skipped, and the skip is logged. The policy is + /// therefore **not** enforced, which is why this is opt-in and loud rather + /// than the default. + pub fn allow_missing_veth_interface(&mut self) { + self.veth_scoping_optional = true; + } + + /// Build one FORWARD hook rule matching the veth as the input interface. + /// + /// `op` is `-I` to install or `-D` to remove. Both come from this one + /// builder so a delete can never drift from the insert it has to match: + /// iptables deletes by full rule specification, and a spec that differs by + /// even one match leaves the hook in place. + fn build_forward_hook_iface_rule_args(op: &str, iface: &str, chain_name: &str) -> Vec { + vec![ + op.to_string(), + "FORWARD".to_string(), + "-i".to_string(), + iface.to_string(), + "-j".to_string(), + chain_name.to_string(), + ] + } + + /// Build one FORWARD hook rule matching the veth as the *bridge port* the + /// packet entered on. + /// + /// This is the rule that does the work whenever the container is attached + /// to a bridge, which is the default LXC topology (`lxc.net.0.link` set to + /// `lxcbr0`). A packet leaving such a container is bridged onto `lxcbr0` + /// and then routed off it, so by the time FORWARD sees the packet its + /// input interface is the bridge and not the veth -- an `-i ` rule + /// matches nothing at all. Measured on a live container: with both rules + /// present in FORWARD and the same traffic flowing, the `--physdev-in` + /// rule counted 11 packets while the `-i` rule counted zero. + /// + /// `--physdev-in` still names one specific bridge port, so the chain stays + /// scoped to a single container. Matching the bridge itself would apply + /// one container's policy to every container sharing it. + fn build_forward_hook_physdev_rule_args( + op: &str, + iface: &str, + chain_name: &str, + ) -> Vec { + vec![ + op.to_string(), + "FORWARD".to_string(), + "-m".to_string(), + "physdev".to_string(), + "--physdev-in".to_string(), + iface.to_string(), + "-j".to_string(), + chain_name.to_string(), + ] + } + + /// Determine whether `iface` is enslaved to a bridge, looked up under an + /// injectable sysfs root so this is testable without a live interface. + /// + /// `master` is a symlink, so the probe uses `symlink_metadata` rather than + /// `exists`, which follows the link and would report a dangling `master` as + /// absent. A `NotFound` on `master` only means "directly routed" when the + /// interface directory itself is readable; otherwise nothing was + /// established and the answer is [`VethTopology::Unknown`]. + fn veth_topology_in(sysfs_net_root: &Path, iface: &str) -> VethTopology { + let iface_dir = sysfs_net_root.join(iface); + match std::fs::symlink_metadata(iface_dir.join("master")) { + Ok(_) => VethTopology::Bridged, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // `metadata` here, not `symlink_metadata`, and the asymmetry is + // deliberate. In real sysfs `/sys/class/net/` is itself a + // symlink into `/sys/devices`, and `symlink_metadata` succeeds + // on a dangling one -- which would report an interface whose + // target is unreachable as positively directly routed. Only a + // directory that actually resolves proves the absent `master` + // was observed rather than merely unreachable. + match std::fs::metadata(&iface_dir) { + Ok(_) => VethTopology::DirectlyRouted, + Err(_) => VethTopology::Unknown, + } + } + Err(_) => VethTopology::Unknown, + } + } + + /// Whether bridged traffic is delivered to iptables at all, read from an + /// injectable path. + /// + /// The file exists only when `br_netfilter` is loaded, and a value of `1` + /// is what makes `--physdev-in` able to match. Absent or `0`, a bridged + /// container's packets bypass these chains entirely. + fn bridge_netfilter_active_at(path: &Path) -> bool { + std::fs::read_to_string(path) + .map(|contents| contents.trim() == "1") + .unwrap_or(false) + } + + /// Probe the real sysfs, unless a test declared the topology outright. + fn veth_topology(&self, iface: &str) -> VethTopology { + #[cfg(test)] + if let Some(topology) = self.topology_override { + return topology; + } + Self::veth_topology_in(Path::new(SYSFS_NET_ROOT), iface) + } + + /// Whether the hook logic must treat `topology` as bridged. + /// + /// Only a positive [`VethTopology::DirectlyRouted`] finding earns the + /// relaxed treatment, because that is the branch which downgrades a failed + /// physdev hook to a warning. An unknown topology has established nothing, + /// so it is handled as bridged and the failure stays fatal. + fn treat_as_bridged(topology: VethTopology) -> bool { + topology != VethTopology::DirectlyRouted + } + + /// Production wrapper over [`Self::bridge_netfilter_active_at`]. + fn bridge_netfilter_active(path: &str) -> bool { + Self::bridge_netfilter_active_at(Path::new(path)) + } + + /// Install the `--physdev-in` FORWARD hook for one family. + /// + /// Whether a failure here is fatal depends entirely on the topology, so + /// the decision lives in one place rather than being duplicated per + /// family. On a bridged veth this rule is the only one that can ever + /// match, so failing to install it means the policy is not enforced and + /// the caller must not be told otherwise. On a directly routed veth the + /// `-i` rule already carries the traffic and this one is redundant, so a + /// host whose kernel lacks the `physdev` match is still correctly + /// filtered and only warrants a warning. + fn install_physdev_hook( + run: fn(&[Vec], &mut Logger) -> Result<(), String>, + iface: &str, + chain_name: &str, + bridged: bool, + tool: &str, + logger: &mut Logger, + ) -> Result { + let rule = Self::build_forward_hook_physdev_rule_args("-I", iface, chain_name); + match run(&[rule], logger) { + Ok(()) => Ok(true), + Err(err) if bridged => Err(format!( + "Failed to install the physdev FORWARD hook on bridged veth {} for chain {} \ + ({}): {}. That rule is the only one a bridged container's packets can match, \ + so the policy would not be enforced. Refusing to report success for an \ + unenforceable policy.", + iface, chain_name, tool, err + )), + Err(err) => { + logger.log_line(&format!( + "Warning: could not install the physdev FORWARD hook on {} for chain {} \ + ({}): {}. The veth is not bridged, so the interface hook already carries \ + this container's traffic.", + iface, chain_name, tool, err + )); + Ok(false) + } + } + } + + /// Build one FORWARD rule accepting reply traffic back to the container. + /// + /// The chain hooks traffic *leaving* the container (`-i` / `--physdev-in`), + /// so a reply -- which arrives with the container's port as the output + /// interface -- matches no MXC rule and falls through to the FORWARD + /// policy. Where that policy is DROP, which is what Docker sets and Docker + /// is installed nearly everywhere, an explicitly allowed destination is + /// unreachable: the request goes out and the answer never comes back. + /// + /// This rule cannot widen the policy, and the reason is that conntrack + /// state is not created by a packet the chain drops. Conntrack attaches an + /// *unconfirmed* entry at PREROUTING, but only `nf_conntrack_confirm` + /// inserts it into the table, and that runs after the FORWARD verdict -- + /// so a dropped packet is freed and takes its unconfirmed entry with it. + /// The reverse packet then finds no state, is classified `NEW` rather than + /// `ESTABLISHED`, matches nothing here, and falls to the host policy. + /// + /// Measured rather than assumed, on the directly routed topology, with a + /// positive control to prove the detector works. Outbound allowed: two + /// conntrack entries, this rule matched three times, three packets reached + /// the container. Outbound dropped by the chain, then a cooperating peer + /// sending the reverse packets: zero conntrack entries, this rule matched + /// **zero** times, zero packets reached the container. + /// + /// What this rule does accept is the continuation of a flow whose state + /// already exists -- which includes a flow the *host* authorized inbound, + /// not only one this chain accepted outbound. That is what stateful + /// filtering means and it is not a widening: the connection's first packet + /// still had to pass the host's own policy. Inbound *new* connections + /// match nothing here and are left to that policy exactly as before. + /// + /// It deliberately accepts rather than jumping to the chain, even though + /// the chain carries an `ESTABLISHED,RELATED` rule of its own that would + /// match. The chain's other rules are written against `-d ` + /// for egress, so an inbound packet that is not established would be + /// tested against egress-shaped rules and, under an `allow` default, hit + /// the chain's closing ACCEPT. That would quietly turn this into an + /// inbound enforcement surface with the wrong semantics -- a separate + /// control, tracked separately, and not something to acquire as a side + /// effect of fixing the reply path. + fn build_forward_return_iface_rule_args(op: &str, iface: &str) -> Vec { + vec![ + op.to_string(), + "FORWARD".to_string(), + "-o".to_string(), + iface.to_string(), + "-m".to_string(), + "state".to_string(), + "--state".to_string(), + "ESTABLISHED,RELATED".to_string(), + "-j".to_string(), + "ACCEPT".to_string(), + ] + } + + /// The bridge-port form of [`Self::build_forward_return_iface_rule_args`]. + /// + /// Mirrors the ingress pair for the same reason it exists there: on the + /// default bridged topology the packet's output interface is `lxcbr0`, not + /// the veth, so the `-o ` rule matches nothing and only + /// `--physdev-out` names the specific container. + fn build_forward_return_physdev_rule_args(op: &str, iface: &str) -> Vec { + vec![ + op.to_string(), + "FORWARD".to_string(), + "-m".to_string(), + "physdev".to_string(), + "--physdev-out".to_string(), + iface.to_string(), + "-m".to_string(), + "state".to_string(), + "--state".to_string(), + "ESTABLISHED,RELATED".to_string(), + "-j".to_string(), + "ACCEPT".to_string(), + ] + } + + /// Install one return-path rule, downgrading any failure to a warning. + /// + /// Unlike the chain hooks, a missing rule here cannot fail open: the rule + /// only ever *accepts*, so failing to install it can leave the container + /// less connected but never less filtered. Refusing to run over it would + /// turn a connectivity limitation into an outage on hosts where the + /// forward policy is ACCEPT and nothing was broken to begin with. + fn install_return_rule( + run: fn(&[Vec], &mut Logger) -> Result<(), String>, + rule: Vec, + form: &str, + iface: &str, + tool: &str, + logger: &mut Logger, + ) -> bool { + match run(&[rule], logger) { + Ok(()) => true, + Err(err) => { + logger.log_line(&format!( + "Warning: could not install the {} return-path rule for {} ({}): {}. \ + Replies to allowed outbound connections will rely on the host's FORWARD \ + policy, so a DROP policy would make allowed destinations unreachable.", + form, iface, tool, err + )); + false + } + } + } + + /// Whether a programmed destination accepts every address in its family. + /// + /// Only a prefix length of zero qualifies. That is the one case where an + /// allow rule can be shown, without knowing the address, to cover a + /// blocked host that failed to resolve -- which is what makes it a hard + /// error rather than a warning in [`Self::build_policy_rules_logged`]. A + /// bare literal or any longer prefix names a bounded set, so it carries no + /// such proof. + fn covers_every_address(destination: &str) -> bool { + destination + .split_once('/') + .and_then(|(_, prefix)| prefix.trim().parse::().ok()) + .is_some_and(|prefix| prefix == 0) + } + /// Resolve a destination string to IPv4 and IPv6 firewall destinations. /// /// Bare IPv4/IPv6 literals are retained in their matching family. CIDR @@ -503,17 +910,202 @@ impl NetworkIptablesManager { .collect() } - fn build_default_policy_rule_arg(chain_name: &str, policy: NetworkPolicy) -> Vec { - let default_action = match policy { + /// The catch-all action for a chain. Proxy mode is "deny all except the + /// proxy", so it always closes with DROP regardless of the configured + /// default policy. + fn default_policy_action(default_policy: NetworkPolicy, proxy_enabled: bool) -> &'static str { + if proxy_enabled { + return "DROP"; + } + match default_policy { NetworkPolicy::Block => "DROP", NetworkPolicy::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); vec!["-A", chain_name, "-j", default_action] .into_iter() .map(String::from) .collect() } + /// Build the ACCEPT rules that open the proxy endpoints, and nothing else. + /// + /// These are the only allow rules a proxied chain carries. They are emitted + /// straight before the closing DROP from + /// [`Self::build_default_policy_rule_arg`], so the chain reads "the proxy, + /// then nothing". + /// + /// IPv4 only, so the caller must not run these through `ip6tables`: the + /// endpoints come from [`Self::resolve_proxy_endpoints`], which refuses an + /// IPv6 proxy rather than programming a rule for it. A proxied IPv6 chain + /// therefore holds its closing DROP alone, which is the fail-closed + /// outcome -- IPv6 egress is denied rather than left open. + 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() + } + + /// Whether `host` is an IPv6 literal (bracketed `[..]` or bare). + /// + /// The proxy firewall rule is emitted with IPv4 `iptables` only, so an IPv6 + /// proxy endpoint cannot be enforced. It must be rejected explicitly rather + /// than passed through IPv4-only endpoint selection, which would drop it and + /// leave a deny-all container whose proxy was silently discarded. + fn host_is_ipv6_literal(host: &str) -> bool { + let candidate = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + matches!(candidate.parse::(), Ok(IpAddr::V6(_))) + } + + /// The error returned when a proxy endpoint is IPv6, which the IPv4-only + /// proxy firewall rule cannot enforce. + 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 + ) + } + + /// Resolve the policy's proxy into the destinations the chain will allow, + /// and the hosts-file pin the container needs to agree with them. + /// + /// Returns an empty vector when the policy carries no proxy, which is what + /// puts the chain back on the ordinary allow/block path. + /// + /// The pin is produced from this same resolution rather than from a second + /// lookup. Two lookups of one name can disagree -- DNS round-robin returns + /// a different order, or a TTL expires between the calls -- and a container + /// pinned to an address this chain did not authorize cannot reach its + /// proxy at all. `None` means no pin is needed because the address is + /// already an IP literal. + /// + /// Every resolved IPv4 address is opened, not just the pinned one. They are + /// all addresses of the configured proxy host, so the posture is unchanged, + /// and a client that resolves the name through something other than + /// `/etc/hosts` still reaches the proxy instead of being dropped. + 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()); + } + + // Reject an IPv6 literal explicitly. Selecting the IPv4 bucket below + // would leave it empty, which the emptiness check would then report as + // an unresolvable host -- a misleading error for a perfectly valid + // literal we simply cannot enforce. + 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() { + // A name with AAAA records and no A records is the same + // unenforceable case as the literal above, so say so rather than + // claiming the name does not resolve. + 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 = resolved + .ipv4 + .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)) + } + + /// Build the hosts-file pin that makes the container resolve the proxy + /// hostname to `ip`. + /// + /// Under "deny all except the proxy" the chain opens no port 53, so the + /// container has no resolver to reach: the pin is what lets it find the + /// proxy at all, and it also stops the container 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( chain_name: &str, destinations: &ResolvedDestinations, @@ -565,17 +1157,24 @@ impl NetworkIptablesManager { /// Build the allow/deny rule args for a container policy. /// /// Test-only shim over the shipping path [`Self::build_policy_rules_logged`] - /// so the rulegen spec assertions — including the allow-before-block - /// ordering that is a tracked security-semantics contract (AB#62830341) — - /// bind to the code that actually runs, not to a duplicate iteration. The + /// so the rulegen spec assertions — including the deny-before-allow + /// ordering that is a security-semantics contract (AB#62830341) — bind to + /// the code that actually runs, not to a duplicate iteration. The /// unresolved-host warning is irrelevant to rule generation, so it is /// discarded to a buffer logger. Production must never call this: it takes /// no logger and would resolve entries a second time relative to the /// warning pass. + /// + /// This shim panics on the unresolvable-block-entry error so that the many + /// rulegen assertions over well-formed policies keep a plain return type. A + /// test that exercises the error path must call + /// [`Self::build_policy_rules_logged`] directly and inspect the `Result`. #[cfg(test)] fn build_policy_rule_args(chain_name: &str, policy: &ContainerPolicy) -> FirewallRuleArgs { let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); - Self::build_policy_rules_logged(chain_name, policy, &mut logger) + Self::build_policy_rules_logged(chain_name, policy, &mut logger).expect( + "test policy should not pair an accepting default with an unresolvable block entry", + ) } /// Resolve every allow/block entry exactly once and build the rule args @@ -589,33 +1188,86 @@ impl NetworkIptablesManager { /// expires between the calls — so the rule installed would not match the /// rule that was validated and logged. /// - /// NOTE — interim ordering (tracked by AB#62830341): rules are emitted in - /// allow-list then block-list order, and iptables/ip6tables apply - /// first-match-wins within the chain. This model-1 change therefore does - /// **not** yet implement deny-precedence: a destination present in both - /// the allow and block lists is ACCEPTed. Reconciling this to the GA - /// "deny-wins" ordering is owned by net-model-2 (AB#62830341); until then - /// callers must not assume deny-precedence. + /// Deny-precedence (AB#62830341): block-list rules are emitted before + /// allow-list rules, and iptables/ip6tables apply first-match-wins within + /// the chain, so a destination present in both lists is DROPped. Emission + /// order is the entire precedence mechanism — there is no separate + /// resolution pass — so swapping these two iterators silently reverses the + /// security semantics of every policy whose lists overlap. + /// + /// A block entry that resolves to nothing programs no rule. That is a + /// containment failure only when something else would then permit the + /// destination, so the response depends on the default policy. Under + /// [`NetworkPolicy::Allow`] the chain ends in ACCEPT and the unwritten deny + /// rule was the only thing that would have stopped the traffic, so the + /// apply fails closed with an error rather than reporting success over a + /// policy it did not enforce. Under [`NetworkPolicy::Block`] the closing + /// DROP already denies every destination the allow list did not name, so an + /// unresolvable block entry is redundant rather than missing — the ordinary + /// case being a blocklist naming a host that does not exist at all — and a + /// warning is the proportionate response. + /// + /// That reasoning holds only while the closing DROP is what the traffic + /// actually reaches. An allow rule is evaluated first, and since + /// `resolve_host` passes CIDRs through unchanged, one entry can legally + /// cover the whole address space. A `/0` allow therefore accepts whatever + /// the unresolvable deny would have named, whatever that turns out to be, + /// so under [`NetworkPolicy::Block`] that combination fails closed too. + /// + /// Allows narrower than `/0` stay a warning. They name a bounded set the + /// operator vouched for, the closing DROP still denies everything outside + /// it, and nothing available here shows the missing deny falls inside it. + /// Failing those as well would reject the ordinary policy described above + /// — an allowlist beside a blocked host that no longer exists — and the + /// cheapest way to satisfy such an error is to delete the blocklist entry, + /// which leaves the deployment less protected than the warning did. + /// + /// An unresolvable allow entry is always a warning: it withholds traffic + /// that was meant to be permitted, which costs availability and cannot + /// widen what the container can reach. fn build_policy_rules_logged( chain_name: &str, policy: &ContainerPolicy, logger: &mut Logger, - ) -> FirewallRuleArgs { + ) -> Result { + let default_permits = matches!(policy.default_network_policy, NetworkPolicy::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 = policy - .allowed_hosts + .blocked_hosts .iter() - .map(|host| (host, RuleAction::Allow)) + .map(|host| (host, RuleAction::Deny)) .chain( policy - .blocked_hosts + .allowed_hosts .iter() - .map(|host| (host, RuleAction::Deny)), + .map(|host| (host, RuleAction::Allow)), ); for (host, action) in entries { let destinations = Self::resolve_host(host); if destinations.is_empty() { + if default_permits && matches!(action, RuleAction::Deny) { + return Err(format!( + "blocked host '{}' 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", + host + )); + } + if matches!(action, RuleAction::Deny) { + unresolved_denies.push(host); + } logger.log_line(&format!("Warning: could not resolve host '{}'", host)); + } else if matches!(action, RuleAction::Allow) + && destinations + .ipv4 + .iter() + .chain(destinations.ipv6.iter()) + .any(|dest| Self::covers_every_address(dest)) + { + catch_all_allows.push(host); } let rule_args = Self::build_resolved_destination_rule_args(chain_name, &destinations, &action); @@ -633,7 +1285,45 @@ impl NetworkIptablesManager { } args.extend(rule_args); } - args + // Under a denying default an unresolvable deny is tolerable on the + // grounds that the chain's closing DROP covers whatever the missing + // rule would have covered. That holds only while no ACCEPT can match + // first. `resolve_host` passes validated CIDRs through untouched, so + // `0.0.0.0/0` is a legal allow entry, and it accepts every address -- + // including whatever the blocked host would have resolved to. There + // the deny is *provably* defeated, and no evidence could rescue it, + // so fail closed. + // + // A narrower allow is left as a warning on purpose. Its destinations + // are a finite set the operator named and vouched for, the closing + // DROP still covers everything outside that set, and nothing here can + // show the missing deny falls inside it. Rejecting that case too + // would make an ordinary policy -- an allowlist plus a blocked host + // that no longer exists -- a hard failure, and the cheapest way out + // of it is to delete the blocklist entry. Trading a recorded warning + // for a silently shortened blocklist is a worse security outcome than + // the residual risk it removes. + 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 \ + 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 \ + catch-all allow", + unresolved_denies + .iter() + .map(|h| format!("'{}'", h)) + .collect::>() + .join(", "), + catch_all_allows + .iter() + .map(|h| format!("'{}'", h)) + .collect::>() + .join(", ") + )); + } + Ok(args) } /// Run an iptables command and return success/failure. @@ -909,6 +1599,11 @@ impl NetworkIptablesManager { /// before the error is returned, so a retry does not trip over a leftover /// `MXC-` chain ("chain already exists") and a partial failure never /// tears down a chain this attempt did not create. + /// + /// A policy carrying a proxy is resolved here, once, before any rule is + /// installed. The resulting endpoints are what the chain opens and the + /// recorded [`Self::proxy_host_pin`] is what the container must be given, + /// so both sides name the address a single lookup returned. pub fn apply_firewall_rules( &mut self, policy: &ContainerPolicy, @@ -916,6 +1611,32 @@ impl NetworkIptablesManager { ) -> Result { // Skip if network enforcement doesn't use firewall. if !Self::enforcement_mode_uses_firewall(&policy.network_enforcement_mode) { + // ...unless the policy also carries a proxy, in which case skipping + // is the dangerous outcome rather than the safe one. The runner + // injects HTTP(S)_PROXY from the same policy regardless of what + // happens here, so returning `Ok(true)` with no rules installed + // yields a container that advertises a proxy and restricts nothing: + // any client ignoring the environment reaches the network directly. + // + // The JSON parser rejects this combination, but the parser is not + // the only door. `LxcScriptRunner::execute` and `mxc_engine::run` + // take an already-built `ExecutionRequest`, and + // `NetworkEnforcementMode` derives `Default` as `Capabilities` -- so + // a policy constructed in code gets the unenforced mode without + // anyone choosing it. Restating the invariant here puts it in the + // layer that can actually observe whether rules were installed, + // which is the only layer every caller passes through. + if policy.network_proxy.is_enabled() { + return Err( + "network.proxy requires network.enforcementMode='firewall' or 'both'. \ + This policy enables a proxy under 'capabilities', where no iptables \ + rules are installed, so the proxy environment would be injected while \ + direct egress stayed unrestricted -- any client that ignores HTTP_PROXY \ + would bypass the proxy entirely. Refusing to apply rather than reporting \ + success for an enforcement that did not happen." + .to_string(), + ); + } logger.log_line("Network enforcement mode does not use firewall, skipping iptables."); return Ok(true); } @@ -935,7 +1656,10 @@ impl NetworkIptablesManager { )); } - let outcome = self.apply_firewall_rules_inner(policy, logger); + let (proxy_endpoints, proxy_pin) = Self::resolve_proxy_endpoints(policy, logger)?; + self.proxy_pin = proxy_pin; + + let outcome = self.apply_firewall_rules_inner(policy, &proxy_endpoints, logger); self.record_apply_outcome(outcome, logger) } @@ -1007,10 +1731,11 @@ impl NetworkIptablesManager { fn apply_firewall_rules_inner( &self, policy: &ContainerPolicy, + proxy_endpoints: &[ProxyEndpoint], logger: &mut Logger, ) -> Result { let mut created = CreatedResources::default(); - match self.install_firewall_rules(policy, logger, &mut created) { + match self.install_firewall_rules(policy, proxy_endpoints, logger, &mut created) { Ok(()) => Ok(created), Err(e) => { let residual = Self::teardown_created( @@ -1030,6 +1755,7 @@ impl NetworkIptablesManager { fn install_firewall_rules( &self, policy: &ContainerPolicy, + proxy_endpoints: &[ProxyEndpoint], logger: &mut Logger, created: &mut CreatedResources, ) -> Result<(), String> { @@ -1065,32 +1791,77 @@ impl NetworkIptablesManager { Self::publish_created(created); } - 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 proxy_mode = !proxy_endpoints.is_empty(); + + if proxy_mode { + // Proxy mode is "deny all except the proxy", so the chain carries + // the proxy ACCEPTs and its closing DROP and nothing else. + // + // None of the base exemptions belong here. There is no port 53 + // accept because the container resolves the proxy through the + // hosts-file pin instead, and an unscoped one would be a standing + // DNS-tunnel exfil path through a posture whose whole point is + // that the proxy is the only reachable destination. There is no + // `-i lo` accept because every packet reaching this chain arrived + // on the container's veth by construction, and no + // ESTABLISHED,RELATED accept because return traffic flows toward + // the container and never traverses it -- such a rule would only + // let flows opened before the chain existed keep running straight + // through the deny-all posture. + // + // The allow and block lists are not programmed either: every + // destination other than the proxy is denied by the closing DROP, + // so a block entry is redundant, and an allow entry naming + // anything but the proxy contradicts the model. + 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 &proxy_rules { + logger.log_line(&format!("Programmed iptables rule: {}", rule.join(" "))); + } + if !policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty() { + logger.log_line( + "Warning: network.proxy is configured, so allowedHosts and blockedHosts \ + are 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 only its closing DROP.", + ); + } + } else { + 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)?; + } - // Resolve every allow/block entry exactly once and reuse that single - // resolution for both the unresolved-host warning and rule - // construction, so the rule installed matches the entry that was - // validated and logged. - 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() - )); + // Resolve every allow/block entry exactly once and reuse that single + // resolution for both the unresolved-host warning and rule + // construction, so the rule installed matches the entry that was + // validated and logged. A block entry that resolves to nothing is an + // error here rather than a warning, and propagating it aborts the + // apply so the caller rolls back the chains created above instead of + // leaving a chain that is missing one of its deny rules. + 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() + )); + } } // Append default policy at end of each chain. let default_rule = Self::build_default_policy_rule_arg( &self.chain_name, policy.default_network_policy.clone(), + proxy_mode, ); let default_args: Vec<&str> = default_rule.iter().map(String::as_str).collect(); let default_action = default_args.last().copied().unwrap_or("ACCEPT"); @@ -1101,35 +1872,196 @@ impl NetworkIptablesManager { } // Hook the chains into FORWARD for the container's egress traffic. - // Packets originating in the container arrive at the host on the - // host-side veth, so they match FORWARD by input interface (`-i`); - // `-o` would instead match traffic flowing toward the container. + // + // Two rules per family, because the input interface FORWARD sees + // depends on how the veth is attached. A veth routed directly by the + // host arrives as `-i `. A veth enslaved to a bridge -- the + // default LXC topology -- arrives as `-i `, and only + // `--physdev-in ` still identifies the container. Installing + // only the first is what let a fully populated deny-all chain sit in + // the ruleset filtering nothing. + // + // The two are mutually exclusive for any given packet, so no packet is + // counted twice. + // + // `-o` matches the reply direction rather than egress, which is why it + // has no place in the hooks -- and why the return-path rules installed + // alongside them below use it instead. Those are scoped to this same + // block: a caller with no veth has no port to name, and an unscoped + // ACCEPT would carry traffic for every container on the host. if let Some(ref iface) = self.veth_interface { - Self::run_iptables( - &["-I", "FORWARD", "-i", iface, "-j", &self.chain_name], + let topology = self.veth_topology(iface); + // Only a positive "directly routed" finding earns the relaxed + // treatment. An unreadable sysfs establishes nothing, and the + // relaxed branch is the one that downgrades a failed physdev hook + // to a warning -- so an unknown topology is handled as bridged. + let bridged = Self::treat_as_bridged(topology); + if topology == VethTopology::Unknown { + logger.log_line(&format!( + "Warning: could not determine whether container veth {} is bridged \ + ({} is unreadable). Treating it as bridged, which keeps a failed \ + physdev hook fatal rather than silently unenforced.", + iface, SYSFS_NET_ROOT + )); + } + let chain_name = self.chain_name.clone(); + + // On a bridged veth the physdev rule is the only one that can + // match, and it can only match while br_netfilter is delivering + // bridged packets to iptables. Without that, both rules install + // cleanly and neither ever fires, which is the exact failure this + // change exists to remove: a chain that looks enforced and is not. + if bridged && !Self::bridge_netfilter_active(BRIDGE_NF_CALL_IPTABLES) { + return Err(format!( + "Container veth {} is enslaved to a bridge but bridged packets are not \ + delivered to iptables ({} is absent or 0), so chain {} could never be \ + reached from FORWARD. Refusing to report success for an unenforceable \ + policy.", + iface, BRIDGE_NF_CALL_IPTABLES, chain_name + )); + } + + created.v4_hook = true; + Self::publish_created(created); + Self::run_iptables_rule_args( + &[Self::build_forward_hook_iface_rule_args( + "-I", + iface, + &chain_name, + )], logger, )?; - created.v4_hook = true; + + created.v4_physdev_hook = true; + Self::publish_created(created); + created.v4_physdev_hook = Self::install_physdev_hook( + Self::run_iptables_rule_args, + iface, + &chain_name, + bridged, + "iptables", + logger, + )?; + Self::publish_created(created); + + // Claimed before insertion for the same reason the hooks are: a + // fatal signal between the call and the record would leave the + // rule installed and absent from the cleanup snapshot. + created.v4_return = true; + created.v4_physdev_return = true; + Self::publish_created(created); + created.v4_return = Self::install_return_rule( + Self::run_iptables_rule_args, + Self::build_forward_return_iface_rule_args("-I", iface), + "interface", + iface, + "iptables", + logger, + ); + created.v4_physdev_return = Self::install_return_rule( + Self::run_iptables_rule_args, + Self::build_forward_return_physdev_rule_args("-I", iface), + "physdev", + iface, + "iptables", + logger, + ); Self::publish_created(created); logger.log_line(&format!( "FORWARD hook installed on {} for chain {} (iptables).", - iface, self.chain_name + iface, chain_name )); if ipv6_enabled { - Self::run_ip6tables( - &["-I", "FORWARD", "-i", iface, "-j", &self.chain_name], + if bridged && !Self::bridge_netfilter_active(BRIDGE_NF_CALL_IP6TABLES) { + return Err(format!( + "Container veth {} is enslaved to a bridge but bridged packets are not \ + delivered to ip6tables ({} is absent or 0), so chain {} could never be \ + reached from FORWARD for IPv6. Refusing to report success for an \ + unenforceable policy.", + iface, BRIDGE_NF_CALL_IP6TABLES, chain_name + )); + } + + created.v6_hook = true; + Self::publish_created(created); + Self::run_ip6tables_rule_args( + &[Self::build_forward_hook_iface_rule_args( + "-I", + iface, + &chain_name, + )], + logger, + )?; + + created.v6_physdev_hook = true; + Self::publish_created(created); + created.v6_physdev_hook = Self::install_physdev_hook( + Self::run_ip6tables_rule_args, + iface, + &chain_name, + bridged, + "ip6tables", logger, )?; - created.v6_hook = true; Self::publish_created(created); + + created.v6_return = true; + created.v6_physdev_return = true; + Self::publish_created(created); + created.v6_return = Self::install_return_rule( + Self::run_ip6tables_rule_args, + Self::build_forward_return_iface_rule_args("-I", iface), + "interface", + iface, + "ip6tables", + logger, + ); + created.v6_physdev_return = Self::install_return_rule( + Self::run_ip6tables_rule_args, + Self::build_forward_return_physdev_rule_args("-I", iface), + "physdev", + iface, + "ip6tables", + logger, + ); + Self::publish_created(created); + logger.log_line(&format!( "FORWARD hook installed on {} for chain {} (ip6tables).", - iface, self.chain_name + iface, chain_name )); } } else { - // Without a veth interface, we cannot safely scope rules to the container. - // Refuse to apply host-wide rules to avoid affecting all host traffic. + // Without a veth interface there is nothing to hook the chain to, + // and an unhooked chain is never traversed: FORWARD reaches it only + // via a rule naming the veth, whether as the input interface or as + // the bridge port. Reporting success here would hand the caller a + // fully populated deny-all chain that filters nothing, which is + // strictly worse than no firewall at all because it looks enforced. + // + // The alternative -- installing the rules host-wide so they do take + // effect -- is not acceptable either: unscoped they would apply to + // every container and to the host's own traffic. + // + // So the only honest outcome is to fail. `apply_firewall_rules_inner` + // rolls back the chains recorded in `created`, and `lxc_runner` + // destroys the container rather than starting a workload that + // believes it is confined. + // + // A caller that has declared it never had a veth to begin with is + // the one exception. For it a missing veth is not a lost lookup, so + // failing would only refuse to start a sandbox that was never going + // to be scopable. It keeps the pre-existing skip, which leaves the + // policy unenforced -- see `allow_missing_veth_interface`. + if !self.veth_scoping_optional { + return Err(format!( + "No veth interface for container; cannot scope iptables rules to chain {}. \ + The chain would never be reached from FORWARD, so the network policy would \ + not be enforced. Refusing to report success for an unenforceable policy.", + self.chain_name + )); + } + logger.log_line( "Warning: No veth interface set for container. \ Cannot scope iptables rules. Skipping FORWARD hook.", @@ -1146,6 +2078,23 @@ impl NetworkIptablesManager { /// at the end of a successful apply. Publishing only on success would mean /// a signal arriving mid-apply sees an empty set, removes nothing, and /// leaks the partially created chain. + /// + /// FORWARD hooks go further and are claimed *before* the `-I` runs, because + /// "after the command returns" still leaves a window: the kernel has the + /// rule, this process has not yet recorded it, and a signal landing there + /// leaves an installed hook absent from the snapshot. Cleanup then skips + /// it, and the surviving hook holds a reference that keeps the chain + /// undeletable. Claiming first inverts the failure into an over-claim, and + /// an over-claimed hook is harmless: removal is by full rule specification, + /// which names this attempt's own chain, so a `-D` that matches nothing is + /// a no-op and cannot disturb another container. + /// + /// Chains are deliberately *not* claimed ahead of their `-N`. Unlike `-I`, + /// which always inserts, `-N` fails when the name is already taken -- and + /// the pre-existing chain in that case belongs to someone else. Claiming + /// first would let the rollback of a failed create delete a live chain this + /// attempt did not install, trading a leak for the removal of another + /// container's enforcement. fn publish_created(created: &CreatedResources) { crate::signal_cleanup::set_active_created(*created); } @@ -1172,32 +2121,110 @@ impl NetworkIptablesManager { ) -> CreatedResources { let mut residual = *created; - // Remove from FORWARD only for families this attempt hooked. Must - // match the `-i` direction used at insertion so the delete finds the - // rule; a `-o` delete would leak the FORWARD hook. + // Remove from FORWARD only for families this attempt hooked, and only + // the hook forms it actually installed. Both specs come from the same + // builders used at insertion, because iptables deletes by full rule + // specification: a spec that differs by even one match -- `-o` instead + // of `-i`, or the interface rule standing in for the physdev one -- + // finds nothing and leaks the hook. if let Some(iface) = veth_interface { if created.v4_hook - && Self::run_iptables(&["-D", "FORWARD", "-i", iface, "-j", chain_name], logger) - .is_ok() + && Self::run_iptables_rule_args( + &[Self::build_forward_hook_iface_rule_args( + "-D", iface, chain_name, + )], + logger, + ) + .is_ok() { residual.v4_hook = false; } + if created.v4_physdev_hook + && Self::run_iptables_rule_args( + &[Self::build_forward_hook_physdev_rule_args( + "-D", iface, chain_name, + )], + logger, + ) + .is_ok() + { + residual.v4_physdev_hook = false; + } if created.v6_hook - && Self::run_ip6tables(&["-D", "FORWARD", "-i", iface, "-j", chain_name], logger) - .is_ok() + && Self::run_ip6tables_rule_args( + &[Self::build_forward_hook_iface_rule_args( + "-D", iface, chain_name, + )], + logger, + ) + .is_ok() { residual.v6_hook = false; } + if created.v6_physdev_hook + && Self::run_ip6tables_rule_args( + &[Self::build_forward_hook_physdev_rule_args( + "-D", iface, chain_name, + )], + logger, + ) + .is_ok() + { + residual.v6_physdev_hook = false; + } + + // The return-path rules jump to ACCEPT rather than to the chain, + // so unlike the hooks above they hold no reference to it and do + // not gate the delete below. They are still this attempt's to + // remove: left behind, they would accept established traffic for + // a veth name the kernel may later hand to a different container. + if created.v4_return + && Self::run_iptables_rule_args( + &[Self::build_forward_return_iface_rule_args("-D", iface)], + logger, + ) + .is_ok() + { + residual.v4_return = false; + } + if created.v4_physdev_return + && Self::run_iptables_rule_args( + &[Self::build_forward_return_physdev_rule_args("-D", iface)], + logger, + ) + .is_ok() + { + residual.v4_physdev_return = false; + } + if created.v6_return + && Self::run_ip6tables_rule_args( + &[Self::build_forward_return_iface_rule_args("-D", iface)], + logger, + ) + .is_ok() + { + residual.v6_return = false; + } + if created.v6_physdev_return + && Self::run_ip6tables_rule_args( + &[Self::build_forward_return_physdev_rule_args("-D", iface)], + logger, + ) + .is_ok() + { + residual.v6_physdev_return = false; + } } // Flush and delete only the chains this attempt created, and only once - // that family's FORWARD hook is confirmed gone. `-X` is the command - // that actually relinquishes the chain, so ownership is only cleared - // when it succeeds. The gate is per family because the two chains live - // in different tables and are referenced independently. + // every FORWARD hook for that family is confirmed gone. `-X` is the + // command that actually relinquishes the chain, so ownership is only + // cleared when it succeeds. Either surviving hook still references the + // chain, so both gate the delete. The gate is per family because the + // two chains live in different tables and are referenced independently. residual.v4_chain = teardown_chain( created.v4_chain, - residual.v4_hook, + residual.v4_hook || residual.v4_physdev_hook, logger, |logger| { let _ = Self::run_iptables(&["-F", chain_name], logger); @@ -1206,7 +2233,7 @@ impl NetworkIptablesManager { ); residual.v6_chain = teardown_chain( created.v6_chain, - residual.v6_hook, + residual.v6_hook || residual.v6_physdev_hook, logger, |logger| { let _ = Self::run_ip6tables(&["-F", chain_name], logger); @@ -1308,6 +2335,34 @@ impl Drop for NetworkIptablesManager { /// because `cargo test` runs tests in parallel -- a process-global fake would /// have to be serialized behind a lock and would let one test observe /// another's commands. +/// Spec for the fail-closed behavior when rules cannot be scoped to the +/// container. Attached as a child module rather than a `tests/` integration +/// test because the `test_firewall` seam below is `#[cfg(test)]`, which an +/// integration test -- a separate crate -- can never see. Kept in its own file +/// so this one does not grow further. +#[cfg(test)] +#[path = "network_iptables_veth_spec.rs"] +mod veth_spec; + +/// Black-box specification for the FORWARD hook wiring, kept in its own file +/// for the same reason as `veth_spec`. +#[cfg(test)] +#[path = "network_iptables_forward_hook_spec.rs"] +mod forward_hook_spec; + +/// Black-box specification for deny-precedence ordering and the fail-closed +/// response to an unresolvable block entry, kept in its own file for the same +/// reason as `veth_spec`. +#[cfg(test)] +#[path = "network_iptables_deny_precedence_spec.rs"] +mod deny_precedence_spec; + +/// Black-box specification for cooperative-proxy egress enforcement, kept in +/// its own file for the same reason as `veth_spec`. +#[cfg(test)] +#[path = "network_iptables_proxy_spec.rs"] +mod proxy_spec; + #[cfg(test)] mod test_firewall { use std::cell::RefCell; @@ -1319,6 +2374,9 @@ mod test_firewall { /// back to `fallback`. scripted: VecDeque>, fallback: Result<(), String>, + /// When set, any command whose argv contains the needle fails with the + /// paired message, regardless of `scripted`/`fallback`. + fail_matching: Option<(String, String)>, } thread_local! { @@ -1341,6 +2399,7 @@ mod test_firewall { issued: Vec::new(), scripted: VecDeque::new(), fallback: Ok(()), + fail_matching: None, }); }); FakeFirewall @@ -1359,6 +2418,17 @@ mod test_firewall { self } + /// Every command containing `needle` in its argument vector fails with + /// `stderr`; every other command succeeds. Lets a test fail one + /// specific step of an apply without having to count the commands that + /// precede it. + pub(super) fn fail_commands_matching(&self, needle: &str, stderr: &str) -> &Self { + Self::with_state(|state| { + state.fail_matching = Some((needle.to_string(), stderr.to_string())); + }); + self + } + /// Every command issued so far, in order, each as `[binary, args..]`. pub(super) fn issued(&self) -> Vec> { Self::with_state(|state| state.issued.clone()) @@ -1391,7 +2461,12 @@ mod test_firewall { let mut argv = Vec::with_capacity(args.len() + 1); argv.push(command.to_string()); argv.extend(args.iter().map(|arg| arg.to_string())); - state.issued.push(argv); + state.issued.push(argv.clone()); + if let Some((needle, stderr)) = &state.fail_matching { + if argv.iter().any(|arg| arg.contains(needle.as_str())) { + return Some(Err(stderr.clone())); + } + } Some( state .scripted @@ -1424,14 +2499,95 @@ mod test_firewall { Some(true) }) } -} +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Error, ErrorKind}; + use wxc_common::logger::{Logger, Mode}; + use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode, ProxyAddress, ProxyConfig}; + + /// Build a policy requesting the given enforcement mode, leaving every + /// other field at its default. + fn policy_requesting_mode(mode: NetworkEnforcementMode) -> ContainerPolicy { + ContainerPolicy { + network_enforcement_mode: mode, + ..Default::default() + } + } + + // Bubblewrap has no veth at all, so the fail-closed path that protects LXC + // would refuse to start every Bubblewrap sandbox asking for firewall mode. + // A caller that declares the absence up front must still get its chain + // built. `Firewall` and `Both` are covered separately so a fix scoped to + // one enforcement mode cannot pass the pair. + #[test] + fn a_caller_that_declared_it_has_no_veth_is_not_refused_in_firewall_mode() { + let _fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("bwrap-noveth"); + manager.allow_missing_veth_interface(); + 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 that declared it has no veth must not be failed closed, got {:?}", + result + ); + } + + #[test] + fn a_caller_that_declared_it_has_no_veth_is_not_refused_in_both_mode() { + let _fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("bwrap-noveth-both"); + manager.allow_missing_veth_interface(); + let policy = policy_requesting_mode(NetworkEnforcementMode::Both); + let mut logger = Logger::new(Mode::Buffer); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + + assert!( + result.is_ok(), + "a caller that declared it has no veth must not be failed closed, got {:?}", + result + ); + } -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Error, ErrorKind}; - use wxc_common::logger::{Logger, Mode}; - use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode}; + // The accessor is what the Bubblewrap suite asserts on, so it has to be + // able to say "no". An accessor stuck at true would let that suite pass + // even if the declaration were never made. + #[test] + fn a_fresh_manager_has_not_declared_a_missing_veth_as_expected() { + let manager = NetworkIptablesManager::new("fresh"); + + assert!( + !manager.veth_scoping_is_optional(), + "a manager that was never told otherwise must report that a missing \ + veth is not expected" + ); + } + + // The declaration is opt-in precisely because it leaves the policy + // unenforced. A manager that never made it must keep failing closed, so + // the two behaviors cannot quietly collapse into one. + #[test] + fn a_manager_that_never_declared_a_missing_veth_still_fails_closed() { + let _fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("lxc-lost-veth"); + 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_err(), + "a manager with no veth and no declaration must fail closed, got {:?}", + result + ); + } #[test] fn an_empty_ownership_record_is_recognized_as_nothing_to_tear_down() { @@ -1988,20 +3144,34 @@ mod tests { let args = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); - assert_eq!( - args.ipv4, - 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"]), - ] - ); - assert_eq!( - args.ipv6, - vec![ - strings(&["-A", "MXC-test", "-d", "2606:50c0::/32", "-j", "ACCEPT"]), - strings(&["-A", "MXC-test", "-d", "2001:db8::/32", "-j", "DROP"]), - ] - ); + // Membership rather than sequence: this test owns the family split, and + // the order the two lists are emitted in is the deny-precedence + // contract, asserted by the deny_precedence_spec module. + 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"]), + ]; + + 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 + ); + } } #[test] @@ -2384,6 +3554,176 @@ mod tests { } } + /// `.invalid` is reserved by RFC 2606 and never resolves, so it is a stable + /// way to exercise the unresolvable path without depending on the network. + const UNRESOLVABLE_HOST: &str = "blocked.invalid"; + + #[test] + fn an_unresolvable_deny_under_a_blocking_default_is_fatal_beside_a_catch_all_allow() { + // The allow is evaluated before the chain's closing DROP and accepts + // every address, so it accepts the blocked host whatever it resolves + // to for the container. + let policy = ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..policy_with_hosts(&["0.0.0.0/0"], &[UNRESOLVABLE_HOST]) + }; + let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); + + let err = NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, &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() { + // The proof is per family and neither family may be overlooked, so a + // v4-only check would leave the identical v6 hole open. + let policy = ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..policy_with_hosts(&["::/0"], &[UNRESOLVABLE_HOST]) + }; + let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); + + NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, &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() { + // An allowlist next to a blocked host that no longer exists is the + // ordinary case. The allow names one address, the closing DROP still + // covers every other, and nothing shows the missing deny is that + // address -- so this must not become a hard failure. + let policy = ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..policy_with_hosts(&["192.0.2.10"], &[UNRESOLVABLE_HOST]) + }; + let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); + + NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, &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() { + // Guards the prefix length specifically: a check that only looked for + // a '/' would reject every CIDR allow, and one that only compared the + // address would reject `0.0.0.0/8`. + let policy = ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..policy_with_hosts(&["192.0.2.0/24"], &[UNRESOLVABLE_HOST]) + }; + let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); + + NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, &mut logger) + .expect("a /24 allow covers a bounded set, so it proves nothing about the deny"); + } + + #[test] + fn an_unresolvable_deny_under_a_blocking_default_stays_a_warning_with_no_allow() { + // With nothing to ACCEPT ahead of it, the closing DROP genuinely covers + // whatever the missing rule would have covered, so this must keep + // working rather than become a new hard failure. + let policy = ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..policy_with_hosts(&[], &[UNRESOLVABLE_HOST]) + }; + let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); + + NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, &mut logger) + .expect("an unresolvable deny with no allow rules is covered by the closing DROP"); + } + + #[test] + fn an_unresolvable_allow_does_not_arm_the_deny_precedence_failure() { + // An allow that resolved to nothing programs no ACCEPT, so it cannot + // preempt the closing DROP and must not be counted as one that did. + let policy = ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..policy_with_hosts(&["allowed.invalid"], &[UNRESOLVABLE_HOST]) + }; + let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); + + NetworkIptablesManager::build_policy_rules_logged("MXC-x", &policy, &mut logger) + .expect("an allow that programs no rule cannot accept the unresolved deny"); + } + + #[test] + fn an_unknown_topology_reaches_the_call_site_and_says_so() { + // The probe states three things, but only the call site decides. This + // pins the join: an unreadable sysfs must arrive as Unknown, be handled + // as bridged, and leave a diagnosable trace. Without the log line the + // fail-closed choice is invisible in the field, which is how the + // original fail-open behavior survived review in the first place. + // + // The apply's Result is deliberately not asserted: whether the bridged + // branch then errors depends on whether the host has br_netfilter + // active, which is not what this test is about. + let _fake = test_firewall::install(); + + let mut manager = NetworkIptablesManager::new("unknown-topology"); + manager.set_veth_interface("mxcv-unknown"); + manager.set_topology_override(VethTopology::Unknown); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + + let _ = manager.apply_firewall_rules(&policy, &mut logger); + + let logged = logger.get_buffer(); + assert!( + logged.contains("could not determine whether container veth mxcv-unknown is bridged"), + "an unknown topology must be reported, or the fail-closed choice is \ + undiagnosable in the field; logged: {logged}" + ); + assert!( + logged.contains("Treating it as bridged"), + "the log must say which way the ambiguity was resolved; logged: {logged}" + ); + } + + #[test] + fn a_forward_hook_is_owned_even_when_its_insert_command_never_completed() { + // The signal race this guards: the kernel accepts `-I`, and the process + // dies before recording it. Ownership must already cover the hook at + // that point, or cleanup skips it and the surviving rule keeps the + // chain referenced and undeletable. + // + // A signal cannot be delivered mid-apply in a unit test, so this uses + // the observable that distinguishes the two orderings: a hook insert + // that does not complete successfully. Claiming after the command would + // leave it unowned and the rollback silent; claiming before means the + // rollback still tries to remove it. + let fake = test_firewall::install(); + fake.fail_commands_matching("FORWARD", "simulated interruption"); + + let mut manager = NetworkIptablesManager::new("hook-race"); + manager.set_veth_interface("mxcv-race"); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + assert!( + result.is_err(), + "a failed FORWARD hook insert must fail the apply, got {:?}", + result + ); + + let issued = fake.issued(); + let attempted_hook_removal = issued + .iter() + .any(|cmd| cmd.iter().any(|arg| arg == "-D") && cmd.iter().any(|arg| arg == "FORWARD")); + assert!( + attempted_hook_removal, + "the rollback must try to remove a hook whose insert did not complete, \ + otherwise a signal in that same window would leak it; issued: {:?}", + issued + ); + } + #[test] fn allow_and_deny_actions_map_to_exact_iptables_jump_targets() { assert_eq!( @@ -2545,52 +3885,6 @@ mod tests { } } - #[test] - fn allow_list_rules_are_emitted_before_block_list_rules_for_same_ipv4_destination() { - let destination = "203.0.113.44"; - let policy = policy_with_hosts(&[destination], &[destination]); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-order-v4", &policy); - let rendered: Vec = rules.ipv4.iter().map(|rule| joined(rule)).collect(); - - let accept_index = rendered - .iter() - .position(|rule| rule.contains(destination) && rule.contains("-j ACCEPT")) - .expect("IPv4 ACCEPT rule for duplicate destination should exist"); - let drop_index = rendered - .iter() - .position(|rule| rule.contains(destination) && rule.contains("-j DROP")) - .expect("IPv4 DROP rule for duplicate destination should exist"); - - // SPEC_BRIEF §3 pins this interim AB#62830341 behavior until deny-precedence lands. - assert!( - accept_index < drop_index, - "IPv4 duplicate {destination} should ACCEPT before DROP; actual order: {rendered:?}" - ); - } - - #[test] - fn allow_list_rules_are_emitted_before_block_list_rules_for_same_ipv6_destination() { - let destination = "2001:db8::44"; - let policy = policy_with_hosts(&[destination], &[destination]); - let rules = NetworkIptablesManager::build_policy_rule_args("MXC-order-v6", &policy); - let rendered: Vec = rules.ipv6.iter().map(|rule| joined(rule)).collect(); - - let accept_index = rendered - .iter() - .position(|rule| rule.contains(destination) && rule.contains("-j ACCEPT")) - .expect("IPv6 ACCEPT rule for duplicate destination should exist"); - let drop_index = rendered - .iter() - .position(|rule| rule.contains(destination) && rule.contains("-j DROP")) - .expect("IPv6 DROP rule for duplicate destination should exist"); - - // SPEC_BRIEF §3 says allow-before-block ordering applies to both iptables buckets. - assert!( - accept_index < drop_index, - "IPv6 duplicate {destination} should ACCEPT before DROP; actual order: {rendered:?}" - ); - } - #[test] fn base_chain_rules_are_four_family_agnostic_rules_in_documented_order() { let chain_name = "MXC-base"; @@ -2633,12 +3927,20 @@ mod tests { let chain_name = "MXC-default"; assert_eq!( - NetworkIptablesManager::build_default_policy_rule_arg(chain_name, NetworkPolicy::Block), + NetworkIptablesManager::build_default_policy_rule_arg( + chain_name, + NetworkPolicy::Block, + false + ), strings(&["-A", chain_name, "-j", "DROP"]), "NetworkPolicy::Block should produce the exact DROP terminal rule" ); assert_eq!( - NetworkIptablesManager::build_default_policy_rule_arg(chain_name, NetworkPolicy::Allow), + NetworkIptablesManager::build_default_policy_rule_arg( + chain_name, + NetworkPolicy::Allow, + false + ), strings(&["-A", chain_name, "-j", "ACCEPT"]), "NetworkPolicy::Allow should produce the exact ACCEPT terminal rule" ); @@ -2743,6 +4045,73 @@ mod tests { } } + // The JSON parser rejects proxy-under-capabilities, but it is not the only + // way in: `LxcScriptRunner::execute` and `mxc_engine::run` take an + // already-built `ExecutionRequest`. Skipping here would report success for + // an enforcement that never happened, while the runner still injects the + // proxy environment -- a container that advertises a proxy and restricts + // nothing. + #[test] + fn a_proxy_under_a_non_firewall_mode_is_refused_rather_than_skipped() { + // `Capabilities` is the only mode the firewall gate rejects, and it is + // also `NetworkEnforcementMode`'s `Default` -- so this is what a policy + // built in code gets when nobody sets the field at all. + let mut policy = policy_with_enforcement_mode(NetworkEnforcementMode::Capabilities); + policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("10.0.0.5".to_string(), 3128)), + builtin_test_server: false, + }; + let mut manager = NetworkIptablesManager::new("proxy-gate"); + let mut logger = Logger::new(Mode::Buffer); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + + let error = result.expect_err( + "a proxy under an enforcement mode that installs no rules must not report success", + ); + assert!( + error.contains("enforcementMode"), + "the error must name the setting that has to change; got: {error}" + ); + assert!( + !manager.rules_applied(), + "a refused apply must leave no rules marked as applied" + ); + } + + // `builtin_test_server` enables the proxy without an address, and it takes + // the same injection path, so the gate cannot key on the address alone. + #[test] + fn the_builtin_test_server_proxy_is_gated_the_same_way() { + let mut policy = policy_with_enforcement_mode(NetworkEnforcementMode::Capabilities); + policy.network_proxy = ProxyConfig { + address: None, + builtin_test_server: true, + }; + let mut manager = NetworkIptablesManager::new("builtin-gate"); + let mut logger = Logger::new(Mode::Buffer); + + assert!( + manager.apply_firewall_rules(&policy, &mut logger).is_err(), + "an address-free proxy is still a proxy and must not be silently unenforced" + ); + } + + // The refusal must be narrow: without a proxy there is nothing to leave + // unenforced, so `capabilities` remains an ordinary supported mode. + #[test] + fn a_proxy_free_policy_still_skips_cleanly_under_capabilities() { + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Capabilities); + let mut manager = NetworkIptablesManager::new("no-proxy-skip"); + let mut logger = Logger::new(Mode::Buffer); + + assert_eq!( + manager.apply_firewall_rules(&policy, &mut logger), + Ok(true), + "capabilities mode without a proxy must stay a successful no-op" + ); + } + fn policy_with_enforcement_mode( network_enforcement_mode: NetworkEnforcementMode, ) -> ContainerPolicy { @@ -3120,4 +4489,209 @@ mod tests { "Unknown must be treated as active so an unreadable IPv6 state fails closed" ); } + #[test] + fn an_ownership_record_naming_only_a_return_rule_is_not_treated_as_empty() { + // is_empty gates the whole teardown. A return rule missing from it + // would leave an ACCEPT in FORWARD naming a veth the kernel is free to + // reassign, so a later container would inherit it. + for created in [ + CreatedResources { + v4_return: true, + ..Default::default() + }, + CreatedResources { + v6_return: true, + ..Default::default() + }, + CreatedResources { + v4_physdev_return: true, + ..Default::default() + }, + CreatedResources { + v6_physdev_return: true, + ..Default::default() + }, + ] { + assert!( + !created.is_empty(), + "{created:?} names an installed rule and must not be treated as empty" + ); + } + } + + #[test] + fn an_apply_installs_a_return_path_rule_in_both_directions_of_the_bridge() { + // Without these, a reply to an allowed destination matches neither + // ingress hook and falls through to the host's FORWARD policy; under + // Docker's DROP default the allowed destination is unreachable. + let fake = test_firewall::install(); + let mut manager = NetworkIptablesManager::new("return-install"); + manager.set_veth_interface("mxcv-ret"); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + + manager + .apply_firewall_rules(&policy, &mut logger) + .expect("the apply must succeed against the fake"); + + // Pinned to the binary on purpose: the builders are family-agnostic, so + // an IPv4 rule and an IPv6 rule differ only by which tool issued them. + // An assertion that ignored the binary would be satisfied by whichever + // family still worked, and would pass with the other one deleted. + let issued = fake.issued(); + for tool in ["iptables", "ip6tables"] { + for (form, expected) in [ + ( + "interface", + NetworkIptablesManager::build_forward_return_iface_rule_args("-I", "mxcv-ret"), + ), + ( + "physdev", + NetworkIptablesManager::build_forward_return_physdev_rule_args( + "-I", "mxcv-ret", + ), + ), + ] { + assert!( + issued + .iter() + .any(|cmd| cmd[0] == tool && cmd[1..] == expected[..]), + "the apply must install the {form} return rule via {tool}; issued: {issued:?}" + ); + } + } + } + + #[test] + fn a_teardown_removes_every_return_rule_the_apply_installed() { + // iptables deletes by full specification, so a return rule the teardown + // does not name outlives the container in the host's FORWARD chain. + let fake = test_firewall::install(); + let mut manager = NetworkIptablesManager::new("return-teardown"); + manager.set_veth_interface("mxcv-down"); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut apply_logger = Logger::new(Mode::Buffer); + manager + .apply_firewall_rules(&policy, &mut apply_logger) + .expect("the apply must succeed against the fake"); + + fake.forget_issued(); + let mut remove_logger = Logger::new(Mode::Buffer); + let _ = manager.remove_firewall_rules(&mut remove_logger); + + // Pinned to the binary for the same reason the install test is: a + // family-blind assertion would let one family's delete stand in for the + // other's, and the missed rule would outlive the container. + let issued = fake.issued(); + for tool in ["iptables", "ip6tables"] { + for (form, expected) in [ + ( + "interface", + NetworkIptablesManager::build_forward_return_iface_rule_args("-D", "mxcv-down"), + ), + ( + "physdev", + NetworkIptablesManager::build_forward_return_physdev_rule_args( + "-D", + "mxcv-down", + ), + ), + ] { + assert!( + issued + .iter() + .any(|cmd| cmd[0] == tool && cmd[1..] == expected[..]), + "the teardown must remove the {form} return rule via {tool}; issued: {issued:?}" + ); + } + } + } + + #[test] + fn a_return_rule_that_could_not_be_installed_warns_instead_of_failing_the_apply() { + // The asymmetry that justifies this: the ingress hook is what confines + // traffic to the chain, so losing it fails open and must be fatal. A + // return rule only ever ACCEPTs, so losing it can only leave the + // container less connected -- never less enforced. Failing the apply + // there would refuse to start a container whose policy is fully + // installed. + let fake = test_firewall::install(); + fake.fail_commands_matching("--physdev-out", "simulated missing physdev module"); + + let mut manager = NetworkIptablesManager::new("return-warn"); + manager.set_veth_interface("mxcv-warn"); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + + assert!( + result.is_ok(), + "a failed return rule must not fail the apply, got {result:?}" + ); + assert!( + logger.get_buffer().contains("return-path rule"), + "the failure must be reported, not swallowed; log: {}", + logger.get_buffer() + ); + } + + #[test] + fn a_caller_with_no_veth_installs_no_return_path_rule() { + // The return rules are only safe because they name one container's + // port. A caller that declared it has no veth -- Bubblewrap -- has no + // port to name, and a rule installed without one would accept + // established traffic for every container on the host. + let fake = test_firewall::install(); + let mut manager = NetworkIptablesManager::new("noveth-return"); + manager.allow_missing_veth_interface(); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + + manager + .apply_firewall_rules(&policy, &mut logger) + .expect("a caller with no veth must not be refused"); + + let issued = fake.issued(); + assert!( + !issued + .iter() + .any(|cmd| cmd.iter().any(|arg| arg == "ESTABLISHED,RELATED") + && cmd.iter().any(|arg| arg == "FORWARD")), + "no return rule may be installed without a veth to scope it to; issued: {issued:?}" + ); + } + + #[test] + fn a_return_rule_whose_install_failed_is_not_deleted_on_teardown() { + // The teardown deletes by full specification and a delete that matches + // nothing is reported as a failure, which would keep residual ownership + // for a rule that never existed and make Drop retry it forever. + let fake = test_firewall::install(); + fake.fail_commands_matching("--physdev-out", "simulated missing physdev module"); + + let mut manager = NetworkIptablesManager::new("return-nodelete"); + manager.set_veth_interface("mxcv-nodel"); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut apply_logger = Logger::new(Mode::Buffer); + manager + .apply_firewall_rules(&policy, &mut apply_logger) + .expect("the apply must succeed against the fake"); + + fake.forget_issued(); + let mut remove_logger = Logger::new(Mode::Buffer); + let _ = manager.remove_firewall_rules(&mut remove_logger); + + let issued = fake.issued(); + let deleted = + NetworkIptablesManager::build_forward_return_physdev_rule_args("-D", "mxcv-nodel"); + for tool in ["iptables", "ip6tables"] { + assert!( + !issued + .iter() + .any(|cmd| cmd[0] == tool && cmd[1..] == deleted[..]), + "a rule whose install failed must not be deleted by {tool}; issued: {issued:?}" + ); + } + } } diff --git a/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs b/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs new file mode 100644 index 000000000..14cd0ea26 --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs @@ -0,0 +1,631 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Black-box specification for deny-precedence and for the fail-closed +//! response to a block-list entry that resolves to no address. +//! +//! Written against the documented contract of the policy rule builder, not +//! against its body. +//! +//! Structural tests (ordering, rule shape, family split) call the test-only +//! `build_policy_rule_args` shim, which panics rather than returning `Err`. +//! Tests that must observe the `Result` or the logger buffer call +//! `build_policy_rules_logged` directly. + +use super::*; +// `super::*` re-exports `Logger` (the parent module uses it in its own +// signatures) but not `Mode`, which the parent never names directly. +use wxc_common::logger::Mode; + +/// Chain name shared by tests that do not care about its exact value. A +/// couple of tests use a distinct literal on purpose, to prove the chain +/// name is threaded through rather than hard-coded. +const CHAIN: &str = "mxc_test_chain"; + +// --------------------------------------------------------------------------- +// Shared helpers. +// --------------------------------------------------------------------------- + +/// Render a rule as `&str` slices, so it can be compared against a literal +/// without allocating `String`s for the expected side. +fn as_str_slice(rule: &[String]) -> Vec<&str> { + rule.iter().map(String::as_str).collect() +} + +/// The destination argument (`-d `) of a rule. +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] +} + +/// The jump target argument (`-j `) of a rule. +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] +} + +/// The largest index whose rule targets `DROP`, or `None` if `rules` has no +/// deny rules. +fn last_drop_index(rules: &[Vec]) -> Option { + rules.iter().rposition(|rule| action_of(rule) == "DROP") +} + +/// The smallest index whose rule targets `ACCEPT`, or `None` if `rules` has +/// no allow rules. +fn first_accept_index(rules: &[Vec]) -> Option { + rules.iter().position(|rule| action_of(rule) == "ACCEPT") +} + +/// `items`, sorted, so two destination sets can be compared without caring +/// about the order the implementation happened to produce them in. +fn sorted<'a>(items: &[&'a str]) -> Vec<&'a str> { + let mut items = items.to_vec(); + items.sort_unstable(); + items +} + +/// Assert that `rules` contains exactly the given DROP and ACCEPT +/// destinations, as sets, and that every DROP rule precedes every ACCEPT +/// rule -- the B1 deny-precedence guarantee. Order within a single action +/// is not part of the documented contract, so it is deliberately not +/// checked here. +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:?}" + ); + } +} + +/// Unwrap `result`, panicking with the `Err` payload if it is an `Err`. +/// Never formats the `Ok` payload, since `FirewallRuleArgs` is not +/// documented to implement `Debug`. +fn expect_ok(result: Result, context: &str) -> FirewallRuleArgs { + match result { + Ok(args) => args, + Err(err) => panic!("{context}; actual Err: {err:?}"), + } +} + +/// Whether `destination` (a bare address or an address/prefix CIDR) parses +/// as IPv4. Used to state the family-split guarantee as an invariant over +/// whatever the implementation actually produced, rather than as a +/// hard-coded list of which literals are which family. +fn parses_as_ipv4(destination: &str) -> bool { + let address = destination.split('/').next().unwrap_or(destination); + address.parse::().is_ok() +} + +/// Whether `destination` (a bare address or an address/prefix CIDR) parses +/// as IPv6. See `parses_as_ipv4` for why this is an invariant, not a table. +fn parses_as_ipv6(destination: &str) -> bool { + let address = destination.split('/').next().unwrap_or(destination); + address.parse::().is_ok() +} + +// --------------------------------------------------------------------------- +// B1 -- deny precedence: blocked-host rules precede allowed-host rules. +// --------------------------------------------------------------------------- + +#[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); + + // B1: both rules are present -- there is no de-duplication pass -- and + // the DROP rule precedes the ACCEPT rule so first-match-wins denies. + 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); + + 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); + + 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"], + ); +} + +// --------------------------------------------------------------------------- +// B4 -- unresolvable entries: fail closed only for a blocked host under an +// Allow default; otherwise log a warning and continue. +// --------------------------------------------------------------------------- + +#[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, &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, &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, &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, &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, &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:?}" + ); +} + +// --------------------------------------------------------------------------- +// B2 / B3 -- rule shape and IPv4 / IPv6 family split. +// --------------------------------------------------------------------------- + +#[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); + + assert_eq!( + args.ipv4.len(), + 2, + "one block entry and one allow entry must produce exactly 2 rules; \ + actual: {:?}", + args.ipv4 + ); + // B2: exactly `["-A", chain_name, "-d", destination, "-j", target]` -- + // no more, no fewer arguments, and in this order. + 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); + + // Property, not an enumerated example: every destination placed in the + // v4 bucket must itself parse as IPv4, and likewise for v6. This is what + // actually catches a leak, unlike checking the four inputs by name. + 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 + ); +} + +// --------------------------------------------------------------------------- +// B5 -- programmed-rule logging. +// --------------------------------------------------------------------------- + +#[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, &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(); + // Hard-coded from B5's documented format, not derived from `args`, so + // this test still pins the log format even if the rule-content tests + // elsewhere were themselves wrong. + 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:?}" + ); +} + +// --------------------------------------------------------------------------- +// B6 -- empty policy. +// --------------------------------------------------------------------------- + +#[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, &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/backends/lxc/common/src/network_iptables_forward_hook_spec.rs b/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs new file mode 100644 index 000000000..d6b3b82ea --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs @@ -0,0 +1,730 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Black-box specification for the FORWARD hook that steers a container's +//! egress into its own chain. +//! +//! Written against the documented contract of the hook builders and the +//! topology detectors, not against their bodies. + +use super::*; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; + +/// Hand back a directory path under the OS temp root that no other test (or +/// prior run) has used, so sysfs and netfilter fixtures never collide when +/// tests run concurrently in the same process. +fn fresh_fixture_dir(label: &str) -> PathBuf { + static SEQ: AtomicU32 = AtomicU32::new(0); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + std::env::temp_dir().join(format!("mxc-forward-hook-spec-{label}-{pid}-{seq}")) +} + +// The op token controls whether this is an install or a removal, and +// iptables reads the operation as the first word of the command; if it were +// buried elsewhere the CLI invocation would not do what the caller asked. +#[test] +fn iface_hook_rule_args_start_with_the_requested_operation() { + let install = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-I", "veth10", "MXC-tenant10"); + let delete = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-D", "veth10", "MXC-tenant10"); + + assert_eq!(install.first().map(String::as_str), Some("-I")); + assert_eq!(delete.first().map(String::as_str), Some("-D")); +} + +// This rule must be installed into the kernel's FORWARD chain specifically; +// any other chain would never see forwarded container traffic at all. +#[test] +fn iface_hook_rule_args_operate_on_the_forward_chain() { + let args = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-I", "veth11", "MXC-tenant11"); + + assert_eq!( + args.get(1).map(String::as_str), + Some("FORWARD"), + "expected the chain immediately after the operation to be FORWARD, got: {args:?}" + ); +} + +// The whole point of this builder is to match on the veth's own input +// interface, naming the specific interface passed in. +#[test] +fn iface_hook_rule_args_match_on_the_named_input_interface() { + let iface = "veth12"; + let args = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-I", iface, "MXC-tenant12"); + + let i_index = args + .iter() + .position(|a| a == "-i") + .expect("expected an -i input-interface match in the rule args"); + assert_eq!( + args.get(i_index + 1).map(String::as_str), + Some(iface), + "expected the -i match to name {iface}, got: {args:?}" + ); +} + +// A rule that matches the right interface but jumps to the wrong chain (or +// no chain) would never hook the container's own filtering. +#[test] +fn iface_hook_rule_args_jump_to_the_named_chain() { + let chain_name = "MXC-tenant13"; + let args = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-I", "veth13", chain_name); + + let j_index = args + .iter() + .position(|a| a == "-j") + .expect("expected a -j jump target in the rule args"); + assert_eq!( + args.get(j_index + 1).map(String::as_str), + Some(chain_name), + "expected the -j target to be {chain_name}, got: {args:?}" + ); +} + +// If this builder ever picked up a physdev match too, it would silently +// start behaving like the bridged-topology rule, defeating the reason the +// two builders are separate functions. +#[test] +fn iface_hook_rule_args_never_carry_a_physdev_match() { + let args = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-I", "veth14", "MXC-tenant14"); + + assert!( + !args.iter().any(|a| a == "physdev" || a == "--physdev-in"), + "an input-interface rule must not also carry a physdev match, got: {args:?}" + ); +} + +// A delete that is not token-for-token identical to its insert (apart from +// the operation) will not match anything in the kernel's rule table, and the +// rule it was supposed to remove leaks. +#[test] +fn iface_hook_delete_spec_differs_from_its_insert_only_by_the_operation() { + let iface = "veth15"; + let chain_name = "MXC-tenant15"; + let install = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-I", iface, chain_name); + let delete = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-D", iface, chain_name); + + assert_eq!( + install.len(), + delete.len(), + "install and delete rule specs must have the same number of tokens, install: {install:?}, delete: {delete:?}" + ); + assert_ne!( + install[0], delete[0], + "the first token is the operation and must differ between install and delete" + ); + assert_eq!( + &install[1..], + &delete[1..], + "every token besides the operation must match exactly, or the delete will not find the rule the install created" + ); +} + +// Same operation-placement guarantee as the interface builder, so an install +// and a delete of a physdev rule both do what the caller asked. +#[test] +fn physdev_hook_rule_args_start_with_the_requested_operation() { + let install = NetworkIptablesManager::build_forward_hook_physdev_rule_args( + "-I", + "veth20", + "MXC-tenant20", + ); + let delete = NetworkIptablesManager::build_forward_hook_physdev_rule_args( + "-D", + "veth20", + "MXC-tenant20", + ); + + assert_eq!(install.first().map(String::as_str), Some("-I")); + assert_eq!(delete.first().map(String::as_str), Some("-D")); +} + +// This rule must also land in the kernel's FORWARD chain -- the physdev +// match only changes what is matched within that chain, not which chain it +// is installed into. +#[test] +fn physdev_hook_rule_args_operate_on_the_forward_chain() { + let args = NetworkIptablesManager::build_forward_hook_physdev_rule_args( + "-I", + "veth21", + "MXC-tenant21", + ); + + assert_eq!( + args.get(1).map(String::as_str), + Some("FORWARD"), + "expected the chain immediately after the operation to be FORWARD, got: {args:?}" + ); +} + +// Once a veth is bridge-enslaved, the bridge port it entered on is the only +// thing that still identifies that one container, so the exact token +// sequence iptables needs for the physdev match -- not just "physdev appears +// somewhere" -- is the contract itself. +#[test] +fn physdev_hook_rule_args_match_the_named_physdev_in_port() { + let iface = "veth-c9f3"; + let chain_name = "MXC-tenant-c9f3"; + let args = + NetworkIptablesManager::build_forward_hook_physdev_rule_args("-I", iface, chain_name); + + let expected: Vec = ["-m", "physdev", "--physdev-in", iface] + .iter() + .map(|s| s.to_string()) + .collect(); + let found = args + .windows(expected.len()) + .any(|w| w == expected.as_slice()); + + assert!( + found, + "expected the contiguous sequence {expected:?} in the physdev rule args, got: {args:?}" + ); +} + +// A physdev rule that matches the right bridge port but jumps to the wrong +// chain would leave the container's own filtering unhooked, same as the +// interface builder's equivalent guarantee. +#[test] +fn physdev_hook_rule_args_jump_to_the_named_chain() { + let chain_name = "MXC-tenant23"; + let args = + NetworkIptablesManager::build_forward_hook_physdev_rule_args("-I", "veth23", chain_name); + + let j_index = args + .iter() + .position(|a| a == "-j") + .expect("expected a -j jump target in the rule args"); + assert_eq!( + args.get(j_index + 1).map(String::as_str), + Some(chain_name), + "expected the -j target to be {chain_name}, got: {args:?}" + ); +} + +// Once a veth is bridge-enslaved, FORWARD sees the bridge as the input +// interface, not the veth; an -i match naming the veth would match nothing +// at all, so this builder must not carry one. +#[test] +fn physdev_hook_rule_args_never_carry_an_input_interface_match() { + let args = NetworkIptablesManager::build_forward_hook_physdev_rule_args( + "-I", + "veth24", + "MXC-tenant24", + ); + + assert!( + !args.iter().any(|a| a == "-i"), + "a physdev-matched rule must not also carry an -i input-interface match, got: {args:?}" + ); +} + +// Same leak hazard as the interface builder's delete/insert invariant: a +// physdev delete spec that drifts from its insert will not find the rule and +// leaves it installed on the host forever. +#[test] +fn physdev_hook_delete_spec_differs_from_its_insert_only_by_the_operation() { + let iface = "veth25"; + let chain_name = "MXC-tenant25"; + let install = + NetworkIptablesManager::build_forward_hook_physdev_rule_args("-I", iface, chain_name); + let delete = + NetworkIptablesManager::build_forward_hook_physdev_rule_args("-D", iface, chain_name); + + assert_eq!( + install.len(), + delete.len(), + "install and delete rule specs must have the same number of tokens, install: {install:?}, delete: {delete:?}" + ); + assert_ne!( + install[0], delete[0], + "the first token is the operation and must differ between install and delete" + ); + assert_eq!( + &install[1..], + &delete[1..], + "every token besides the operation must match exactly, or the delete will not find the rule the install created" + ); +} + +// The two builders exist because a directly routed veth and a +// bridge-enslaved veth need different matches to see the same packets. If +// they ever produced identical rule specs, one of those two topologies would +// silently collapse onto the other's match, bringing back the bug this +// change fixes. +#[test] +fn the_iface_and_physdev_hook_builders_never_produce_the_same_rule_specification() { + let iface = "veth26"; + let chain_name = "MXC-tenant26"; + let iface_rule = + NetworkIptablesManager::build_forward_hook_iface_rule_args("-I", iface, chain_name); + let physdev_rule = + NetworkIptablesManager::build_forward_hook_physdev_rule_args("-I", iface, chain_name); + + assert_ne!( + iface_rule, physdev_rule, + "the input-interface rule and the physdev rule must differ, or bridged and directly \ + routed containers would collapse onto the same match" + ); +} + +// The kernel only creates a `master` entry once an interface is enslaved to +// a bridge, so its presence alone is what this function is allowed to trust. +#[test] +fn an_interface_with_a_master_entry_is_reported_as_bridge_enslaved() { + let root = fresh_fixture_dir("enslaved"); + let iface_dir = root.join("veth-a1b2"); + fs::create_dir_all(&iface_dir).expect("failed to create the fake sysfs interface directory"); + fs::write(iface_dir.join("master"), "").expect("failed to create the fake master entry"); + + let result = NetworkIptablesManager::veth_topology_in(&root, "veth-a1b2"); + + fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); + assert_eq!( + result, + VethTopology::Bridged, + "an interface with a master entry must be reported as bridge-enslaved" + ); +} + +// A veth that is not enslaved has an interface directory but no `master` +// entry inside it; this is the ordinary "routed directly" topology. +#[test] +fn an_interface_without_a_master_entry_is_not_bridge_enslaved() { + let root = fresh_fixture_dir("unenslaved"); + let iface_dir = root.join("veth-d4e5"); + fs::create_dir_all(&iface_dir).expect("failed to create the fake sysfs interface directory"); + + let result = NetworkIptablesManager::veth_topology_in(&root, "veth-d4e5"); + + fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); + assert_eq!( + result, + VethTopology::DirectlyRouted, + "an interface directory with no master entry is a positive directly-routed finding" + ); +} + +// This assertion is the reverse of what it used to be, and the reversal is the +// fix. It previously read a missing interface directory as "not enslaved", +// which is how an unreadable sysfs came to be reported as directly routed. +// +// The two facts are independent: `discover_veth_interface` parses `lxc-info`, +// not sysfs, so the veth can be known to exist while its sysfs entry is +// missing, masked, or unreadable. Absence of the directory is therefore a +// failed lookup, not evidence about the topology. +#[test] +fn a_missing_interface_directory_is_an_unknown_topology_not_a_routed_one() { + let root = fresh_fixture_dir("missing-iface"); + fs::create_dir_all(&root).expect("failed to create the fake sysfs root"); + + let result = NetworkIptablesManager::veth_topology_in(&root, "veth-ghost"); + + fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); + assert_eq!( + result, + VethTopology::Unknown, + "a missing interface directory establishes nothing about the topology" + ); +} + +// The whole sysfs root being absent is the masked/unmounted case from review. +#[test] +fn an_unreadable_sysfs_root_is_an_unknown_topology() { + let root = fresh_fixture_dir("no-sysfs-at-all"); + let _ = fs::remove_dir_all(&root); + + let result = NetworkIptablesManager::veth_topology_in(&root, "veth-a1b2"); + + assert_eq!( + result, + VethTopology::Unknown, + "an absent sysfs root must not be read as a directly-routed topology" + ); +} + +// The two probes in `veth_topology_in` read metadata differently on purpose, +// and only a symlink can tell them apart. A dangling `master` still means the +// veth is enslaved, so that probe must NOT follow the link -- following it +// would report a bridged veth as directly routed, which is the relaxed branch. +// +// This is the mutation that survived the first battery. It is Unix-gated +// because a dangling symlink is not creatable without privilege on Windows; +// the same pattern is used by +// `resolve_denied_host_path_fails_closed_on_dangling_symlink`. +#[cfg(unix)] +#[test] +fn a_dangling_master_symlink_still_means_the_veth_is_bridged() { + use std::os::unix::fs::symlink; + + let root = fresh_fixture_dir("dangling-master"); + let iface_dir = root.join("veth-dangle"); + fs::create_dir_all(&iface_dir).expect("failed to create the fake sysfs root"); + symlink(root.join("no-such-bridge"), iface_dir.join("master")) + .expect("failed to create the dangling master symlink"); + + let result = NetworkIptablesManager::veth_topology_in(&root, "veth-dangle"); + + fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); + assert_eq!( + result, + VethTopology::Bridged, + "a dangling master symlink still means enslaved; following it would \ + report a bridged veth as directly routed" + ); +} + +// The other half of the asymmetry. `/sys/class/net/` is itself a symlink +// into `/sys/devices`, so the interface probe MUST follow it -- a dangling one +// proves nothing was observed, and calling that directly routed is the same +// fail-open defect one level down. +#[cfg(unix)] +#[test] +fn a_dangling_interface_symlink_is_an_unknown_topology() { + use std::os::unix::fs::symlink; + + let root = fresh_fixture_dir("dangling-iface"); + fs::create_dir_all(&root).expect("failed to create the fake sysfs root"); + symlink(root.join("no-such-device"), root.join("veth-ghostlink")) + .expect("failed to create the dangling interface symlink"); + + let result = NetworkIptablesManager::veth_topology_in(&root, "veth-ghostlink"); + + fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); + assert_eq!( + result, + VethTopology::Unknown, + "an interface symlink whose target does not resolve establishes nothing" + ); +} + +// The decision that actually carries the security weight: which topologies get +// the relaxed treatment that downgrades a failed physdev hook to a warning. +// Only a positive directly-routed finding may. +#[test] +fn only_a_confirmed_directly_routed_topology_escapes_the_bridged_treatment() { + assert!( + NetworkIptablesManager::treat_as_bridged(VethTopology::Bridged), + "a bridged veth must be treated as bridged" + ); + assert!( + NetworkIptablesManager::treat_as_bridged(VethTopology::Unknown), + "an unknown topology must be treated as bridged, so a failed physdev hook stays fatal" + ); + assert!( + !NetworkIptablesManager::treat_as_bridged(VethTopology::DirectlyRouted), + "a confirmed directly-routed veth is the one case that may relax the hook requirement" + ); +} + +// The toggle file's documented "on" value is exactly "1"; this is the +// baseline positive case every other Function 4 test is a variation of. +#[test] +fn a_bridge_netfilter_toggle_of_1_is_reported_active() { + let dir = fresh_fixture_dir("nf-on"); + fs::create_dir_all(&dir).expect("failed to create the fake proc directory"); + let toggle = dir.join("bridge-nf-call-iptables"); + fs::write(&toggle, "1").expect("failed to write the fake netfilter toggle"); + + let result = NetworkIptablesManager::bridge_netfilter_active_at(&toggle); + + fs::remove_dir_all(&dir).expect("failed to clean up the fake proc directory"); + assert!( + result, + "a toggle file containing exactly \"1\" must be reported as active" + ); +} + +// The real kernel file ends in a newline; a comparison that forgets to trim +// would treat every real, active system as inactive. +#[test] +fn a_bridge_netfilter_toggle_of_1_with_a_trailing_newline_is_reported_active() { + let dir = fresh_fixture_dir("nf-on-newline"); + fs::create_dir_all(&dir).expect("failed to create the fake proc directory"); + let toggle = dir.join("bridge-nf-call-iptables"); + fs::write(&toggle, "1\n").expect("failed to write the fake netfilter toggle"); + + let result = NetworkIptablesManager::bridge_netfilter_active_at(&toggle); + + fs::remove_dir_all(&dir).expect("failed to clean up the fake proc directory"); + assert!( + result, + "a toggle file containing \"1\\n\", matching the real kernel file's trailing newline, must be reported as active" + ); +} + +// "0" is the documented "off" value and must read as inactive, not merely as +// "not 1 so default to something". +#[test] +fn a_bridge_netfilter_toggle_of_0_is_not_active() { + let dir = fresh_fixture_dir("nf-off"); + fs::create_dir_all(&dir).expect("failed to create the fake proc directory"); + let toggle = dir.join("bridge-nf-call-iptables"); + fs::write(&toggle, "0").expect("failed to write the fake netfilter toggle"); + + let result = NetworkIptablesManager::bridge_netfilter_active_at(&toggle); + + fs::remove_dir_all(&dir).expect("failed to clean up the fake proc directory"); + assert!( + !result, + "a toggle file containing \"0\" must not be reported as active" + ); +} + +// Absence means the bridge-netfilter machinery is not loaded at all, which +// is the unsafe case: it must never be mistaken for "on". +#[test] +fn a_missing_bridge_netfilter_toggle_is_not_active() { + let dir = fresh_fixture_dir("nf-missing"); + fs::create_dir_all(&dir).expect("failed to create the fake proc directory"); + let toggle = dir.join("bridge-nf-call-iptables"); + + let result = NetworkIptablesManager::bridge_netfilter_active_at(&toggle); + + fs::remove_dir_all(&dir).expect("failed to clean up the fake proc directory"); + assert!( + !result, + "a toggle file that does not exist at all must not be reported as active" + ); +} + +// Empty contents are neither "1" nor "0"; the function must not treat a +// truncated or not-yet-written file as active. +#[test] +fn an_empty_bridge_netfilter_toggle_is_not_active() { + let dir = fresh_fixture_dir("nf-empty"); + fs::create_dir_all(&dir).expect("failed to create the fake proc directory"); + let toggle = dir.join("bridge-nf-call-iptables"); + fs::write(&toggle, "").expect("failed to write the fake netfilter toggle"); + + let result = NetworkIptablesManager::bridge_netfilter_active_at(&toggle); + + fs::remove_dir_all(&dir).expect("failed to clean up the fake proc directory"); + assert!( + !result, + "a toggle file with empty contents must not be reported as active" + ); +} + +// Whitespace-only contents must not survive trimming into an empty string +// that somehow compares equal to "1"; it must compare as not-"1" and read as +// inactive. +#[test] +fn a_whitespace_only_bridge_netfilter_toggle_is_not_active() { + let dir = fresh_fixture_dir("nf-whitespace"); + fs::create_dir_all(&dir).expect("failed to create the fake proc directory"); + let toggle = dir.join("bridge-nf-call-iptables"); + fs::write(&toggle, " \n\t ").expect("failed to write the fake netfilter toggle"); + + let result = NetworkIptablesManager::bridge_netfilter_active_at(&toggle); + + fs::remove_dir_all(&dir).expect("failed to clean up the fake proc directory"); + assert!( + !result, + "a toggle file with only whitespace must not be reported as active" + ); +} + +// Any value that is not exactly "1" must read as inactive, not just values +// that happen to be "0"; otherwise a fail-open bug could hide behind an +// unexpected value like a stray "2". +#[test] +fn a_bridge_netfilter_toggle_with_an_unrecognized_value_is_not_active() { + let dir = fresh_fixture_dir("nf-unrecognized"); + fs::create_dir_all(&dir).expect("failed to create the fake proc directory"); + let toggle = dir.join("bridge-nf-call-iptables"); + fs::write(&toggle, "2").expect("failed to write the fake netfilter toggle"); + + let result = NetworkIptablesManager::bridge_netfilter_active_at(&toggle); + + fs::remove_dir_all(&dir).expect("failed to clean up the fake proc directory"); + assert!( + !result, + "a toggle file containing a value other than \"1\" must not be reported as active" + ); +} + +// --------------------------------------------------------------------------- +// Return-path rules +// +// The hooks above steer traffic *leaving* the container. A reply arrives in +// the opposite direction and matches none of them, so under a DROP forward +// policy an explicitly allowed destination is unreachable. These rules carry +// that reply, and the contract they have to keep is narrow: accept only what +// conntrack already knows about, name one container, and never become an +// inbound control. +// --------------------------------------------------------------------------- + +// A rule that matched only on the interface would accept inbound packets that +// begin a new connection, which is an inbound policy decision this rule has no +// business making. The state match is what confines it to traffic the egress +// chain already permitted. +#[test] +fn return_rule_args_accept_only_established_and_related_traffic() { + for args in [ + NetworkIptablesManager::build_forward_return_iface_rule_args("-I", "veth0"), + NetworkIptablesManager::build_forward_return_physdev_rule_args("-I", "veth0"), + ] { + let state = args + .iter() + .position(|a| a == "--state") + .unwrap_or_else(|| panic!("the rule must carry a state match; got: {args:?}")); + + assert_eq!( + args[state + 1], + "ESTABLISHED,RELATED", + "the rule must accept only traffic conntrack already knows; got: {args:?}" + ); + assert!( + args.windows(2).any(|w| w[0] == "-m" && w[1] == "state"), + "the state value needs its match module loaded; got: {args:?}" + ); + } +} + +// The whole point is to accept the reply. Jumping to the MXC chain instead +// would test inbound packets against rules written as `-d ` for +// egress and, under an allow default, fall through to the chain's closing +// ACCEPT -- an inbound enforcement surface acquired by accident. +#[test] +fn return_rule_args_jump_straight_to_accept_and_never_to_a_chain() { + for args in [ + NetworkIptablesManager::build_forward_return_iface_rule_args("-I", "veth0"), + NetworkIptablesManager::build_forward_return_physdev_rule_args("-I", "veth0"), + ] { + let target = args + .iter() + .position(|a| a == "-j") + .unwrap_or_else(|| panic!("the rule must name a target; got: {args:?}")); + + assert_eq!( + args[target + 1], + "ACCEPT", + "the return rule must accept directly; got: {args:?}" + ); + assert!( + !args.iter().any(|a| a.starts_with("MXC-")), + "the return rule must not reference the policy chain; got: {args:?}" + ); + } +} + +// Matching the reply direction is the entire difference from the hooks. A rule +// that named the container's port as *input* would duplicate the egress hook +// and leave the reply path exactly as broken as before. +#[test] +fn return_rule_args_match_the_container_port_as_output() { + let iface = NetworkIptablesManager::build_forward_return_iface_rule_args("-I", "veth0"); + assert!( + iface.windows(2).any(|w| w[0] == "-o" && w[1] == "veth0"), + "the interface form must match the veth as output; got: {iface:?}" + ); + assert!( + !iface.iter().any(|a| a == "-i"), + "the interface form must not match on input; got: {iface:?}" + ); + + let physdev = NetworkIptablesManager::build_forward_return_physdev_rule_args("-I", "veth0"); + assert!( + physdev + .windows(2) + .any(|w| w[0] == "--physdev-out" && w[1] == "veth0"), + "the physdev form must match the veth as the outbound bridge port; got: {physdev:?}" + ); + assert!( + !physdev.iter().any(|a| a == "--physdev-in"), + "the physdev form must not match the inbound bridge port; got: {physdev:?}" + ); +} + +// An unscoped rule would accept established traffic for every container on the +// host, so one container's flows would be carried by another's policy. +#[test] +fn return_rule_args_name_the_specific_container_port() { + for args in [ + NetworkIptablesManager::build_forward_return_iface_rule_args("-I", "vethABC"), + NetworkIptablesManager::build_forward_return_physdev_rule_args("-I", "vethABC"), + ] { + assert!( + args.iter().any(|a| a == "vethABC"), + "the rule must name the container's port; got: {args:?}" + ); + assert!( + !args.iter().any(|a| a == "lxcbr0"), + "the rule must not be scoped to the shared bridge; got: {args:?}" + ); + } +} + +// iptables deletes by full rule specification, so a delete that differs from +// its insert by any match finds nothing and leaks the rule into a FORWARD +// chain that outlives the container. +#[test] +fn return_rule_delete_specs_differ_from_their_inserts_only_by_the_operation() { + for (install, remove) in [ + ( + NetworkIptablesManager::build_forward_return_iface_rule_args("-I", "veth0"), + NetworkIptablesManager::build_forward_return_iface_rule_args("-D", "veth0"), + ), + ( + NetworkIptablesManager::build_forward_return_physdev_rule_args("-I", "veth0"), + NetworkIptablesManager::build_forward_return_physdev_rule_args("-D", "veth0"), + ), + ] { + assert_eq!( + install[0], "-I", + "the install must insert; got: {install:?}" + ); + assert_eq!(remove[0], "-D", "the removal must delete; got: {remove:?}"); + assert_eq!( + install[1..], + remove[1..], + "the delete spec must match the insert exactly apart from the operation" + ); + } +} + +// Both forms are installed together and deleted independently, so if they +// produced the same specification one delete would remove the other's rule and +// the second would silently find nothing. +#[test] +fn the_two_return_rule_forms_never_produce_the_same_specification() { + let iface = NetworkIptablesManager::build_forward_return_iface_rule_args("-I", "veth0"); + let physdev = NetworkIptablesManager::build_forward_return_physdev_rule_args("-I", "veth0"); + + assert_ne!( + iface, physdev, + "the two return forms must be distinguishable to iptables" + ); +} + +// The return rules must not be mistaken for the egress hooks: those jump to +// the chain and match the inbound direction, and deleting one with the other's +// specification would leave a rule behind. +#[test] +fn return_rules_are_distinguishable_from_the_egress_hooks() { + let egress = NetworkIptablesManager::build_forward_hook_iface_rule_args("-I", "veth0", "MXC-x"); + let egress_physdev = + NetworkIptablesManager::build_forward_hook_physdev_rule_args("-I", "veth0", "MXC-x"); + let ret = NetworkIptablesManager::build_forward_return_iface_rule_args("-I", "veth0"); + let ret_physdev = NetworkIptablesManager::build_forward_return_physdev_rule_args("-I", "veth0"); + + for e in [&egress, &egress_physdev] { + for r in [&ret, &ret_physdev] { + assert_ne!( + *e, *r, + "an egress hook and a return rule must never share a specification" + ); + } + } +} diff --git a/src/backends/lxc/common/src/network_iptables_proxy_spec.rs b/src/backends/lxc/common/src/network_iptables_proxy_spec.rs new file mode 100644 index 000000000..d989bbbb5 --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_proxy_spec.rs @@ -0,0 +1,501 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Black-box specification for cooperative-proxy egress enforcement: when the +//! policy routes traffic through a proxy, the container must be able to reach +//! that proxy and nothing else. +//! +//! Written against the documented contract, not against the bodies of the +//! builders. Every test that reaches `apply_firewall_rules` names an IP +//! literal or `localhost` as the proxy host, so the assertions do not depend +//! on the DNS the machine running them happens to have. + +use super::*; +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ + ContainerPolicy, NetworkEnforcementMode, NetworkPolicy, ProxyAddress, ProxyConfig, +}; + +/// Build a firewall-mode policy routed through the given proxy endpoint, +/// leaving every other field at its default. +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() + } +} + +/// Apply `policy` through the fake firewall and hand back the manager and +/// every command the apply issued. +fn apply_and_collect( + container: &str, + policy: &ContainerPolicy, +) -> ( + NetworkIptablesManager, + Vec>, + Result, +) { + let fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new(container); + manager.set_veth_interface("veth-proxy0"); + 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) +} + +/// The commands from `issued` that appended a rule to the container's chain +/// with the given binary, in the order they were issued. +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() +} + +/// The jump target (`-j `) of a rule, or `None` when it has none. +fn action_of(rule: &[String]) -> Option<&str> { + let index = rule.iter().position(|arg| arg == "-j")?; + rule.get(index + 1).map(String::as_str) +} + +/// Whether `rule` carries `flag` immediately followed by `value`. +fn has_pair(rule: &[String], flag: &str, value: &str) -> bool { + rule.windows(2) + .any(|pair| pair[0] == flag && pair[1] == value) +} + +// --------------------------------------------------------------------------- +// The catch-all action. +// --------------------------------------------------------------------------- + +// Proxy mode is "deny all except the proxy". A configured default policy of +// Allow would end the chain in ACCEPT, which lets every destination through +// and makes the proxy ACCEPT above it meaningless -- the container would +// reach the whole internet directly. +#[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" + ); +} + +// Negative control for the rule above: with no proxy the configured default +// policy must still decide the catch-all, or the proxy change would have +// silently turned every Allow policy into a deny-all. +#[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" + ); +} + +// The same forcing must survive through the rule builder the install path +// actually calls, not just the pure helper underneath it. +#[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:?}"); +} + +// End-to-end through apply: an Allow default plus a proxy must still close +// the chain with DROP. +#[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:?}" + ); +} + +// --------------------------------------------------------------------------- +// The proxy ACCEPT. +// --------------------------------------------------------------------------- + +// The one destination a proxied container may reach is the proxy's address on +// the proxy's port over TCP. A rule missing any of those three narrows or +// widens the hole in ways the policy did not ask for. +#[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")) + .collect(); + + assert_eq!( + accepts.len(), + 1, + "a proxied chain must carry exactly one ACCEPT, 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:?}" + ); +} + +// Ordering is the whole security property: a DROP appended before the proxy +// ACCEPT would match first and the container would reach nothing at all. +#[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("DROP")], + "a proxied chain must read exactly 'accept the proxy, drop the rest'; actual: {rules:?}" + ); +} + +// Every address the proxy host resolves to belongs to that same proxy, so all +// of them are opened. Opening only the first would drop a client that picked +// a different one. +#[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:?}" + ); +} + +// --------------------------------------------------------------------------- +// What proxy mode must NOT emit. +// --------------------------------------------------------------------------- + +// An unscoped port 53 ACCEPT is a standing DNS-tunnel exfil path straight +// through a posture whose entire point is that the proxy is the only +// reachable destination. The container resolves the proxy through its +// hosts-file pin instead, so it needs no resolver. +#[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()); + // Without this the test passes vacuously: a chain name that matches + // nothing yields an empty list, and the loop below asserts nothing. + 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:?}" + ); + } +} + +// The base exemptions belong to the ordinary allow/block posture. `-i lo` +// and ESTABLISHED,RELATED in a deny-all proxy chain would let flows the proxy +// never brokered keep running. +#[test] +fn proxy_mode_emits_no_base_exemptions() { + 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:?}" + ); + + for rule in rules { + assert!( + !has_pair(rule, "-i", "lo"), + "a proxied chain must not carry the loopback exemption; actual: {rule:?}" + ); + assert!( + !has_pair(rule, "--state", "ESTABLISHED,RELATED"), + "a proxied chain must not carry the conntrack exemption; actual: {rule:?}" + ); + } +} + +// Under "the proxy and nothing else" a blocked host is already denied by the +// closing DROP, and an allowed host contradicts the model. Programming +// either would widen the posture the proxy defines. +#[test] +fn proxy_mode_programs_neither_the_allow_list_nor_the_block_list() { + let mut policy = policy_with_proxy("10.9.8.7", 3128); + policy.allowed_hosts = vec!["10.1.1.1".to_string()]; + policy.blocked_hosts = vec!["10.2.2.2".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") && !has_pair(rule, "-d", "10.2.2.2"), + "a proxied chain must ignore the host lists; actual: {rule:?}" + ); + } +} + +// The proxy endpoint is IPv4, so nothing authorizes IPv6 egress. The v6 +// chain must therefore hold its closing DROP and nothing else -- leaving it +// empty would fail open the moment the chain is hooked. +#[test] +fn the_ipv6_chain_carries_only_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("DROP")], + "the IPv6 chain of a proxied container must be a bare deny-all; actual: {rules:?}" + ); +} + +// Negative control for every "proxy mode omits X" test above: without a proxy +// the base exemptions and the host lists must still be programmed, or those +// tests would pass against a manager that had stopped emitting rules at all. +#[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, "-i", "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:?}" + ); +} + +// --------------------------------------------------------------------------- +// IPv6 proxy endpoints. +// --------------------------------------------------------------------------- + +// The proxy rule is emitted with IPv4 iptables only. An IPv6 proxy that fell +// through IPv4 endpoint selection would be silently discarded, leaving a +// deny-all container whose proxy was never authorized -- so it must be +// refused loudly instead. +#[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}" + ); + } +} + +// Both spellings of an IPv6 literal reach the same code path, and a bare one +// is what a `{ host, port }` proxy carries. +#[test] +fn ipv6_literals_are_recognized_bracketed_or_bare() { + assert!(NetworkIptablesManager::host_is_ipv6_literal("::1")); + assert!(NetworkIptablesManager::host_is_ipv6_literal("[::1]")); + assert!(NetworkIptablesManager::host_is_ipv6_literal("2001:db8::1")); + assert!(!NetworkIptablesManager::host_is_ipv6_literal("10.9.8.7")); + assert!(!NetworkIptablesManager::host_is_ipv6_literal( + "proxy.example.com" + )); +} + +// --------------------------------------------------------------------------- +// The hosts-file pin. +// --------------------------------------------------------------------------- + +// With DNS closed, a container handed a proxy URL naming a hostname cannot +// resolve it. The pin is what makes the proxy reachable, and it must name the +// address this apply authorized rather than one a later lookup returned. +#[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"); +} + +// An IP literal is already the address the chain allows, so there is nothing +// to resolve and nothing to pin. Recording a pin here would write a hosts +// entry whose name column is an IP literal. +#[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" + ); +} + +// A policy with no proxy must not leave a pin behind, or the runner would +// write an unrelated hosts entry into every container. +#[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()); +} + +// --------------------------------------------------------------------------- +// Malformed proxy configuration. +// --------------------------------------------------------------------------- + +// Port 0 is not a listening port. Programming `--dport 0` would build a rule +// that can never match, leaving a container that looks proxied and reaches +// nothing. +#[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}" + ); +} + +// A proxy host that resolves to nothing cannot be authorized, and continuing +// would install a deny-all chain the caller believes is proxied. +#[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}" + ); +} + +// A policy carrying no proxy must produce no endpoints, which is what puts +// the chain back on the ordinary allow/block path. +#[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()); +} diff --git a/src/backends/lxc/common/src/network_iptables_veth_spec.rs b/src/backends/lxc/common/src/network_iptables_veth_spec.rs new file mode 100644 index 000000000..2b14eac83 --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_veth_spec.rs @@ -0,0 +1,193 @@ +//! Spec for the fail-closed contract of `apply_firewall_rules`: when the +//! firewall cannot be scoped to the container, the caller must be told the +//! policy was not applied rather than being handed a chain that filters +//! nothing. +//! +//! Attached to `network_iptables` as a child module via `#[path]`, so it can +//! reach the `#[cfg(test)]` fake-firewall seam. + +use super::*; +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode}; + +/// Build a policy that requests the given network enforcement mode, leaving +/// every other field at its default. +fn policy_requesting(mode: NetworkEnforcementMode) -> ContainerPolicy { + ContainerPolicy { + network_enforcement_mode: mode, + ..Default::default() + } +} + +// A chain that is never hooked to the container's veth interface is a chain +// no packet ever traverses. If the manager does not know which veth belongs +// to the container, it must refuse rather than report success on a firewall +// that filters nothing. This covers the `Firewall` half of R1; `Both` is +// covered separately below so a fix scoped to only one enforcement mode +// cannot pass the suite. +#[test] +fn apply_is_refused_when_the_container_interface_is_unknown_in_firewall_mode() { + let _fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("ctrl-firewall"); + let policy = policy_requesting(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + + assert!( + result.is_err(), + "Firewall mode with no veth interface set must fail closed, got {:?}", + result + ); +} + +// Same hazard as above under `Both`, which also requests firewall +// enforcement. A fix that only checks the interface in the `Firewall` arm +// would leave `Both` silently unenforced, and only a dedicated test for this +// mode would catch it. +#[test] +fn apply_is_refused_when_the_container_interface_is_unknown_in_both_mode() { + let _fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("ctrl-both"); + let policy = policy_requesting(NetworkEnforcementMode::Both); + let mut logger = Logger::new(Mode::Buffer); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + + assert!( + result.is_err(), + "Both mode with no veth interface set must fail closed, got {:?}", + result + ); +} + +// A caller who is told "firewall applied" while the interface was never known +// deserves an error that says what to check. If the message drops the chain +// name or the "will not be enforced" meaning, an operator debugging why a +// container's traffic is unfiltered has nothing to search logs for. +#[test] +fn refusal_error_names_the_unenforced_chain() { + let _fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("acme-web"); + let policy = policy_requesting(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + + let err = manager + .apply_firewall_rules(&policy, &mut logger) + .expect_err("Firewall mode with no veth interface set must fail closed"); + + let chain = manager.chain_name(); + assert!( + err.contains(chain), + "error must name the chain left unenforced ({chain}), got: {err}" + ); + + let lower = err.to_lowercase(); + assert!( + lower.contains("not") && lower.contains("enforc"), + "error must convey that the policy will not be enforced, got: {err}" + ); +} + +// Negative control for R1: the only thing that changes here is that the veth +// interface is now known. Without this test, R1's failures would prove +// nothing about the interface check specifically -- an `apply_firewall_rules` +// that always returned `Err` would also pass every R1 test above. +#[test] +fn apply_succeeds_once_the_veth_interface_is_known() { + let fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("ctrl-negative"); + manager.set_veth_interface("veth-ctrl0"); + let policy = policy_requesting(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + let _ = fake.forget_issued(); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + + assert!( + result.is_ok(), + "the same Firewall policy that fails with no veth interface must succeed once one is set, got {:?}", + result + ); + assert!( + !fake.issued().is_empty(), + "a successful Firewall apply must actually issue iptables commands, not just report success" + ); +} + +// A caller who is refused must not be left holding a chain on the host: an +// unhooked-but-still-installed chain is inert today but becomes a liability +// the moment anything later hooks a chain by that name. The failed apply +// must tear down what it created, not merely stop short of hooking it up. +#[test] +fn apply_tears_down_the_chain_it_created_when_it_fails_closed() { + let fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("ctrl-teardown"); + let policy = policy_requesting(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + let _ = fake.forget_issued(); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + assert!( + result.is_err(), + "expected the apply to fail closed so the teardown path runs, got {:?}", + result + ); + + let issued = fake.issued(); + let chain = manager.chain_name(); + let creation_index = issued + .iter() + .position(|cmd| cmd.iter().any(|a| a == "-N") && cmd.iter().any(|a| a == chain)) + .unwrap_or_else(|| { + panic!( + "expected a chain-creation (-N) command naming {chain} before the failure, issued: {:?}", + issued + ) + }); + let teardown_index = issued + .iter() + .position(|cmd| { + (cmd.iter().any(|a| a == "-F") || cmd.iter().any(|a| a == "-X")) + && cmd.iter().any(|a| a == chain) + }) + .unwrap_or_else(|| { + panic!( + "expected a teardown (-F/-X) command naming {chain} after the failed apply, issued: {:?}", + issued + ) + }); + + assert!( + teardown_index > creation_index, + "teardown of {chain} must be issued after its creation, issued: {:?}", + issued + ); +} + +// A container that never asked for a firewall (`Capabilities` is the default +// enforcement mode) must not be punished for an interface the caller was +// never required to set. Any firewall command touching the host here would +// be an unrequested side effect on a container that opted out of firewalling +// entirely. +#[test] +fn capabilities_only_container_is_unaffected_by_a_missing_veth_interface() { + let fake = super::test_firewall::install(); + let mut manager = NetworkIptablesManager::new("ctrl-capsonly"); + let policy = policy_requesting(NetworkEnforcementMode::Capabilities); + let mut logger = Logger::new(Mode::Buffer); + let _ = fake.forget_issued(); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + + assert!( + result.is_ok(), + "Capabilities mode must not fail just because the veth interface is unknown, got {:?}", + result + ); + assert!( + fake.issued().is_empty(), + "Capabilities-only enforcement must not issue any iptables commands, issued: {:?}", + fake.issued() + ); +} diff --git a/src/backends/lxc/common/tests/chain_name_script_drift_spec.rs b/src/backends/lxc/common/tests/chain_name_script_drift_spec.rs new file mode 100644 index 000000000..182ef09ab --- /dev/null +++ b/src/backends/lxc/common/tests/chain_name_script_drift_spec.rs @@ -0,0 +1,316 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Drift guard: the bash network scripts must derive the firewall chain name at +//! run time rather than hard-coding it. +//! +//! This is deliberately not a unit test. It reads the repository from disk, so +//! it crosses Feathers' file-system line, and it lives in its own file so that +//! `chain_name_spec.rs` stays filesystem- and dependency-free. +//! +//! Why it exists rather than more `chain_name_for` cases: on the day +//! `run_lxc_network_enforcement_test.sh` was asserting against +//! `MXC-CLI-LXC-Net-Deny`, every one of the twenty naming tests in +//! `chain_name_spec.rs` was green. They cover what the function returns, and +//! the defect was in what the scripts believed it returned. A chain name is a +//! digest of the container name, so a literal in a script names a chain that +//! cannot exist: `iptables -S ` always fails, the cleanup assertion +//! reads that failure as "the chain was removed", and the test passes without +//! examining anything. Catching that class requires reading the scripts. + +use lxc_common::network_iptables::chain_name_for; +use std::fs; +use std::path::PathBuf; + +/// Repository `tests/scripts/` directory. +/// +/// `CARGO_MANIFEST_DIR` is `src/backends/lxc/common/` during `cargo test`. +fn scripts_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() // src/backends/lxc + .and_then(|p| p.parent()) // src/backends + .and_then(|p| p.parent()) // src + .and_then(|p| p.parent()) // repo root + .expect("could not determine repo root") + .join("tests") + .join("scripts") +} + +/// The network scripts that make assertions about MXC-owned chains, and so must +/// derive the chain name instead of naming one. +/// +/// Enumerated rather than discovered: a glob would silently shrink to zero on a +/// rename or a path change and still report success, which is the same +/// vacuous-pass defect this file exists to catch. A new network script that +/// asserts on chains belongs in this list. +const CHAIN_ASSERTING_SCRIPTS: &[&str] = &[ + "run_lxc_network_cidr_boundary_test.sh", + "run_lxc_network_deny_precedence_test.sh", + "run_lxc_network_dualstack_test.sh", + "run_lxc_network_enforcement_test.sh", + "run_lxc_network_invalid_cidr_test.sh", + "run_lxc_network_ipv6_cidr_test.sh", +]; + +/// Read every `run_lxc_network_*.sh` as (file name, contents). +/// +/// Fails rather than returning an empty vector when the directory is missing or +/// holds no network scripts, so a broken path cannot look like a clean run. +fn network_scripts() -> Vec<(String, String)> { + let dir = scripts_dir(); + let entries = + fs::read_dir(&dir).unwrap_or_else(|e| panic!("could not read {}: {e}", dir.display())); + + let mut scripts = Vec::new(); + for entry in entries { + let path = entry.expect("could not read a directory entry").path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.starts_with("run_lxc_network_") || !name.ends_with(".sh") { + continue; + } + let body = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("could not read {}: {e}", path.display())); + scripts.push((name.to_string(), body)); + } + + assert!( + !scripts.is_empty(), + "no run_lxc_network_*.sh scripts found under {} -- this guard verified nothing", + dir.display() + ); + scripts +} + +/// The `sed` program `mxc_chains` uses to enumerate MXC-owned chains. +/// +/// Legitimate because it names the prefix and matches whatever follows, rather +/// than claiming to know a specific digest. +const MXC_CHAIN_SED_PROGRAM: &str = r"s/^-N \(MXC-.*\)$/\1/p"; + +/// Every `MXC-` on a line that is not one of the two legitimate +/// idioms: the pinned shape check and the chain-enumerating `sed` program. +/// +/// Scans whole lines rather than just assignments, because a literal is just as +/// vacuous passed straight to an assertion -- +/// `assert_no_forward_reference "MXC-CLI-LXC-Net-Deny"` names a chain that +/// cannot exist exactly as an assignment would. +fn illegal_mxc_literals(line: &str) -> Vec { + if line.trim_start().starts_with('#') { + return Vec::new(); + } + + let stripped = line + .replace(DOCUMENTED_SHAPE_ERE, " ") + .replace(MXC_CHAIN_SED_PROGRAM, " "); + + let mut found = Vec::new(); + let mut search = stripped.as_str(); + while let Some(at) = search.find("MXC-") { + search = &search[at + "MXC-".len()..]; + let tail: String = search + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + if !tail.is_empty() { + found.push(format!("MXC-{tail}")); + } + } + found +} + +#[test] +fn no_network_script_names_a_specific_chain() { + let mut offenders = Vec::new(); + + for (name, body) in network_scripts() { + for (index, line) in body.lines().enumerate() { + for literal in illegal_mxc_literals(line) { + offenders.push(format!("{name}:{} names '{literal}'", index + 1)); + } + } + } + + assert!( + offenders.is_empty(), + "chain names are derived from a digest of the container name, so naming \ + a specific chain -- whether by assignment or inline in an assertion -- \ + names one that cannot exist, and every assertion against it passes \ + vacuously. Derive the name from the run's own --debug output instead. \ + Offenders:\n {}", + offenders.join("\n ") + ); +} + +/// The one shape every script checks its derived chain name against. +/// +/// Pinned here because the check is copy-pasted into each script: nothing in +/// bash ties those copies to each other or to `chain_name_for`, so a change to +/// the hash width would leave five stale patterns behind. The test below +/// hand-rolls this exact pattern's semantics, so changing the constant means +/// updating `matches_documented_shape` in the same edit. +const DOCUMENTED_SHAPE_ERE: &str = "^MXC-([A-Za-z0-9_-]{1,7}-)?[a-z2-7]{16}$"; + +/// Recognizer for [`DOCUMENTED_SHAPE_ERE`], hand-rolled to keep this suite free +/// of a regex dependency, matching the convention in `chain_name_spec.rs`. +/// +/// The 16-character base32 hash is a fixed-width suffix, so the separator (when +/// a slug is present) is always the byte immediately before it, which makes the +/// parse unambiguous even though `-` is legal inside the slug. +fn matches_documented_shape(chain: &str) -> bool { + if !chain.is_ascii() { + return false; + } + let Some(rest) = chain.strip_prefix("MXC-") else { + return false; + }; + if rest.len() < 16 { + return false; + } + let (head, hash) = rest.split_at(rest.len() - 16); + if !hash.bytes().all(|b| matches!(b, b'a'..=b'z' | b'2'..=b'7')) { + return false; + } + if head.is_empty() { + return true; + } + let Some(slug) = head.strip_suffix('-') else { + return false; + }; + !slug.is_empty() + && slug.len() <= 7 + && slug + .bytes() + .all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) +} + +/// Every `grep -Eq ''` shape check found in the network scripts, as +/// (script name, line number, pattern). +/// +/// A comment is not a check, and neither is a pattern that is not applied to +/// the derived name, so both are excluded: a disabled check that still mentions +/// the pattern would otherwise satisfy the guard below while validating +/// nothing. That makes this deliberately coupled to the scripts' exact idiom -- +/// if the idiom changes, this stops finding checks and the guard fails loudly +/// rather than going quietly green. +fn shape_patterns() -> Vec<(String, usize, String)> { + let mut found = Vec::new(); + for (name, body) in network_scripts() { + for (index, line) in body.lines().enumerate() { + if line.trim_start().starts_with('#') + || !line.contains("grep -Eq") + || !line.contains("<<<\"$CHAIN_NAME\"") + { + continue; + } + let Some(open) = line.find('\'') else { + continue; + }; + let Some(close) = line[open + 1..].find('\'') else { + continue; + }; + let pattern = &line[open + 1..open + 1 + close]; + found.push((name.clone(), index + 1, pattern.to_string())); + } + } + found +} + +#[test] +fn every_script_checks_the_same_documented_shape() { + let patterns = shape_patterns(); + + assert!( + !patterns.is_empty(), + "no chain-shape check found in any network script -- either the scripts \ + stopped validating the derived name, or this guard stopped finding the \ + check and is now verifying nothing" + ); + + let mismatched: Vec = patterns + .iter() + .filter(|(_, _, pattern)| pattern != DOCUMENTED_SHAPE_ERE) + .map(|(name, line, pattern)| format!("{name}:{line} uses '{pattern}'")) + .collect(); + + assert!( + mismatched.is_empty(), + "the chain-shape check is copy-pasted into each script, so every copy \ + must stay identical to the pinned shape '{DOCUMENTED_SHAPE_ERE}'. A \ + copy that drifts either rejects a valid name and fails the suite for \ + the wrong reason, or accepts a malformed one. Offenders:\n {}", + mismatched.join("\n ") + ); +} + +#[test] +fn the_pinned_shape_accepts_the_names_the_code_actually_produces() { + // Representative of what the scripts feed it: ordinary names, names whose + // slug is exhausted or absent, and a name long enough to be truncated. + for input in [ + "lxc-network-enforcement-deny", + "lxc_network_deny_precedence_control", + "web", + "", + "----", + &"container-name-that-is-very-long".repeat(8), + ] { + let chain = chain_name_for(input); + assert!( + matches_documented_shape(&chain), + "chain_name_for({input:?}) produced '{chain}', which the shape \ + pinned in every network script would reject. The scripts would \ + fail on a correct name, so the pinned shape is stale." + ); + } +} + +#[test] +fn a_script_that_derives_a_name_also_validates_its_shape() { + let patterns = shape_patterns(); + + for (name, body) in network_scripts() { + // A script that never derives a name has nothing to validate. One that + // does is about to feed that name to `iptables -S` and to a FORWARD + // grep, so an unvalidated parse failure would hand those assertions a + // malformed string instead of failing here. + if !body.contains("derive_chain_name") { + continue; + } + assert!( + patterns.iter().any(|(script, _, _)| script == &name), + "{name} derives a chain name but never checks its shape, so a \ + mis-parse reaches the chain assertions instead of failing loudly. \ + Add the pinned shape check '{DOCUMENTED_SHAPE_ERE}'." + ); + } +} + +#[test] +fn every_chain_asserting_script_derives_the_name_it_asserts_on() { + let scripts = network_scripts(); + + for expected in CHAIN_ASSERTING_SCRIPTS { + let (_, body) = scripts + .iter() + .find(|(name, _)| name == expected) + .unwrap_or_else(|| { + panic!( + "{expected} is listed as a chain-asserting script but is not in {}. \ + If it was renamed or removed, update CHAIN_ASSERTING_SCRIPTS.", + scripts_dir().display() + ) + }); + + // Either idiom reads the name back from the run rather than assuming + // it: `mxc_chains` enumerates the chains a tool actually holds, and + // `derive_chain_name` parses the name out of this run's --debug output. + assert!( + body.contains("mxc_chains") || body.contains("derive_chain_name"), + "{expected} asserts on MXC chains but never derives a chain name. \ + Without a derivation its assertions cannot be checking a real \ + chain. Use the mxc_chains snapshot or derive_chain_name." + ); + } +} diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 2dd544961..c27f835b7 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -448,6 +448,26 @@ fn normalize_filesystem_paths(policy: &mut ContainerPolicy, logger: &mut Logger) // ---------- Conversion from wire model to domain model ---------- +/// Whether `host` is a loopback endpoint that a container cannot reach through +/// its own network namespace: 127.0.0.0/8, ::1, or the name "localhost". +/// +/// Accepts bracketed IPv6 literals (e.g. `[::1]`) as stored by the proxy URL +/// parser. Used to reject loopback proxy hosts under the LXC deny-all model, +/// where the container's loopback is not the host's. +fn host_is_loopback(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + let candidate = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + candidate + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + /// Convert a typed `wire::Proxy` block into the validated domain `ProxyConfig`. /// Exactly one of `builtinTestServer` / `localhost` / `url` may be set. fn convert_wire_proxy(proxy: wire::Proxy) -> Result { @@ -490,6 +510,14 @@ fn convert_wire_proxy(proxy: wire::Proxy) -> Result { } if let Some(url_str) = url { + // Redact once, up front, and use this in every diagnostic below. A + // proxy URL commonly carries basic-auth credentials, and every error in + // this block reaches the diagnostic/log stream. Redacting at each site + // instead invites exactly the miss this hoist removes: the host and + // port errors used to interpolate the raw URL, so a credential-bearing + // URL with no port leaked the password before the LXC credential guard + // downstream ever ran. + let redacted = crate::proxy_env::redact_proxy_url(&url_str); let parsed = url::Url::parse(&url_str) .map_err(|e| WxcError::ConfigParse(format!("network.proxy.url is invalid: {e}")))?; @@ -498,10 +526,6 @@ fn convert_wire_proxy(proxy: wire::Proxy) -> Result { // by many clients, which fails open under WSLc's defaultPolicy=allow. let scheme = parsed.scheme(); if scheme != "http" && scheme != "https" { - // Redact any embedded userinfo (`user:password@`) before it reaches - // the diagnostic/log stream — a proxy URL commonly carries basic-auth - // credentials, and the scheme alone diagnoses the failure. - let redacted = crate::proxy_env::redact_proxy_url(&url_str); return Err(WxcError::ConfigParse(format!( "network.proxy.url must use the 'http' or 'https' scheme (got '{scheme}'): {redacted}" ))); @@ -511,13 +535,13 @@ fn convert_wire_proxy(proxy: wire::Proxy) -> Result { .host_str() .ok_or_else(|| { WxcError::ConfigParse(format!( - "network.proxy.url must include a host (e.g., http://localhost:8080), got: {url_str}" + "network.proxy.url must include a host (e.g., http://localhost:8080), got: {redacted}" )) })? .to_string(); let port = parsed.port().ok_or_else(|| { WxcError::ConfigParse(format!( - "network.proxy.url must include a port (e.g., http://localhost:8080), got: {url_str}" + "network.proxy.url must include a port (e.g., http://localhost:8080), got: {redacted}" )) })?; @@ -973,19 +997,71 @@ fn convert_wire_config( policy.network_specified = cfg.network.is_some(); if let Some(net) = cfg.network { if let Some(proxy) = net.proxy { + // Capture which shorthand was used before the wire proxy is + // consumed — LXC can't reach a localhost/loopback proxy. + let proxy_used_localhost = proxy.localhost.is_some(); let proxy_config = convert_wire_proxy(proxy)?; if proxy_config.is_enabled() && containment != ContainmentBackend::ProcessContainer && containment != ContainmentBackend::Bubblewrap + && containment != ContainmentBackend::Lxc && containment != ContainmentBackend::Seatbelt && containment != ContainmentBackend::Wslc { let msg = "Network proxy is only supported with the 'processcontainer', \ - 'bubblewrap', 'seatbelt', or 'wslc' containment backends"; + 'bubblewrap', 'lxc', 'seatbelt', or 'wslc' containment backends"; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + + if containment == ContainmentBackend::Lxc && proxy_config.builtin_test_server { + let msg = "LXC: network.proxy.builtinTestServer is not supported; \ + use network.proxy.url"; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + + // `network.proxy.localhost` maps to 127.0.0.1, which inside an LXC + // network namespace is the container's own loopback rather than the + // host. The injected HTTP(S)_PROXY would be unreachable and the + // iptables proxy-allow rule would never match, so require a routable + // host via `network.proxy.url` instead. + if containment == ContainmentBackend::Lxc && proxy_used_localhost { + let msg = "LXC: network.proxy.localhost is not reachable from the \ + container network namespace (127.0.0.1 is the container \ + loopback); use network.proxy.url with a host routable from \ + inside the container"; logger.log_line(msg); return Err(WxcError::ConfigParse(msg.to_string())); } + // A `url`-form proxy whose host is a loopback literal is as + // unreachable from the container's network namespace as the + // `localhost` shorthand: 127.0.0.0/8, ::1, and the name "localhost" + // all name the container's own loopback, not the host. Under a + // deny-all-except-proxy policy the container would silently get no + // working proxy, so reject it at parse time with a clear error. + // + // Rejection is at parse time (not resolution time) because the three + // forms the reviewer flagged - http://localhost, http://127.0.0.1, + // and [::1] - are all literals visible here, and this matches the + // file's other parse-time proxy validations. A hostname that only + // *resolves* to loopback is not caught: that would require rejecting + // in pin_proxy_to_resolved_ip, which also pins `localhost` for the + // A-record round-trip test, so it is left as a known residual gap. + if containment == ContainmentBackend::Lxc { + if let Some(host) = proxy_config.address.as_ref().map(|addr| addr.host()) { + if host_is_loopback(host) { + let msg = "LXC: network.proxy.url host is a loopback address \ + (127.0.0.0/8, ::1, or localhost), which names the \ + container's own loopback rather than the host; use a \ + proxy host routable from inside the container"; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + } + } + // WSLc containers run in their own network namespace, so an // MXC-run host-loopback proxy is unreachable. Accept only the // caller-supplied `url` form (which carries `original_url`); reject @@ -1144,6 +1220,69 @@ fn convert_wire_config( return Err(WxcError::ConfigParse(msg.to_string())); } + // LXC is the inverse of the two guards above: it *does* have a + // privileged packet-filter layer, and that layer is the only thing that + // makes the proxy an exception rather than a suggestion. Under the + // default `Capabilities` mode `apply_firewall_rules` installs nothing, + // so the runner would inject HTTP(S)_PROXY while leaving direct egress + // wide open -- a config that reads as deny-all-except-proxy and + // enforces neither half. Reject it rather than auto-promoting, so the + // user's stated enforcement is never silently rewritten. + if containment == ContainmentBackend::Lxc + && policy.network_proxy.is_enabled() + && !matches!( + policy.network_enforcement_mode, + NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both + ) + { + let msg = "LXC: network.proxy requires network.enforcementMode='firewall' \ + or 'both'. Under the default 'capabilities' mode no iptables \ + rules are installed, so the proxy environment variables would be \ + injected while direct egress stayed unrestricted -- any client \ + that ignores HTTP_PROXY would bypass the proxy entirely."; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + + // A proxy URL may carry `user:pass@` userinfo, and for LXC that value + // does not stay in the environment. `apply_proxy_env` sets HTTP(S)_PROXY + // to the URL, and `build_attach_args_with_env_control` turns every + // environment entry into a `--set-var=KEY=VALUE` argument of the + // `lxc-attach` process this backend spawns (lxc_bindings.rs). A + // process's argv is readable through /proc//cmdline by any local + // user for the lifetime of the command, so the credentials would be + // exposed to the whole host -- which is precisely what + // `redact_proxy_url` exists to prevent in logs. lxc-attach offers no + // argv-free way to pass a variable, so the only honest options are to + // expose the secret or to refuse it. Refuse it. + if containment == ContainmentBackend::Lxc + && policy + .network_proxy + .address + .as_ref() + .map(|address| address.to_url()) + .is_some_and(|url| crate::proxy_env::proxy_url_has_credentials(&url)) + { + // Built from the redacted form so the rejection cannot become the + // leak it is rejecting. + let msg = 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.", + policy + .network_proxy + .address + .as_ref() + .map(|address| crate::proxy_env::redact_proxy_url(&address.to_url())) + .unwrap_or_default() + ); + logger.log_line(&msg); + return Err(WxcError::ConfigParse(msg)); + } + // External proxy (`url` / `localhost`) enforces its own policy — the // runner does NOT forward host lists to it. Reject configs that combine // an external proxy with host lists or a restrictive default, otherwise @@ -1547,6 +1686,10 @@ fn mask_state_aware_experimental<'a>( Ok(Cow::Owned(masked)) } +#[cfg(test)] +#[path = "config_parser_loopback_spec_tests.rs"] +mod loopback_spec_tests; + #[cfg(test)] mod tests { use super::*; @@ -3434,13 +3577,219 @@ mod tests { } #[test] - fn proxy_rejected_with_non_processcontainer() { + fn proxy_rejected_with_an_unsupported_backend() { + let json = r#"{"process":{"commandLine":"x"},"containment":"vm","network":{"proxy":{"localhost":8080}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{}", err).contains("Network proxy is only supported"), + "expected the supported-backend gate to reject 'vm', got: {}", + err + ); + } + + #[test] + fn proxy_accepted_with_lxc() { + // LXC requires a routable proxy host: localhost/127.0.0.1 is the + // container loopback and unreachable, so use network.proxy.url. + // A firewall mode is required, because that is what makes the proxy an + // exception to deny-all rather than an unenforced suggestion. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"},"enforcementMode":"firewall"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.policy.network_proxy.is_enabled()); + let addr = req.policy.network_proxy.address.as_ref().unwrap(); + assert_eq!(addr.host(), "proxy.example.com"); + assert_eq!(addr.port(), 8080); + } + + #[test] + fn proxy_with_lxc_accepts_both_mode() { + // 'both' also installs the iptables rules, so it satisfies the guard. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"},"enforcementMode":"both"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.policy.network_proxy.is_enabled()); + } + + #[test] + fn proxy_with_lxc_and_omitted_enforcement_mode_is_rejected() { + // enforcementMode defaults to 'capabilities', under which + // apply_firewall_rules installs nothing. Accepting this config would + // inject HTTP(S)_PROXY while leaving direct egress unrestricted, so + // anything ignoring the environment variables bypasses the proxy. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{}", err).contains("network.proxy requires network.enforcementMode"), + "expected the LXC enforcement-mode rejection, got: {}", + err + ); + } + + #[test] + fn proxy_with_lxc_and_explicit_capabilities_mode_is_rejected() { + // Stating 'capabilities' explicitly is the same fail-open as omitting + // it, so it must be rejected identically rather than read as consent. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"},"enforcementMode":"capabilities"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{}", err).contains("network.proxy requires network.enforcementMode"), + "expected the LXC enforcement-mode rejection, got: {}", + err + ); + } + + // Raised in review: the credential guard runs after `convert_wire_proxy`, + // so a credential-bearing URL that fails an *earlier* check never reaches + // it. The port error used to interpolate the raw URL, which leaked the + // password the guard downstream exists to keep out of the diagnostic + // stream. + #[test] + fn a_malformed_credential_bearing_proxy_url_does_not_leak_the_password() { + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://alice:hunter2@proxy.example.com"},"enforcementMode":"firewall"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let msg = format!("{}", load_request(&encoded, &mut logger, true).unwrap_err()); + + assert!( + msg.contains("must include a port"), + "expected the port diagnostic, got: {msg}" + ); + assert!( + !msg.contains("hunter2"), + "the password leaked into the port diagnostic: {msg}" + ); + assert!( + !msg.contains("alice:hunter2"), + "the userinfo leaked into the port diagnostic: {msg}" + ); + } + #[test] + fn proxy_url_with_credentials_is_rejected_for_lxc() { + // LXC forwards the URL to lxc-attach as `--set-var=HTTP_PROXY=...`, and + // argv is world-readable via /proc//cmdline, so accepting this + // would publish the password to every local user. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://alice:hunter2@proxy.example.com:8080"},"enforcementMode":"firewall"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("must not carry credentials"), + "expected the LXC credential rejection, got: {msg}" + ); + } + + #[test] + fn the_lxc_credential_rejection_does_not_leak_the_password() { + // The error is the one place a rejected secret could still escape, so + // it must name the URL only in redacted form. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://alice:hunter2@proxy.example.com:8080"},"enforcementMode":"firewall"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let msg = format!("{}", load_request(&encoded, &mut logger, true).unwrap_err()); + assert!( + !msg.contains("hunter2") && !msg.contains("alice"), + "credentials leaked into the rejection: {msg}" + ); + assert!( + msg.contains("***@proxy.example.com:8080"), + "expected the redacted authority in the rejection: {msg}" + ); + } + + #[test] + fn a_credential_free_proxy_url_is_still_accepted_for_lxc() { + // Negative control: without this, a guard that rejected every LXC + // proxy URL would pass both tests above. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"},"enforcementMode":"firewall"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.policy.network_proxy.is_enabled()); + } + + #[test] + fn an_at_sign_in_the_path_is_not_mistaken_for_credentials() { + // `@` after the authority is an ordinary path character. Rejecting on + // a bare `@` would refuse a URL that carries no secret at all. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080/route@v2"},"enforcementMode":"firewall"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.policy.network_proxy.is_enabled()); + } + + #[test] + fn proxy_localhost_rejected_with_lxc() { + // network.proxy.localhost maps to 127.0.0.1, unreachable from inside + // the LXC network namespace — it must be rejected at parse time. let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"localhost":8080}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); - let result = load_request(&encoded, &mut logger, true); - assert!(result.is_err()); + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{}", err).contains("network.proxy.localhost is not reachable"), + "expected the LXC localhost rejection, got: {}", + err + ); + } + + #[test] + fn proxy_loopback_url_rejected_with_lxc() { + // The url form names the container's own loopback just as the + // localhost shorthand does, so it is rejected for the same reason. + for url in [ + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://[::1]:8080", + ] { + let json = format!( + r#"{{"process":{{"commandLine":"x"}},"containment":"lxc","network":{{"proxy":{{"url":"{}"}}}}}}"#, + url + ); + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + format!("{}", err).contains("loopback address"), + "expected the LXC loopback-url rejection for {}, got: {}", + url, + err + ); + } + } + + #[test] + fn proxy_builtin_test_server_rejected_with_lxc() { + // LXC enforces a configured proxy address with iptables; it does not + // launch the builtin testing proxy. + let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"builtinTestServer":true}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!(format!("{}", err).contains("builtinTestServer is not supported")); } #[test] diff --git a/src/core/wxc_common/src/config_parser_loopback_spec_tests.rs b/src/core/wxc_common/src/config_parser_loopback_spec_tests.rs new file mode 100644 index 000000000..85fbdf6ce --- /dev/null +++ b/src/core/wxc_common/src/config_parser_loopback_spec_tests.rs @@ -0,0 +1,229 @@ +//! Spec-derived tests for loopback proxy-host rejection. +//! Written from the documented contract only. +//! +//! Contract source: doc comment on `host_is_loopback`: +//! "127.0.0.0/8, ::1, or the name "localhost". +//! Accepts bracketed IPv6 literals (e.g. `[::1]`)." + +use super::*; + +// ─── 127.0.0.0/8 ───────────────────────────────────────────────────────────── +// Contract: "127.0.0.0/8" — the entire /8 block is loopback, not just .1. + +#[test] +fn the_canonical_loopback_address_is_loopback() { + // Contract: 127.0.0.0/8 + assert!( + host_is_loopback("127.0.0.1"), + "input=127.0.0.1 — canonical loopback must be rejected" + ); +} + +#[test] +fn a_non_canonical_address_inside_127_slash_8_is_loopback() { + // Contract: "127.0.0.0/8" — the *whole* block, not only .1. + // This case distinguishes a correct /8 check from an exact-match on 127.0.0.1. + assert!( + host_is_loopback("127.0.0.2"), + "input=127.0.0.2 — entire 127.0.0.0/8 block must be loopback" + ); +} + +#[test] +fn the_upper_bound_of_127_slash_8_is_loopback() { + // Contract: "127.0.0.0/8" — 127.255.255.254 is the last usable host in the block. + assert!( + host_is_loopback("127.255.255.254"), + "input=127.255.255.254 — top of 127.0.0.0/8 must be loopback" + ); +} + +#[test] +fn a_midrange_127_address_is_loopback() { + // Contract: "127.0.0.0/8" + assert!( + host_is_loopback("127.1.2.3"), + "input=127.1.2.3 — mid-range 127.x.x.x must be loopback" + ); +} + +#[test] +fn the_network_address_of_127_slash_8_is_loopback() { + // Contract: "127.0.0.0/8" — network address itself is inside the block. + assert!( + host_is_loopback("127.0.0.0"), + "input=127.0.0.0 — 127.0.0.0/8 network address must be loopback" + ); +} + +// ─── 127.x.x.x near-misses ─────────────────────────────────────────────────── + +#[test] +fn an_address_just_above_127_slash_8_is_not_loopback() { + // Contract negation: 128.0.0.1 is outside 127.0.0.0/8. + assert!( + !host_is_loopback("128.0.0.1"), + "input=128.0.0.1 — outside 127.0.0.0/8, must NOT be loopback" + ); +} + +#[test] +fn an_address_just_below_127_slash_8_is_not_loopback() { + // Contract negation: 126.255.255.255 is outside 127.0.0.0/8. + assert!( + !host_is_loopback("126.255.255.255"), + "input=126.255.255.255 — outside 127.0.0.0/8, must NOT be loopback" + ); +} + +#[test] +fn a_private_rfc1918_address_is_not_loopback() { + // Contract negation: only 127.0.0.0/8, ::1, or "localhost" are loopback. + assert!( + !host_is_loopback("10.0.3.1"), + "input=10.0.3.1 — RFC 1918 private address must NOT be loopback" + ); +} + +#[test] +fn the_unspecified_address_is_not_loopback() { + // Contract negation: 0.0.0.0 is not listed as loopback. + assert!( + !host_is_loopback("0.0.0.0"), + "input=0.0.0.0 — unspecified address must NOT be loopback" + ); +} + +// ─── ::1 ───────────────────────────────────────────────────────────────────── +// Contract: "::1" + +#[test] +fn the_ipv6_loopback_address_is_loopback() { + // Contract: "::1" + assert!( + host_is_loopback("::1"), + "input=::1 — IPv6 loopback must be rejected" + ); +} + +// ─── Bracketed IPv6 ────────────────────────────────────────────────────────── +// Contract: "Accepts bracketed IPv6 literals (e.g. `[::1]`) as stored by the +// proxy URL parser." + +#[test] +fn bracketed_ipv6_loopback_is_loopback() { + // Contract: explicit bracketed-form acceptance. + assert!( + host_is_loopback("[::1]"), + "input=[::1] — bracketed IPv6 loopback must be rejected" + ); +} + +#[test] +fn bracketed_non_loopback_ipv6_is_not_loopback() { + // Contract: bracket stripping must not make a non-loopback address loopback. + assert!( + !host_is_loopback("[2001:db8::1]"), + "input=[2001:db8::1] — bracketed non-loopback IPv6 must NOT be loopback" + ); +} + +// ─── "localhost" ───────────────────────────────────────────────────────────── +// Contract: `or the name "localhost"` (exact name, not a prefix/substring rule). + +#[test] +fn the_name_localhost_is_loopback() { + // Contract: `or the name "localhost"` + assert!( + host_is_loopback("localhost"), + "input=localhost — the name localhost must be loopback" + ); +} + +#[test] +fn a_host_merely_prefixed_with_localhost_is_not_loopback() { + // Contract: "the name" — exact match only. + // A substring/prefix match would accept localhost.evil.com; the contract forbids it. + assert!( + !host_is_loopback("localhost.evil.com"), + "input=localhost.evil.com — must NOT be loopback; contract requires exact name match" + ); +} + +#[test] +fn a_host_that_contains_localhost_as_a_suffix_is_not_loopback() { + // Contract: exact name match, not substring. + assert!( + !host_is_loopback("notlocalhost"), + "input=notlocalhost — must NOT be loopback; contract requires exact name match" + ); +} + +// ─── Characterization tests for contract-silent cases ──────────────────────── +// These record the *observed* behavior of a live, deterministic implementation. +// The contract is silent on each case — so these are not required guarantees, +// but they ARE live assertions. A change to any of these behaviors must be +// a conscious decision, not a silent drift. See CONTRACT GAPS in the report. + +#[test] +fn empty_string_is_not_loopback() { + // Contract is silent on empty string. The three named families (127.0.0.0/8, + // ::1, "localhost") do not include ""; this assertion pins that it stays false. + // For a security predicate, silently flipping "" to loopback would be a bug. + assert!( + !host_is_loopback(""), + "input='' — empty string must not be treated as loopback" + ); +} + +#[test] +fn uppercase_localhost_is_loopback() { + // Contract gap 2: the doc comment says `the name "localhost"` without + // specifying case. The implementation uses `eq_ignore_ascii_case`, so + // "LOCALHOST" and "LocalHost" are treated as loopback today. + // This is a characterization test — the contract does not require it, + // but a change here should be intentional. + assert!( + host_is_loopback("LOCALHOST"), + "input=LOCALHOST — implementation treats this as loopback (eq_ignore_ascii_case); \ + pin to catch silent changes" + ); + assert!( + host_is_loopback("LocalHost"), + "input=LocalHost — implementation treats this as loopback (eq_ignore_ascii_case); \ + pin to catch silent changes" + ); +} + +#[test] +fn ipv4_mapped_ipv6_loopback_is_not_loopback() { + // Contract gap 3: the contract names "127.0.0.0/8" and "::1" but not + // IPv4-mapped IPv6 (::ffff:127.0.0.1). Rust's IpAddr::is_loopback() + // returns false for IPv4-mapped addresses; this test pins that behavior. + // For the LXC proxy-host call site in `config_parser.rs` a false negative + // is fail-safe: the container is given an unreachable proxy, not open + // access. + assert!( + !host_is_loopback("::ffff:127.0.0.1"), + "input=::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback; not in contract; \ + currently returns false (not caught); pin to detect behavior change" + ); + assert!( + !host_is_loopback("[::ffff:127.0.0.1]"), + "input=[::ffff:127.0.0.1] — bracketed IPv4-mapped form; also currently false; \ + pin to detect behavior change" + ); +} + +#[test] +fn trailing_dot_localhost_is_not_loopback() { + // Contract gap 5: the contract says `the name "localhost"` with no mention + // of FQDN trailing-dot form. "localhost." does not equal "localhost" under + // exact-match or eq_ignore_ascii_case, and does not parse as an IpAddr, + // so the implementation returns false. Pin that. + assert!( + !host_is_loopback("localhost."), + "input='localhost.' — trailing-dot FQDN form; contract requires exact \ + name match; must NOT be loopback" + ); +} diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 200c613bb..06474c024 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -2,6 +2,9 @@ // Licensed under the MIT License. use serde::{Deserialize, Serialize}; +use std::net::IpAddr; + +use crate::error::WxcError; /// Selects which containment backend to use for script execution. #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -315,6 +318,56 @@ impl From for NetworkEnforcementMode { } } +/// A hostname-to-IP mapping that makes a sandbox resolve the proxy to exactly +/// the address the firewall authorized. +/// +/// This exists instead of rewriting the proxy URL's host to the resolved IP. +/// Rewriting the host breaks TLS for an `https://`-scheme proxy: the client +/// then contacts an IP literal, so SNI and certificate validation fail unless +/// the proxy certificate carries an IP SAN. Pinning the name resolution +/// instead keeps the hostname in the URL, so TLS identity is preserved, while +/// still guaranteeing the sandbox and the firewall agree on one endpoint. +/// +/// Without a pin the sandbox re-resolves the hostname itself, and under +/// round-robin or split-horizon DNS it can select an address the firewall +/// never allowed. +/// +/// The fields are private and the address is an [`IpAddr`], so a pin that does +/// not denote exactly one mapping cannot be constructed. This matters because +/// [`Self::hosts_line`] is written to a hosts file: a newline or space in +/// either field would inject additional entries, letting an attacker redirect +/// names the policy never mentioned. Construct one with +/// [`ProxyAddress::host_pin`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyHostPin { + hostname: String, + ip: IpAddr, +} + +impl ProxyHostPin { + /// The proxy hostname as it appears in the URL handed to the sandbox. + pub fn hostname(&self) -> &str { + &self.hostname + } + + /// The address the hostname is pinned to, and the only address the + /// firewall authorizes for it. + pub fn ip(&self) -> IpAddr { + self.ip + } + + /// Render this pin as a `/etc/hosts` line. + /// + /// The address is written bare. A hosts file takes an unbracketed IPv6 + /// literal, unlike a URL host component, and [`IpAddr`]'s `Display` is + /// already that bare form -- so the difference from + /// [`ProxyAddress::to_url`], which brackets, is structural rather than a + /// convention a caller could forget. + pub fn hosts_line(&self) -> String { + format!("{} {}", self.ip, self.hostname) + } +} + #[derive(Debug, Clone)] pub struct ProxyAddress { pub address: String, @@ -350,12 +403,100 @@ impl ProxyAddress { } /// Returns the proxy URL. Uses the original URL if one was provided, - /// otherwise constructs `http://127.0.0.1:{port}` for localhost proxies. + /// otherwise constructs one from this address and port. + /// + /// The constructed form names [`Self::address`] rather than assuming + /// loopback. A proxy bound to a non-loopback address is reachable through + /// [`ProxyAddress::new`], and reporting `127.0.0.1` for it would hand the + /// sandbox a different endpoint from the one the firewall authorized. pub fn to_url(&self) -> String { if let Some(url) = &self.original_url { return url.clone(); } - format!("http://127.0.0.1:{}", self.port) + format!( + "http://{}:{}", + Self::bracket_if_ipv6(&self.address), + self.port + ) + } + + /// Returns the pin required for a sandbox to resolve this proxy's hostname + /// to `ip`. + /// + /// `Ok(None)` means no pin is needed: the address is already an IP + /// literal, so there is nothing to resolve and nothing a sandbox could + /// resolve differently. + /// + /// `Err` means a pin is needed but cannot be produced, because the address + /// is empty or contains characters that are not valid in a hostname. This + /// is deliberately an error rather than `None`. Returning `None` would tell + /// the caller "no hosts entry required", so a malformed address would + /// silently skip the pin and let the sandbox re-resolve the name freely -- + /// failing open, which is the defect review objected to elsewhere in this + /// work. A proxy whose endpoint cannot be pinned must not run. + /// + /// Taking an [`IpAddr`] rather than a string means the caller has already + /// resolved the name, and makes an unparseable address unrepresentable + /// here. + /// + /// The URL is deliberately left untouched. See [`ProxyHostPin`] for why + /// rewriting the host to `ip` instead would break TLS. + pub fn host_pin(&self, ip: IpAddr) -> Result, WxcError> { + let hostname = Self::unbracket(&self.address); + + // An IP literal needs no pin. Unbracket first so a bracketed IPv6 + // literal is recognized as a literal rather than mistaken for a + // hostname: `IpAddr::from_str` rejects brackets, so `[::1]` would + // otherwise be pinned as though it were a name. + if hostname.parse::().is_ok() { + return Ok(None); + } + + if !Self::is_pinnable_hostname(hostname) { + return Err(WxcError::NetworkProxy(format!( + "proxy address {:?} cannot be pinned to {}: not a valid hostname", + self.address, ip + ))); + } + + Ok(Some(ProxyHostPin { + hostname: hostname.to_string(), + ip, + })) + } + + /// Whether `host` is safe to write as the name column of a hosts file + /// entry. + /// + /// Rejects the empty string and anything outside the letter, digit, `-`, + /// and `.` set. That set excludes whitespace and newlines, which is the + /// property that matters: either would end the record and inject a second, + /// unauthorized mapping. + fn is_pinnable_hostname(host: &str) -> bool { + !host.is_empty() + && host + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') + } + + /// Wraps `host` in `[` `]` when it is a bare IPv6 literal, so it is valid + /// as a URL host component. + /// + /// An already-bracketed literal needs no special case. `IpAddr::from_str` + /// does not accept brackets, so a bracketed host fails to parse and falls + /// through unchanged rather than being bracketed twice. + fn bracket_if_ipv6(host: &str) -> std::borrow::Cow<'_, str> { + match host.parse::() { + Ok(IpAddr::V6(_)) => std::borrow::Cow::Owned(format!("[{host}]")), + _ => std::borrow::Cow::Borrowed(host), + } + } + + /// Strips one pair of surrounding `[` `]` from a bracketed IPv6 literal. + fn unbracket(host: &str) -> &str { + host.strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .unwrap_or(host) } } diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index 484a1a698..5902c92c2 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -29,15 +29,26 @@ //! Functions here operate on `"KEY=VALUE"` strings, so they are //! platform-agnostic and unit-testable on every host. +use crate::models::ProxyConfig; +use std::borrow::Cow; + /// Proxy-related env var keys that are *scrubbed* from caller-supplied env so /// a sandboxed process cannot override or disable the cooperative proxy. +/// +/// Both spellings of every family are listed. Matching goes through +/// [`is_managed_proxy_key`], which is case-insensitive, so the lower-case +/// entries are redundant for that path; they are kept so a consumer that +/// iterates or does a case-sensitive `contains` over this slice still sees the +/// whole set. pub const PROXY_ENV_KEYS: &[&str] = &[ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "FTP_PROXY", "http_proxy", "https_proxy", "all_proxy", + "ftp_proxy", "NO_PROXY", "no_proxy", ]; @@ -79,18 +90,200 @@ pub fn is_managed_proxy_key(key: &str) -> bool { } /// Redact any `user:pass@` userinfo from a proxy URL so it is safe to log. +/// +/// Handles the malformed input a diagnostic actually sees: a proxy URL is +/// redacted on the failure path, where it may not be a well-formed absolute +/// URL. `scheme:opaque` is redacted too, since `url::Url::parse` accepts it and +/// the resulting error message would otherwise carry the password. +/// +/// When the value carries a credential the *normalized* form is returned, not +/// the original, so the whitespace a URL parser ignores cannot be used to smuggle +/// the secret through the redaction. When it carries none the input is returned +/// untouched. pub fn redact_proxy_url(url: &str) -> String { - let Some((scheme, rest)) = url.split_once("://") else { - return url.to_string(); + let normalized = normalize_as_the_url_parser_does(url); + let parts = split_proxy_authority(&normalized); + match credential_userinfo(parts.authority) { + Some((_userinfo, host)) => format!( + "{}{}***@{}{}", + parts.scheme, parts.separator, host, parts.tail + ), + None => url.to_string(), + } +} + +/// Drop the characters `url::Url::parse` ignores, so this module judges the +/// same URL the rest of the system acts on. +/// +/// WHATWG strips leading and trailing C0 controls and spaces, and removes tab, +/// newline, and carriage return from anywhere in the input. `ProxyAddress::from_url` +/// stores the string it was given and `to_url` returns it verbatim, so without +/// this the guard read one URL while `lxc-attach` received another: +/// `" http://alice:hunter2@host"` has no recognizable scheme once the leading +/// space is counted, so the authority stopped at the first `/` of `//` and the +/// `@` after it was never seen. The guard reported no credentials and redaction +/// returned the password verbatim. +fn normalize_as_the_url_parser_does(url: &str) -> Cow<'_, str> { + let is_trimmed = |c: char| c <= ' '; + if url.contains(['\t', '\n', '\r']) { + Cow::Owned( + url.chars() + .filter(|c| !matches!(c, '\t' | '\n' | '\r')) + .collect::() + .trim_matches(is_trimmed) + .to_string(), + ) + } else { + Cow::Borrowed(url.trim_matches(is_trimmed)) + } +} + +/// The pieces of a proxy URL that userinfo handling needs. +struct ProxyAuthority<'a> { + scheme: &'a str, + /// Whichever separator followed the scheme -- `:`, `://`, or the `:/` that + /// a special scheme also accepts -- so a redaction can be reassembled in + /// the same form it arrived in. + separator: &'a str, + /// Everything between the separator and the first path, query, or fragment + /// delimiter. An `@` after that point belongs to the path, not to userinfo. + authority: &'a str, + tail: &'a str, +} + +/// Split `url` into scheme, separator, authority, and tail. +/// +/// Every form is recognized deliberately, because [`ProxyAddress::from_url`] +/// stores whatever string it is given and [`ProxyAddress::to_url`] returns it +/// verbatim, so anything that parses somewhere downstream reaches the same +/// places a well-formed URL does. +/// +/// * `scheme://authority` -- the ordinary form. +/// * `scheme:authority` -- `url::Url::parse` accepts the opaque form, and +/// `alice:hunter2@example.com` parses with scheme `alice`. +/// * `scheme:/authority` -- for a *special* scheme (`http`, `https`, and the +/// rest of the WHATWG set) one slash introduces an authority exactly as two +/// do, so `http:/alice:hunter2@example.com` is the credentialed URL +/// `http://alice:hunter2@example.com/`. Any run of leading slashes is +/// therefore part of the separator rather than the start of a path. +/// * no scheme at all -- the whole value is treated as an authority. Nothing +/// downstream should accept it as a proxy URL, but the point here is not to +/// decide that; it is that a bearer token used as sole userinfo, +/// `token@proxy.example.com`, carries no colon and would otherwise be both +/// unflagged and unredacted. +/// +/// A colon alone does not make a scheme: the prefix has to satisfy +/// [`is_uri_scheme`], or the colon is a port separator and the whole value is +/// the authority. `alice@proxy.example.com:3128` is that case. +/// +/// This is the single parse shared by [`redact_proxy_url`] and +/// [`proxy_url_has_credentials`]. They previously had one each, which is how +/// they came to disagree: redaction handled the opaque form while the guard +/// reported it as carrying no credentials. It is total rather than fallible +/// for the same reason -- an input only one of them could parse is an input +/// they can differ on. +fn split_proxy_authority(url: &str) -> ProxyAuthority<'_> { + let (scheme, after_scheme) = match url.find(':') { + Some(colon) if is_uri_scheme(&url[..colon]) => url.split_at(colon), + _ => (&url[..0], url), }; - let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + + // A backslash is read exactly as a slash, both where an authority begins + // and where it ends. WHATWG only does that for the special schemes, and + // this function deliberately does it for every scheme, because the two + // ways of being wrong are not symmetric. Missing userinfo puts a password + // into argv and into the very error text meant to hide it; claiming + // userinfo a strict parse would not is a rejected config. The guard is + // therefore allowed to be more suspicious than the parser and never less. + // `http:\/alice:hunter2@host` was a live bypass on exactly this point. + let introduces_authority = |c: char| c == '/' || c == '\\'; + let ends_authority = |c: char| matches!(c, '/' | '?' | '#' | '\\'); + + let slashes = after_scheme + .strip_prefix(':') + .map(|rest| 1 + (rest.len() - rest.trim_start_matches(introduces_authority).len())) + .unwrap_or(0); + let (separator, rest) = after_scheme.split_at(slashes); + let auth_end = rest.find(ends_authority).unwrap_or(rest.len()); let (authority, tail) = rest.split_at(auth_end); - match authority.rsplit_once('@') { - Some((_userinfo, host)) => format!("{scheme}://***@{host}{tail}"), - None => url.to_string(), + ProxyAuthority { + scheme, + separator, + authority, + tail, } } +/// Whether `candidate` satisfies the RFC 3986 scheme grammar, +/// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`. +/// +/// Everything before the first colon used to be taken for a scheme on sight, +/// which is wrong whenever the colon is a *port* separator instead. In +/// `alice@proxy.example.com:3128` that read the scheme as +/// `alice@proxy.example.com` and the authority as `3128`; an authority of +/// `3128` carries no `@`, so the guard reported no credentials and redaction +/// returned the string untouched, while the username still reached +/// `lxc-attach` argv. +/// +/// `@` is not in the grammar and a prefix carrying userinfo always contains +/// one, so refusing non-schemes is what sends the whole value through as an +/// authority -- where the `@` is found. A hostname alone still satisfies the +/// grammar (`proxy.example.com` is ALPHA and `.`), and that is harmless: it +/// leaves the authority as the bare port, which carries no credential either +/// way. +fn is_uri_scheme(candidate: &str) -> bool { + let mut chars = candidate.chars(); + if !chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') +} + +/// The userinfo an authority carries, split from its host, or `None` when it +/// carries none worth hiding. +/// +/// Empty userinfo is not a credential: `http://@proxy.example.com` and +/// `http://:@proxy.example.com` name neither a user nor a password, and +/// refusing them would reject a configuration that leaks nothing. A single +/// component *is* one, though -- `http://token@proxy.example.com` is how a +/// bearer token is passed, and `http://:secret@proxy.example.com` is a +/// password with the username omitted. +/// +/// Emptiness stops at `""` and `":"`. A previous version treated any run of +/// colons as empty, which is wrong because only the *first* colon separates +/// the two components: in `::` the second colon is the password's own value. +/// Measured against the parser, `http://::@host` reports +/// `password = Some("%3A")` while `http://:@host` reports `None`, so that is +/// exactly where the boundary belongs. +/// +/// Sharing this between the two public functions is what makes them unable to +/// disagree about a given string. +fn credential_userinfo(authority: &str) -> Option<(&str, &str)> { + let (userinfo, host) = authority.rsplit_once('@')?; + if userinfo.is_empty() || userinfo == ":" { + return None; + } + Some((userinfo, host)) +} + +/// Whether a proxy URL carries `user:pass@` userinfo. +/// +/// This is the single definition of "carries credentials", so a backend that +/// must refuse such a URL and the config parser that rejects it up front +/// cannot drift apart. +/// +/// It deliberately does not ask whether [`redact_proxy_url`] changes the +/// string. That answers a different question — how to render a URL safely — +/// and returns the input unchanged when the userinfo is already the literal +/// redaction marker, which would report a credential-bearing URL as clean. +pub fn proxy_url_has_credentials(url: &str) -> bool { + let normalized = normalize_as_the_url_parser_does(url); + credential_userinfo(split_proxy_authority(&normalized).authority).is_some() +} + /// Build the effective environment for a sandbox whose egress is routed /// through a cooperative proxy at `proxy_url`. /// @@ -124,6 +317,34 @@ pub fn apply_cooperative_proxy_env(caller_env: &[String], proxy_url: &str) -> Ve effective } +/// Scrub proxy env vars from `env` in place, then point them at `proxy` when it +/// carries an address. +/// +/// This is the LXC entry point. It delegates to [`apply_cooperative_proxy_env`] +/// so LXC scrubs and sets exactly the same key set as Bubblewrap and WSLc, +/// rather than maintaining a parallel list that can drift. +/// +/// `env` uses the `ExecutionRequest::env` representation: `KEY=VALUE` strings. +/// An entry with no `=` is treated as a bare key, so a valueless `HTTP_PROXY` +/// is still scrubbed. +/// +/// Returns whether the caller must force a clean environment. This is always +/// `true`, including when `env` ends up empty: the return value tells the +/// caller to emit `--clear-env`, and an empty vector must still stop +/// `lxc-attach` inheriting the MXC host process environment, which carries +/// both proxy vars and credentials. +pub fn apply_proxy_env(env: &mut Vec, proxy: &ProxyConfig) -> bool { + if let Some(address) = &proxy.address { + *env = apply_cooperative_proxy_env(env, &address.to_url()); + return true; + } + + // With the proxy disabled the vars are still stripped, so a caller cannot + // point the sandbox at an egress path the policy never authorized. + env.retain(|entry| !is_managed_proxy_key(env_key(entry))); + true +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/core/wxc_common/tests/proxy_address_spec.rs b/src/core/wxc_common/tests/proxy_address_spec.rs new file mode 100644 index 000000000..b5e40fe41 --- /dev/null +++ b/src/core/wxc_common/tests/proxy_address_spec.rs @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Black-box contract tests for `wxc_common::models::ProxyAddress` and +//! `ProxyHostPin`. +//! +//! These live in the integration-test directory on purpose: from here only the +//! crate's public API is visible, so the tests exercise the same surface the +//! real callers do and cannot accidentally couple to a private helper. They +//! were written from the documented contract without reading the +//! implementation, so a bug baked into the code cannot silently teach the tests +//! to expect it. +//! +//! Why this surface matters. The "deny-all-except-proxy" network policy +//! requires the sandbox and the firewall to agree on exactly one proxy +//! endpoint. If the URL handed to the sandbox names a different endpoint than +//! the firewall authorized -- or if the sandbox is left free to re-resolve a +//! hostname under round-robin or split-horizon DNS -- that is a policy bypass. +//! `to_url` decides the endpoint string the sandbox receives, and `host_pin` / +//! `hosts_line` express the resolved mapping as a hosts-file pin so the +//! hostname stays in the URL and TLS identity is preserved. +//! +//! Client status, verified against the tree on the day these tests were +//! written: +//! +//! * The URL surface (`new`, `from_url`, `to_url`, and the `original_url` +//! field) has live callers today. `appcontainer_runner::inject_proxy_vars` +//! turns `to_url()` into the `HTTP_PROXY` / `HTTPS_PROXY` values injected into +//! the sandboxed process, `proxy_coordinator` uses it to launch the elevated +//! shim, `unix_proxy_coordinator` logs it, `config_parser` produces addresses +//! via `from_url`, and `wsl_container_runner` reads the `original_url` field +//! directly. +//! * The pin surface (`host_pin`, `hosts_line`, `ProxyHostPin`) still has no +//! callers. It is planned wiring for the firewall / hosts-file consumer, so +//! the tests below name that consumer as planned, not present. `ProxyHostPin` +//! has no public constructor -- the only way to obtain one is `host_pin` on a +//! hostname -- so the tests build pins that way through the `pin_for` helper. +//! +//! `host_pin` returns `Result, WxcError>`, and the three +//! arms are the whole point of the type after PR 789: +//! +//! * `Ok(None)` -- and only this -- means the address is an IP literal (bare or +//! bracketed), so there is nothing to resolve and no pin is needed. +//! * `Ok(Some(pin))` means the address is a hostname and the pin is required. +//! * `Err(_)` means a pin is required but impossible: the address is empty or +//! holds characters invalid in a hostname (notably whitespace or a newline, +//! the hosts-file injection vectors). Conflating this with `Ok(None)` would +//! fail open -- the caller would skip a required pin and let the sandbox +//! re-resolve the name freely -- so the tests assert the specific arm, not +//! merely `is_err` or `is_none`. +//! +//! Test list (the scenarios these tests are meant to cover, enumerated before +//! the assertions were written): +//! 1. `to_url` with no original URL constructs `http://{address}:{port}` from +//! the struct's own address -- for loopback, for a non-loopback address +//! that must not be rewritten to loopback, and for bare and +//! already-bracketed IPv6 literals. +//! 2. `to_url` with an original URL returns it verbatim -- including a +//! trailing slash, credentials, a path and query, and an `https` scheme. +//! 3. The two constructors differ only in whether they record `original_url`. +//! 4. `host_pin` returns `Ok(Some)` for a hostname (with a hyphen accepted and +//! the typed IP read back through `ip()`), `Ok(None)` for every IP literal, +//! and `Err` for the empty address and for hostnames carrying a newline or +//! a space. +//! 5. `host_pin` does not disturb `to_url`. +//! 6. `hosts_line` writes `{ip} {hostname}` with the address bare, which is +//! the deliberate asymmetry against `to_url`'s bracketing of IPv6. + +use std::net::IpAddr; + +use wxc_common::models::{ProxyAddress, ProxyHostPin}; + +// `ProxyHostPin` has no public constructor: the only way to obtain one is +// `ProxyAddress::host_pin` on a hostname address, which must return +// `Ok(Some(pin))`. This helper centralizes that construction and fails the +// test with a precise message if either non-`Ok(Some)` arm comes back, so the +// pin-shape tests can read like ordinary value assertions. +fn pin_for(address: &str, ip: IpAddr) -> ProxyHostPin { + match ProxyAddress::new(address.to_string(), 8080).host_pin(ip) { + Ok(Some(pin)) => pin, + Ok(None) => panic!("expected a pin for hostname {address:?}, got Ok(None)"), + Err(_) => panic!("expected a pin for hostname {address:?}, got Err"), + } +} + +// Protects `appcontainer_runner::inject_proxy_vars` and the proxy coordinators, +// which build the sandbox's proxy URL from an address created with `new`. A +// loopback bind address is the common case for the builtin test proxy. +#[test] +fn to_url_constructs_http_url_for_loopback_when_no_original_url() { + let addr = ProxyAddress::new("127.0.0.1".to_string(), 8080); + + assert_eq!(addr.to_url(), "http://127.0.0.1:8080"); +} + +// Protects `appcontainer_runner::inject_proxy_vars`. A proxy bound to a +// non-loopback address is constructible via `new`, and the sandbox must be told +// that exact endpoint. Reporting `127.0.0.1` here would hand the sandbox a +// different endpoint than the firewall authorized -- a policy bypass. +#[test] +fn to_url_preserves_non_loopback_address_and_does_not_assume_loopback() { + let addr = ProxyAddress::new("10.1.2.3".to_string(), 3128); + + assert_eq!(addr.to_url(), "http://10.1.2.3:3128"); +} + +// Protects every client that turns a `new`-built address into a URL when the +// proxy is bound to an IPv6 address. An unbracketed IPv6 literal is not a valid +// URL host component, so the constructed URL must bracket it. +#[test] +fn to_url_brackets_bare_ipv6_literal() { + let addr = ProxyAddress::new("2001:db8::1".to_string(), 8080); + + assert_eq!(addr.to_url(), "http://[2001:db8::1]:8080"); +} + +// Protects the same URL-building clients against a double-bracketing bug when +// the address is already in bracketed form. +#[test] +fn to_url_does_not_double_bracket_already_bracketed_ipv6() { + let addr = ProxyAddress::new("[2001:db8::1]".to_string(), 8080); + + assert_eq!(addr.to_url(), "http://[2001:db8::1]:8080"); +} + +// Protects `wsl_container_runner` and the env-var injection path, which forward +// the operator-supplied proxy URL unchanged. When an original URL was recorded +// via `from_url`, `to_url` must return it byte for byte. +#[test] +fn to_url_returns_original_url_verbatim() { + let addr = ProxyAddress::from_url( + "http://proxy.example.com:8080", + "proxy.example.com".to_string(), + 8080, + ); + + assert_eq!(addr.to_url(), "http://proxy.example.com:8080"); +} + +// Protects `wsl_container_runner` against the trailing-slash mangling an earlier +// implementation exhibited. The original URL must pass through exactly, slash +// and all. +#[test] +fn to_url_preserves_trailing_slash_in_original_url() { + let addr = ProxyAddress::from_url( + "http://proxy.example.com:8080/", + "proxy.example.com".to_string(), + 8080, + ); + + assert_eq!(addr.to_url(), "http://proxy.example.com:8080/"); +} + +// Protects `wsl_container_runner` and the env-var injection path for a +// fully-specified URL. Credentials, path, and query must all survive verbatim; +// dropping the credentials would silently change how the proxy authenticates. +#[test] +fn to_url_preserves_credentials_path_and_query_in_original_url() { + let addr = ProxyAddress::from_url( + "http://user:pass@proxy.example.com:8080/path?token=abc", + "proxy.example.com".to_string(), + 8080, + ); + + assert_eq!( + addr.to_url(), + "http://user:pass@proxy.example.com:8080/path?token=abc" + ); +} + +// Protects the whole reason `ProxyHostPin` exists instead of rewriting the host +// to an IP: an `https` proxy must keep its hostname and scheme so the client's +// SNI and certificate validation still work. The original URL passes through +// unchanged, including the `https` scheme. +#[test] +fn to_url_preserves_https_scheme_original_url_verbatim() { + let addr = ProxyAddress::from_url( + "https://proxy.example.com:8443", + "proxy.example.com".to_string(), + 8443, + ); + + assert_eq!(addr.to_url(), "https://proxy.example.com:8443"); +} + +// Protects `wsl_container_runner`, which reads the `original_url` field +// directly. `from_url` must record the original string and `new` must leave it +// empty; that single difference is what selects passthrough versus construction +// in `to_url`. +#[test] +fn from_url_records_original_url_and_new_does_not() { + let from_url = ProxyAddress::from_url( + "http://proxy.example.com:8080", + "proxy.example.com".to_string(), + 8080, + ); + let constructed = ProxyAddress::new("127.0.0.1".to_string(), 8080); + + assert_eq!( + from_url.original_url, + Some("http://proxy.example.com:8080".to_string()) + ); + assert_eq!(constructed.original_url, None); +} + +// Protects the planned firewall / hosts-file consumer. A hostname address +// requires resolution, so `host_pin` must return `Ok(Some(pin))` carrying the +// hostname and the typed IP it was handed. +#[test] +fn host_pin_returns_pin_for_hostname() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + let pin = pin_for("proxy.example.com", ip); + + assert_eq!(pin.hostname(), "proxy.example.com"); + assert_eq!(pin.ip(), ip); +} + +// Protects the planned firewall / hosts-file consumer against an over-strict +// validator. Hyphens and dots are legal in a hostname, so a label containing a +// hyphen must still pin rather than being rejected as invalid. +#[test] +fn host_pin_accepts_hostname_with_hyphen() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + let pin = pin_for("my-proxy.example.com", ip); + + assert_eq!(pin.hostname(), "my-proxy.example.com"); +} + +// Protects the planned firewall / hosts-file consumer. `ip()` now returns a +// typed `IpAddr`, so a pin built for a hostname with a resolved IPv6 address +// must return that exact address through the accessor -- not a string, and not a +// lossy reformatting. +#[test] +fn host_pin_ip_accessor_returns_typed_ipv6_address() { + let ip: IpAddr = "2001:db8::1".parse().unwrap(); + + let pin = pin_for("proxy.example.com", ip); + + assert_eq!(pin.ip(), ip); +} + +// Protects the planned firewall / hosts-file consumer. An IPv4 literal address +// is already an endpoint, so there is nothing to resolve: the one and only +// `Ok(None)` case ("no pin needed"), which must not be confused with `Err` +// ("pin needed but impossible"). +#[test] +fn host_pin_returns_ok_none_for_ipv4_literal() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + match ProxyAddress::new("127.0.0.1".to_string(), 8080).host_pin(ip) { + Ok(None) => {} + Ok(Some(_)) => panic!("an IPv4 literal needs no pin; expected Ok(None), got Ok(Some)"), + Err(_) => panic!("an IPv4 literal needs no pin; expected Ok(None), got Err"), + } +} + +// Protects the planned firewall / hosts-file consumer. A bare IPv6 literal is +// likewise already an endpoint and needs no hosts entry. +#[test] +fn host_pin_returns_ok_none_for_bare_ipv6_literal() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + match ProxyAddress::new("2001:db8::1".to_string(), 8080).host_pin(ip) { + Ok(None) => {} + Ok(Some(_)) => panic!("a bare IPv6 literal needs no pin; expected Ok(None), got Ok(Some)"), + Err(_) => panic!("a bare IPv6 literal needs no pin; expected Ok(None), got Err"), + } +} + +// Protects the planned firewall / hosts-file consumer. A bracketed IPv6 literal +// is unbracketed before classification, so `[::1]` is still an IP literal and +// must be `Ok(None)`, never treated as a hostname to pin. +#[test] +fn host_pin_returns_ok_none_for_bracketed_ipv6_literal() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + match ProxyAddress::new("[::1]".to_string(), 8080).host_pin(ip) { + Ok(None) => {} + Ok(Some(_)) => { + panic!("a bracketed IPv6 literal needs no pin; expected Ok(None), got Ok(Some)") + } + Err(_) => panic!("a bracketed IPv6 literal needs no pin; expected Ok(None), got Err"), + } +} + +// Protects the planned firewall / hosts-file consumer, and pins the security fix +// from PR 789. An empty address is a pin that is REQUIRED but impossible, so it +// must be `Err`, never `Ok(None)`. If these two arms were swapped the caller +// would read "no hosts entry needed", skip the pin, and let the sandbox +// re-resolve the name freely -- failing open and defeating the firewall. +#[test] +fn host_pin_returns_err_for_empty_address() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + match ProxyAddress::new(String::new(), 8080).host_pin(ip) { + Err(_) => {} + Ok(None) => { + panic!("empty address must be Err (pin required but impossible), not Ok(None); Ok(None) fails open") + } + Ok(Some(_)) => panic!("empty address cannot yield a pin; expected Err, got Ok(Some)"), + } +} + +// Protects the planned firewall / hosts-file consumer against hosts-file +// injection, the defect PR 789 fixed. A newline would end the hosts record and +// begin a second, unauthorized mapping, so an address carrying one must be `Err` +// and never reach `hosts_line`. +#[test] +fn host_pin_returns_err_for_hostname_with_newline() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + let injected = "proxy.example.com\n10.0.0.1 evil.example.com"; + + match ProxyAddress::new(injected.to_string(), 8080).host_pin(ip) { + Err(_) => {} + Ok(None) => { + panic!("a newline-bearing address must be Err, not Ok(None); Ok(None) fails open") + } + Ok(Some(_)) => { + panic!("a newline-bearing address must be Err; Ok(Some) would inject a hosts record") + } + } +} + +// Protects the planned firewall / hosts-file consumer against hosts-file +// injection. A space splits one hosts record into an address and a second, +// unauthorized name, so an address containing whitespace must be `Err`. +#[test] +fn host_pin_returns_err_for_hostname_with_space() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + match ProxyAddress::new("proxy.example.com evil".to_string(), 8080).host_pin(ip) { + Err(_) => {} + Ok(None) => { + panic!("a space-bearing address must be Err, not Ok(None); Ok(None) fails open") + } + Ok(Some(_)) => { + panic!("a space-bearing address must be Err; Ok(Some) would inject a hosts record") + } + } +} + +// Protects both the URL clients and the planned pin consumer. Computing a pin +// is documented not to alter the URL, so `to_url` must return the same verbatim +// original after `host_pin` as before it. +#[test] +fn host_pin_does_not_change_to_url() { + let addr = ProxyAddress::from_url( + "https://proxy.example.com:8443", + "proxy.example.com".to_string(), + 8443, + ); + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + let before = addr.to_url(); + match addr.host_pin(ip) { + Ok(Some(_)) => {} + Ok(None) => panic!("a hostname address should require a pin, got Ok(None)"), + Err(_) => panic!("a hostname address should pin cleanly, got Err"), + } + + assert_eq!(addr.to_url(), before); + assert_eq!(addr.to_url(), "https://proxy.example.com:8443"); +} + +// Protects the planned firewall / hosts-file consumer. A hosts line is +// "{ip} {hostname}" -- address first, then hostname, separated by a single +// space, with no trailing newline. +#[test] +fn hosts_line_writes_ip_then_hostname() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); + + let pin = pin_for("proxy.example.com", ip); + + assert_eq!(pin.hosts_line(), "10.0.0.5 proxy.example.com"); +} + +// Protects the planned firewall / hosts-file consumer. A hosts file takes an +// unbracketed IPv6 literal, so `hosts_line` must write the address bare -- the +// deliberate opposite of how `to_url` renders IPv6. +#[test] +fn hosts_line_writes_ipv6_address_without_brackets() { + let ip: IpAddr = "2001:db8::1".parse().unwrap(); + + let pin = pin_for("proxy.example.com", ip); + let line = pin.hosts_line(); + + assert!( + !line.contains('['), + "hosts line must not bracket IPv6: {line:?}" + ); + assert_eq!(line, "2001:db8::1 proxy.example.com"); +} + +// Protects both surfaces at once by pinning the asymmetry the contract calls out +// explicitly: for the very same IPv6 literal, the URL host component is +// bracketed while the hosts-file line is bare. A well-meaning refactor that +// unified the two would break exactly one of them, and this test names which. +#[test] +fn ipv6_is_bracketed_in_url_but_bare_in_hosts_line() { + let ip: IpAddr = "2001:db8::1".parse().unwrap(); + + let url = ProxyAddress::new("2001:db8::1".to_string(), 8080).to_url(); + let line = pin_for("proxy.example.com", ip).hosts_line(); + + assert_eq!(url, "http://[2001:db8::1]:8080"); + assert_eq!(line, "2001:db8::1 proxy.example.com"); +} diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs new file mode 100644 index 000000000..b93fa4726 --- /dev/null +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -0,0 +1,1132 @@ +//! Black-box contract tests for `wxc_common::proxy_env`. +//! +//! These tests are derived from the documented contract of the public API, not +//! from its implementation. Each test names the client whose observable +//! behavior it protects, in the sense of Khorikov's "observable behavior is +//! relative to a named client and its goals": +//! +//! (a) LXC backend (PLANNED integration, not yet wired) -- will call +//! `apply_proxy_env` and use the returned bool to decide whether to pass +//! `--clear-env` to `lxc-attach`. Today `attach_run` derives `--clear-env` +//! solely from `env` being non-empty (`lxc_bindings.rs:90`). The empty-env +//! case is where the helper contract and current behavior diverge: +//! `apply_proxy_env` returns `true` even for an empty env so the host +//! environment cannot leak, whereas current code emits no `--clear-env` +//! then. Wiring this in must update `lxc_bindings.rs` and the test at +//! `lxc_bindings.rs:743` that pins the current empty-env rule. These tests +//! validate the helper contract, not existing LXC behavior. +//! (b) Bubblewrap backend -- calls `is_managed_proxy_key`, iterates +//! `PROXY_SET_KEYS`. +//! (c) WSLc backend -- calls `apply_cooperative_proxy_env`, merges the result +//! over an image's baked-in `ENV`. +//! (d) Security review -- a sandboxed workload must not disable or redirect the +//! proxy via its own env, and logs must not leak proxy credentials. + +use url::Url; +use wxc_common::models::{ProxyAddress, ProxyConfig}; +use wxc_common::proxy_env::{ + apply_cooperative_proxy_env, apply_proxy_env, is_managed_proxy_key, proxy_url_has_credentials, + redact_proxy_url, PROXY_ENV_KEYS, PROXY_NEUTRALIZE_KEYS, PROXY_SET_KEYS, +}; + +const PROXY_URL: &str = "http://127.0.0.1:8080"; + +// Split a `KEY=VALUE` entry into its key. An entry with no `=` is a bare key. +fn key_of(entry: &str) -> &str { + match entry.split_once('=') { + Some((key, _)) => key, + None => entry, + } +} + +// First value for `key` (case-sensitive on the key) in a `KEY=VALUE` list. +fn value_for<'a>(env: &'a [String], key: &str) -> Option<&'a str> { + env.iter().find_map(|entry| { + let (k, v) = entry.split_once('=')?; + (k == key).then_some(v) + }) +} + +// Every entry whose key is NOT managed, in original order. +fn non_proxy_entries(env: &[String]) -> Vec<&String> { + env.iter() + .filter(|e| !is_managed_proxy_key(key_of(e))) + .collect() +} + +// --------------------------------------------------------------------------- +// is_managed_proxy_key +// --------------------------------------------------------------------------- + +// Protects client (b) and (d): the scrub decision runs through this predicate, +// so every managed family in every spelling must match. If a spelling stopped +// matching, a sandboxed workload could smuggle that variable past the scrubber. +#[test] +fn is_managed_proxy_key_matches_every_managed_family() { + assert!(is_managed_proxy_key("HTTP_PROXY")); + assert!(is_managed_proxy_key("HTTPS_PROXY")); + assert!(is_managed_proxy_key("ALL_PROXY")); + assert!(is_managed_proxy_key("FTP_PROXY")); + assert!(is_managed_proxy_key("NO_PROXY")); +} + +// Protects client (b) and (d): the contract states matching is case-insensitive +// because clients (Python urllib, curl) lower-case these names. If matching +// regressed to case-sensitive, `No_Proxy` from a workload would survive. +#[test] +fn is_managed_proxy_key_is_case_insensitive() { + assert!(is_managed_proxy_key("http_proxy")); + assert!(is_managed_proxy_key("no_proxy")); + assert!(is_managed_proxy_key("No_Proxy")); + assert!(is_managed_proxy_key("hTtP_pRoXy")); + assert!(is_managed_proxy_key("all_proxy")); + assert!(is_managed_proxy_key("ftp_proxy")); +} + +// Protects client (b): over-scrubbing would silently delete a workload's +// legitimate environment. Names that merely resemble a proxy key, or embed one +// as a substring, must NOT be treated as managed. +#[test] +fn is_managed_proxy_key_rejects_non_proxy_names() { + assert!(!is_managed_proxy_key("PROXY")); + assert!(!is_managed_proxy_key("HTTP_PROXYY")); + assert!(!is_managed_proxy_key("XHTTP_PROXY")); + assert!(!is_managed_proxy_key("HTTP_PROXY_EXTRA")); + assert!(!is_managed_proxy_key("PATH")); + assert!(!is_managed_proxy_key("")); +} + +// --------------------------------------------------------------------------- +// The key-set constants +// --------------------------------------------------------------------------- + +// Protects client (b) and (d): the contract says NO_PROXY is deliberately +// omitted from the actively-set keys (it is a host-exemption list, not a proxy +// target). Setting NO_PROXY to a proxy URL would be nonsensical and could open +// an exemption. +#[test] +fn proxy_set_keys_never_include_no_proxy() { + assert!(!PROXY_SET_KEYS + .iter() + .any(|k| k.eq_ignore_ascii_case("NO_PROXY"))); +} + +// Protects client (b): every key the module actively sets must also be a key it +// scrubs first. A set key that is not managed would be appended on top of a +// caller-supplied value instead of replacing it. +#[test] +fn every_set_key_is_managed() { + assert!(PROXY_SET_KEYS.iter().all(|k| is_managed_proxy_key(k))); +} + +// Protects client (d): every neutralized key must be a managed key, and the +// neutralize set is exactly the NO_PROXY family. If HTTP_PROXY leaked into the +// neutralize set the proxy target would be blanked and egress would break. +#[test] +fn neutralize_keys_are_exactly_the_no_proxy_family() { + assert!(PROXY_NEUTRALIZE_KEYS + .iter() + .all(|k| is_managed_proxy_key(k))); + assert!(PROXY_NEUTRALIZE_KEYS + .iter() + .all(|k| k.eq_ignore_ascii_case("NO_PROXY"))); + assert!(PROXY_NEUTRALIZE_KEYS.contains(&"NO_PROXY")); + assert!(PROXY_NEUTRALIZE_KEYS.contains(&"no_proxy")); +} + +// Protects client (b) and (d): every managed family appears in the scrub list +// in BOTH spellings -- upper-case and lower-case -- and every entry in the list +// is itself a key the module recognizes as managed (no stray or unmanaged +// entry). The contract keeps the lower-case duplicates so a consumer that does +// a case-sensitive `contains` over the slice (clients like Python urllib and +// curl lower-case these names) still sees the whole set; if a lower-case +// spelling went missing, such a consumer would fail to scrub that family. +#[test] +fn proxy_env_keys_contain_both_spellings_of_every_family_and_only_managed_entries() { + for family in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "NO_PROXY", + ] { + assert!(PROXY_ENV_KEYS.contains(&family)); + assert!(PROXY_ENV_KEYS.contains(&family.to_ascii_lowercase().as_str())); + } + assert!(PROXY_ENV_KEYS.iter().all(|k| is_managed_proxy_key(k))); +} + +// Protects client (b), (c), and (d): every key the module actively sets or +// neutralizes must also appear in the scrub list, so a consumer that scrubs by +// iterating PROXY_ENV_KEYS removes everything the module will re-add. If a set +// key were missing from the scrub list, a caller-supplied value could shadow +// the one the module appends. +#[test] +fn every_set_and_neutralize_key_is_in_the_scrub_list() { + assert!(PROXY_SET_KEYS.iter().all(|k| PROXY_ENV_KEYS.contains(k))); + assert!(PROXY_NEUTRALIZE_KEYS + .iter() + .all(|k| PROXY_ENV_KEYS.contains(k))); +} + +// --------------------------------------------------------------------------- +// apply_cooperative_proxy_env -- scrubbing +// --------------------------------------------------------------------------- + +// Protects client (d): a sandboxed workload cannot pre-set a proxy variable to +// escape the cooperative proxy. Every managed key the caller supplies -- in any +// case, and the FTP family that is scrubbed but never re-set -- is gone or +// replaced; the workload's `evil` target never survives. +#[test] +fn cooperative_env_scrubs_all_caller_supplied_proxy_keys() { + let caller = vec![ + "HTTP_PROXY=http://evil:1".to_string(), + "https_proxy=http://evil:1".to_string(), + "ALL_PROXY=http://evil:1".to_string(), + "FTP_PROXY=http://evil:1".to_string(), + "No_Proxy=internal.example.com".to_string(), + ]; + + let result = apply_cooperative_proxy_env(&caller, PROXY_URL); + + assert!(!result.iter().any(|e| e.contains("evil"))); + assert!(!result.iter().any(|e| e.contains("internal.example.com"))); + assert!(!result + .iter() + .any(|e| key_of(e).eq_ignore_ascii_case("FTP_PROXY"))); +} + +// Protects client (d): duplicate and mixed-case proxy keys are an obvious +// evasion attempt. Neither the second copy nor an unusual casing survives with +// the workload's value. +#[test] +fn cooperative_env_scrubs_duplicate_and_mixed_case_proxy_keys() { + let caller = vec![ + "HTTP_PROXY=http://evil:1".to_string(), + "HtTp_PrOxY=http://evil:2".to_string(), + "http_proxy=http://evil:3".to_string(), + ]; + + let result = apply_cooperative_proxy_env(&caller, PROXY_URL); + + assert!(!result.iter().any(|e| e.contains("evil"))); + assert_eq!(value_for(&result, "HTTP_PROXY"), Some(PROXY_URL)); + assert_eq!(value_for(&result, "http_proxy"), Some(PROXY_URL)); +} + +// --------------------------------------------------------------------------- +// apply_cooperative_proxy_env -- setting and neutralizing +// --------------------------------------------------------------------------- + +// Protects client (c): WSLc merges this result over an image's baked-in ENV, +// so each set key must point at the real proxy for cooperating traffic to be +// routed. Every PROXY_SET_KEYS entry is present and points at proxy_url. +#[test] +fn cooperative_env_sets_every_set_key_to_the_proxy_url() { + let result = apply_cooperative_proxy_env(&[], PROXY_URL); + + assert!(PROXY_SET_KEYS + .iter() + .all(|k| value_for(&result, k) == Some(PROXY_URL))); +} + +// Protects client (d): the contract neutralizes the NO_PROXY family to the +// empty string so an inherited or image-baked exemption cannot disable the +// proxy. Each neutralize key is present and set to empty -- not absent, not a +// host list. +#[test] +fn cooperative_env_neutralizes_no_proxy_family_to_empty() { + let caller = vec!["NO_PROXY=*".to_string(), "no_proxy=*.internal".to_string()]; + + let result = apply_cooperative_proxy_env(&caller, PROXY_URL); + + assert!(PROXY_NEUTRALIZE_KEYS + .iter() + .all(|k| value_for(&result, k) == Some(""))); +} + +// Protects client (d): a workload that sets NO_PROXY=* is trying to exempt all +// hosts from the proxy. NO_PROXY must never be pointed at the proxy URL, and +// its blanket-exemption value must not survive. +#[test] +fn cooperative_env_never_points_no_proxy_at_the_proxy_url() { + let caller = vec!["NO_PROXY=*".to_string()]; + + let result = apply_cooperative_proxy_env(&caller, PROXY_URL); + + assert_ne!(value_for(&result, "NO_PROXY"), Some(PROXY_URL)); + assert_eq!(value_for(&result, "NO_PROXY"), Some("")); + assert!(!result.iter().any(|e| e == "NO_PROXY=*")); +} + +// --------------------------------------------------------------------------- +// apply_cooperative_proxy_env -- order preservation +// --------------------------------------------------------------------------- + +// Protects client (c): WSLc relies on non-proxy entries surviving unchanged so +// the merge over the image ENV is predictable. Every non-proxy entry is +// preserved verbatim and in its original relative order. +#[test] +fn cooperative_env_preserves_non_proxy_entries_in_order() { + let caller = vec![ + "PATH=/usr/bin:/bin".to_string(), + "HTTP_PROXY=http://evil:1".to_string(), + "HOME=/root".to_string(), + "LANG=C.UTF-8".to_string(), + ]; + + let result = apply_cooperative_proxy_env(&caller, PROXY_URL); + + let preserved = non_proxy_entries(&result); + assert_eq!( + preserved, + vec![ + &"PATH=/usr/bin:/bin".to_string(), + &"HOME=/root".to_string(), + &"LANG=C.UTF-8".to_string(), + ] + ); +} + +// Protects client (c): the contract appends the managed keys after scrubbing, +// so when WSLc treats later entries as winning duplicates the proxy keys win. +// Every non-proxy entry precedes every managed entry in the result. +#[test] +fn cooperative_env_appends_managed_keys_after_non_proxy_entries() { + let caller = vec![ + "PATH=/usr/bin".to_string(), + "HTTP_PROXY=http://evil:1".to_string(), + "HOME=/root".to_string(), + ]; + + let result = apply_cooperative_proxy_env(&caller, PROXY_URL); + + let last_non_proxy = result + .iter() + .rposition(|e| !is_managed_proxy_key(key_of(e))) + .unwrap(); + let first_managed = result + .iter() + .position(|e| is_managed_proxy_key(key_of(e))) + .unwrap(); + assert!(last_non_proxy < first_managed); +} + +// --------------------------------------------------------------------------- +// apply_proxy_env -- LXC entry point +// --------------------------------------------------------------------------- + +// Protects client (a): when the proxy carries an address, LXC needs the env +// pointed at it and NO_PROXY neutralized. The keys are set to the proxy's URL +// and the return is true so LXC emits --clear-env. +#[test] +fn apply_proxy_env_enabled_sets_keys_and_returns_true() { + let proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + let expected_url = proxy.address.as_ref().unwrap().to_url(); + let mut env = vec![ + "PATH=/usr/bin".to_string(), + "HTTP_PROXY=http://evil:1".to_string(), + ]; + + let force_clean = apply_proxy_env(&mut env, &proxy); + + assert!(force_clean); + assert!(!env.iter().any(|e| e.contains("evil"))); + assert!(PROXY_SET_KEYS + .iter() + .all(|k| value_for(&env, k) == Some(expected_url.as_str()))); + assert!(PROXY_NEUTRALIZE_KEYS + .iter() + .all(|k| value_for(&env, k) == Some(""))); +} + +// Protects client (a) and (d): the contract says a valueless entry with no `=` +// is treated as a bare key and still scrubbed. A workload passing a bare +// `HTTP_PROXY` (which inherits the host value) must not slip through. +#[test] +fn apply_proxy_env_scrubs_bare_valueless_proxy_key() { + let proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + let mut env = vec!["HTTP_PROXY".to_string(), "PATH=/usr/bin".to_string()]; + + let force_clean = apply_proxy_env(&mut env, &proxy); + + assert!(force_clean); + assert!(!env.iter().any(|e| e == "HTTP_PROXY")); + assert_eq!(value_for(&env, "PATH"), Some("/usr/bin")); +} + +// Protects client (a) and (d): even with no proxy configured, LXC must still +// force a clean environment so lxc-attach cannot inherit the MXC host process +// env (which carries proxy vars and credentials). Caller proxy keys are +// scrubbed and the return is still true. +#[test] +fn apply_proxy_env_disabled_still_scrubs_and_returns_true() { + let proxy = ProxyConfig::default(); + let mut env = vec![ + "PATH=/usr/bin".to_string(), + "HTTP_PROXY=http://host-proxy:9".to_string(), + "NO_PROXY=internal".to_string(), + ]; + + let force_clean = apply_proxy_env(&mut env, &proxy); + + assert!(force_clean); + assert!(!env.iter().any(|e| e.contains("host-proxy"))); + assert!(!env.iter().any(|e| e.contains("internal"))); + assert_eq!(value_for(&env, "PATH"), Some("/usr/bin")); +} + +// Protects client (a): the return contract is "always true, including when env +// ends up empty" -- the empty vector still tells LXC to emit --clear-env so an +// empty env does not silently inherit the host environment. +#[test] +fn apply_proxy_env_returns_true_for_empty_env() { + let proxy = ProxyConfig::default(); + let mut env: Vec = Vec::new(); + + let force_clean = apply_proxy_env(&mut env, &proxy); + + assert!(force_clean); +} + +// --------------------------------------------------------------------------- +// redact_proxy_url +// --------------------------------------------------------------------------- + +// Protects client (d): logs must not leak proxy credentials. When userinfo is +// present the password must not appear in the redacted string, while the host +// is retained so the log is still useful. +#[test] +fn redact_proxy_url_removes_userinfo_credentials() { + let redacted = redact_proxy_url("http://alice:hunter2@proxy.example.com:8080"); + + assert!(!redacted.contains("hunter2")); + assert!(!redacted.contains("alice:hunter2")); + assert!(redacted.contains("proxy.example.com")); +} + +// Protects client (d): a URL with no userinfo has nothing to redact and must be +// returned unchanged, so redaction does not corrupt an ordinary proxy URL. +#[test] +fn redact_proxy_url_leaves_url_without_userinfo_unchanged() { + let input = "http://127.0.0.1:8080"; + + let redacted = redact_proxy_url(input); + + assert_eq!(redacted, input); +} + +// Protects client (d): a naive split on '@' would corrupt a URL whose only '@' +// is in the path. Such a URL has no userinfo, so it must be returned intact. +#[test] +fn redact_proxy_url_ignores_at_sign_in_path() { + let input = "http://127.0.0.1:8080/path@segment"; + + let redacted = redact_proxy_url(input); + + assert_eq!(redacted, input); +} + +// proxy_url_has_credentials +// ------------------------- +// Protects client (d): this predicate is what a backend consults before it +// puts a proxy URL somewhere the URL cannot be taken back out of -- process +// argv, in the LXC case. A false negative is a leaked password, so each shape +// below is asserted directly rather than inferred from the redaction helper. + +// The shape the guard exists for: userinfo carrying a password. +#[test] +fn a_url_with_user_and_password_carries_credentials() { + assert!(proxy_url_has_credentials( + "http://alice:hunter2@proxy.example.com:8080" + )); +} + +// A bare username is still userinfo. It names a principal, and the guard's +// contract is about userinfo, not about whether a password happens to follow. +#[test] +fn a_url_with_a_bare_username_carries_credentials() { + assert!(proxy_url_has_credentials( + "http://alice@proxy.example.com:8080" + )); +} + +// The complement, and the anti-vacuity partner for every assertion above: an +// ordinary proxy URL must pass, or the guard would refuse all proxies and the +// positive cases would prove nothing. +#[test] +fn an_ordinary_proxy_url_carries_no_credentials() { + assert!(!proxy_url_has_credentials("http://127.0.0.1:8080")); + assert!(!proxy_url_has_credentials( + "https://proxy.example.com:3128/" + )); +} + +// A naive `contains('@')` would report credentials for a URL whose only '@' is +// in the path, refusing a legitimate proxy. +#[test] +fn an_at_sign_in_the_path_is_not_credentials() { + assert!(!proxy_url_has_credentials( + "http://127.0.0.1:8080/path@segment" + )); + assert!(!proxy_url_has_credentials("http://127.0.0.1:8080/?q=a@b")); + assert!(!proxy_url_has_credentials("http://127.0.0.1:8080/#a@b")); +} + +// The case that rules out defining this predicate as "redaction changes the +// string": userinfo that is already the redaction marker redacts to itself, so +// a comparison-based implementation reports a credential-bearing URL as clean. +#[test] +fn userinfo_that_looks_like_the_redaction_marker_still_carries_credentials() { + let url = "http://***@proxy.example.com:8080"; + + assert_eq!( + redact_proxy_url(url), + url, + "precondition: redaction leaves this URL unchanged" + ); + assert!( + proxy_url_has_credentials(url), + "the predicate must not be defined as `redact_proxy_url(url) != url`" + ); +} + +// This test used to assert that `not-a-url@at-all` carries no credentials, on +// the premise that a string with no scheme separator has no authority to parse +// and therefore no userinfo to find. The premise confuses parseability with +// safety. Nothing downstream re-parses the value: `ProxyAddress::from_url` +// stores it verbatim, `to_url` returns it verbatim, and it lands in +// `lxc-attach` argv as written. +// +// The decisive shape is a bearer token used as the sole userinfo -- +// `token@proxy.example.com` has no colon and no scheme, so a predicate that +// gives up without a scheme reports a live secret as clean. Every +// password-bearing form does contain a colon, which is why this looked safe. +// +// What survives from the original test is the part that was actually right: a +// port colon is not a scheme separator, and the guard must not fire on it. +#[test] +fn a_port_colon_is_not_userinfo_but_a_schemeless_token_is() { + assert!( + !proxy_url_has_credentials("proxy.example.com:8080"), + "a port colon must not be mistaken for userinfo" + ); + assert!( + !proxy_url_has_credentials("not-a-url-at-all"), + "a schemeless string with no `@` carries nothing" + ); + assert!( + proxy_url_has_credentials("not-a-url@at-all"), + "an `@` ahead of the path is userinfo even with no scheme to anchor it" + ); +} + +// Protects client (d): a proxy URL is redacted on the *failure* path, where it +// may not be a well-formed absolute URL. `url::Url::parse` accepts +// `alice:hunter2@example.com` as scheme `alice`, so a redactor that gives up +// without `://` hands the password straight to the scheme diagnostic. +#[test] +fn redact_proxy_url_removes_userinfo_from_a_scheme_opaque_url() { + let redacted = redact_proxy_url("alice:hunter2@proxy.example.com"); + + assert!( + !redacted.contains("hunter2"), + "password survived: {redacted}" + ); + assert!( + redacted.contains("proxy.example.com"), + "the host must survive so the error still diagnoses anything: {redacted}" + ); +} + +// The complement: a string with no userinfo and no `://` must come back intact, +// or the redactor would corrupt ordinary diagnostics. +#[test] +fn redact_proxy_url_leaves_a_scheme_opaque_url_without_userinfo_alone() { + let input = "socks5:proxy.example.com"; + + assert_eq!(redact_proxy_url(input), input); +} + +// The bypass the redactor already knew about and the guard did not. +// `url::Url::parse` accepts `scheme:rest`, `ProxyAddress::from_url` is public +// and stores whatever string it is handed, and `to_url` returns it verbatim -- +// so this shape reaches `--set-var` in `lxc-attach` argv, and argv is +// world-readable through /proc//cmdline. The two functions used to parse +// the URL separately, which is exactly how they came to disagree about it. +#[test] +fn a_scheme_opaque_url_with_userinfo_carries_credentials() { + assert!( + proxy_url_has_credentials("http:alice:hunter2@proxy.example.com"), + "the opaque scheme:rest form hides userinfo from a `://`-only parser" + ); +} + +#[test] +fn a_scheme_opaque_url_with_a_bare_username_carries_credentials() { + assert!( + proxy_url_has_credentials("http:alice@proxy.example.com"), + "userinfo without a password is still userinfo" + ); +} + +// The complement, so the fix cannot be "return true more often". A port colon +// must not be mistaken for the opaque scheme separator. +#[test] +fn a_scheme_opaque_url_without_userinfo_carries_no_credentials() { + assert!( + !proxy_url_has_credentials("socks5:proxy.example.com"), + "an opaque URL with no `@` carries nothing" + ); + assert!( + !proxy_url_has_credentials("proxy.example.com:8080"), + "a port colon is not userinfo" + ); +} + +// An `@` after the path delimiter belongs to the path, in the opaque form just +// as in the absolute one. +#[test] +fn an_at_sign_in_the_path_of_an_opaque_url_is_not_userinfo() { + assert!( + !proxy_url_has_credentials("http:proxy.example.com/a@b"), + "an `@` after the path delimiter is not userinfo" + ); +} + +// A value with no scheme at all reaches no legitimate proxy path, but the guard +// is the last line before argv, so it fails closed rather than reasoning about +// where a malformed value ends up. +#[test] +fn a_schemeless_value_with_userinfo_fails_closed() { + assert!( + proxy_url_has_credentials("alice@proxy.example.com"), + "a schemeless value carrying userinfo must not be reported as clean" + ); +} + +// The two functions must agree about what an authority is. Disagreeing about +// it is the whole defect: redaction handled the opaque form while the guard +// called the same string clean. +#[test] +fn redaction_and_the_credential_guard_agree_on_every_shape() { + let bearing = [ + "http://alice:hunter2@proxy.example.com:8080", + "http:alice:hunter2@proxy.example.com", + "https://alice@proxy.example.com", + "http:alice@proxy.example.com", + ]; + for url in bearing { + assert!( + proxy_url_has_credentials(url), + "guard reported no credentials for {url}" + ); + assert_ne!( + redact_proxy_url(url), + url, + "redaction left {url} unchanged while the guard flagged it" + ); + assert!( + !redact_proxy_url(url).contains("hunter2"), + "password survived redaction of {url}" + ); + } + + let clean = [ + "http://proxy.example.com:8080", + "socks5:proxy.example.com", + "http://proxy.example.com/a@b", + ]; + for url in clean { + assert!( + !proxy_url_has_credentials(url), + "guard invented credentials in {url}" + ); + assert_eq!( + redact_proxy_url(url), + url, + "redaction altered the credential-free {url}" + ); + } +} + +// A *special* scheme (http, https, and the rest of the WHATWG set) treats one +// slash exactly as it treats two, so this is the credentialed URL +// `http://alice:hunter2@proxy.example.com:3128/` however plainly it reads as a +// path. Anchoring the authority on `://` skipped straight past it. +#[test] +fn a_single_slash_after_the_scheme_still_introduces_an_authority() { + let url = "http:/alice:hunter2@proxy.example.com:3128"; + + assert!( + proxy_url_has_credentials(url), + "the one-slash form carries credentials" + ); + assert!( + !redact_proxy_url(url).contains("hunter2"), + "password survived redaction of {url}" + ); +} + +#[test] +fn any_run_of_slashes_after_the_scheme_introduces_an_authority() { + for url in [ + "http:///alice:hunter2@proxy.example.com", + "http:////alice:hunter2@proxy.example.com", + ] { + assert!(proxy_url_has_credentials(url), "{url} carries credentials"); + assert!( + !redact_proxy_url(url).contains("hunter2"), + "password survived redaction of {url}" + ); + } +} + +// The redaction has to reassemble the URL in the form it arrived in, or the +// message names a URL the operator never wrote. +#[test] +fn redaction_preserves_the_separator_it_was_given() { + assert_eq!( + redact_proxy_url("http://alice:hunter2@proxy.example.com"), + "http://***@proxy.example.com" + ); + assert_eq!( + redact_proxy_url("http:/alice:hunter2@proxy.example.com"), + "http:/***@proxy.example.com" + ); + assert_eq!( + redact_proxy_url("http:alice:hunter2@proxy.example.com"), + "http:***@proxy.example.com" + ); +} + +// A bearer token used as sole userinfo has no colon and so no scheme to anchor +// on. The guard already refused it, but redaction returned it unchanged -- so +// the rejection message printed the very secret it was refusing. +#[test] +fn a_schemeless_value_with_userinfo_is_redacted_as_well_as_refused() { + let url = "token@proxy.example.com"; + + assert!(proxy_url_has_credentials(url), "{url} carries a credential"); + assert_eq!(redact_proxy_url(url), "***@proxy.example.com"); +} + +// Empty userinfo names no user and no password. Refusing it would reject a +// configuration that leaks nothing, and redacting it would invent a secret. +// +// Only `""` and `":"` are empty. `"::"` is not: the *first* colon separates the +// username from the password, so the second one is the password's own value. +// Measured against the parser, `http://::@host` yields `password = Some("%3A")` +// while `http://:@host` yields `None`, which is where the boundary sits. +#[test] +fn empty_userinfo_is_not_a_credential() { + for url in [ + "http://@proxy.example.com:3128", + "http://:@proxy.example.com:3128", + ] { + assert!( + !proxy_url_has_credentials(url), + "{url} names neither a user nor a password" + ); + assert_eq!(redact_proxy_url(url), url, "nothing to redact in {url}"); + } +} + +// The boundary case that the all-colons rule got wrong. A password made of +// colons carries little, but the guard's whole job is to agree with the parser +// about what the userinfo is, and disagreeing here is how it disagreed +// everywhere else. +#[test] +fn a_second_colon_in_the_userinfo_is_a_password_not_emptiness() { + for url in [ + "http://::@proxy.example.com:3128", + "http://:::@proxy.example.com:3128", + ] { + let parsed = Url::parse(url).expect("corpus entry should parse"); + assert!( + parsed.password().is_some(), + "the parser should see a password in {url}" + ); + assert!( + proxy_url_has_credentials(url), + "{url} carries a password the guard missed" + ); + assert!( + !redact_proxy_url(url).contains("::@"), + "the userinfo survived redaction in {url}" + ); + } +} + +// The omitted half is the dangerous half to get wrong: a password with no +// username is still a password, and a username with no password is how a +// bearer token is passed. +#[test] +fn a_single_userinfo_component_is_a_credential() { + for url in [ + "http://:hunter2@proxy.example.com", + "http://token@proxy.example.com", + ] { + assert!(proxy_url_has_credentials(url), "{url} carries a credential"); + assert_ne!( + redact_proxy_url(url), + url, + "redaction left {url} unchanged while the guard flagged it" + ); + } +} + +// The two functions are only safe while they cannot disagree, and the pairs +// below are exactly the shapes on which they historically did. +#[test] +fn the_guard_and_the_redaction_never_disagree_on_the_shapes_that_broke_them() { + let shapes = [ + "http://alice:hunter2@proxy.example.com", + "http:alice:hunter2@proxy.example.com", + "http:/alice:hunter2@proxy.example.com", + "token@proxy.example.com", + "alice@proxy.example.com:3128", + ":hunter2@proxy.example.com:3128", + "http://@proxy.example.com", + "http://:@proxy.example.com", + "http://proxy.example.com:8080", + "proxy.example.com:8080", + "http://proxy.example.com/a@b", + "socks5:proxy.example.com", + ]; + + for url in shapes { + let flagged = proxy_url_has_credentials(url); + let redacted = redact_proxy_url(url) != url; + assert_eq!( + flagged, redacted, + "guard said {flagged} and redaction said {redacted} for {url}" + ); + } +} + +// A colon is not proof of a scheme. When it separates a port instead, every +// character before it used to be swallowed as the scheme -- so the authority +// of `alice@proxy.example.com:3128` was read as the bare port `3128`, which +// carries no `@`, and the username was neither flagged nor hidden while still +// reaching `lxc-attach` argv. +#[test] +fn a_schemeless_host_and_port_still_shows_its_userinfo() { + assert!( + proxy_url_has_credentials("alice@proxy.example.com:3128"), + "a username before a host:port is a credential" + ); + assert_eq!( + redact_proxy_url("alice@proxy.example.com:3128"), + "***@proxy.example.com:3128", + "the username must not survive redaction" + ); +} + +#[test] +fn a_schemeless_password_before_a_port_is_a_credential() { + assert!(proxy_url_has_credentials(":hunter2@proxy.example.com:3128")); + assert!( + !redact_proxy_url(":hunter2@proxy.example.com:3128").contains("hunter2"), + "the password must not survive redaction" + ); +} + +// The prefix of a bare `host:port` does satisfy the scheme grammar, and that +// has to stay harmless: it leaves the port as the authority, which carries no +// credential either way. This is the invariant the fix above could have broken. +#[test] +fn a_bare_host_and_port_is_still_not_a_credential() { + assert!(!proxy_url_has_credentials("proxy.example.com:8080")); + assert_eq!( + redact_proxy_url("proxy.example.com:8080"), + "proxy.example.com:8080" + ); +} + +// A prefix that fails the grammar for a reason other than `@` must not start +// being treated as an authority in a way that invents a credential. +#[test] +fn a_prefix_that_is_not_a_scheme_does_not_invent_a_credential() { + for url in [ + "1http://proxy.example.com", + "pro xy:8080", + ":3128", + "proxy_host:8080", + ] { + assert!( + !proxy_url_has_credentials(url), + "{url} names no user and no password" + ); + } +} + +// `url::Url::parse` follows WHATWG and ignores leading and trailing C0 +// controls and spaces, and strips tab, newline, and carriage return from +// anywhere in the value -- but `ProxyAddress::from_url` stores the string it +// was given. A guard that reads the raw bytes therefore judges a different +// URL from the one the rest of the system acts on. +#[test] +fn whitespace_around_a_credentialed_url_does_not_hide_it() { + for (name, url) in [ + ( + "leading space", + " http://alice:hunter2@proxy.example.com:3128", + ), + ( + "leading tab", + "\thttp://alice:hunter2@proxy.example.com:3128", + ), + ( + "leading newline", + "\nhttp://alice:hunter2@proxy.example.com:3128", + ), + ( + "leading crlf", + "\r\nhttp://alice:hunter2@proxy.example.com:3128", + ), + ( + "trailing space", + "http://alice:hunter2@proxy.example.com:3128 ", + ), + ( + "interior tab", + "ht\ttp://alice:hunter2@proxy.example.com:3128", + ), + ] { + assert!( + proxy_url_has_credentials(url), + "{name}: the credential is still there once the parser is done with it" + ); + assert!( + !redact_proxy_url(url).contains("hunter2"), + "{name}: redaction left the password in place" + ); + } +} + +// Whitespace must not invent a credential either. +#[test] +fn whitespace_around_a_clean_url_stays_clean() { + for url in [ + " http://proxy.example.com:3128", + "http://proxy.example.com:3128\n", + "\tproxy.example.com:8080", + ] { + assert!( + !proxy_url_has_credentials(url), + "{url:?} names no user and no password" + ); + } +} + +// The guard exists to agree with the parser that actually consumes this URL. +// Every bypass found in review was the same failure -- the guard read one +// string and `lxc-attach` received another -- so the assertion is differential: +// wherever `url::Url::parse` finds userinfo, the guard must find it too, and +// wherever the parser finds none, the guard must not invent one. `url` is the +// crate `ProxyAddress` itself parses with, so it is the oracle rather than a +// second opinion. +// +// The corpus is *generated* rather than listed. A hand-written list already +// failed twice: it missed the backslash authority introducer, and it missed +// `::@`, where the second colon is a password rather than more emptiness. +// Both were shapes nobody thought to write down. Crossing the dimensions +// instead makes coverage a property of the dimensions, so a gap has to be a +// missing *dimension* rather than a missing example. +fn differential_corpus() -> Vec { + let schemes = [ + "http", + "https", + "HTTP", + "ftp", + "ws", + "socks5", + "weird-scheme", + ]; + let separators = ["://", ":/", ":", ":\\/", ":/\\", ":\\\\", "//"]; + let userinfos = [ + "", + "@", + ":@", + "::@", + ":::@", + "alice@", + ":hunter2@", + "alice:hunter2@", + "alice:@", + "***@", + "a%40b:c@", + "alice:hun%20ter2@", + ]; + let hosts = ["10.0.3.1:3128", "proxy.example.com", "[::1]:3128"]; + let tails = ["", "/path", "/p@th", "?q=a@b", "#f@g"]; + let paddings = ["", " ", "\t", "\n", "\r"]; + + let mut corpus = Vec::new(); + for scheme in schemes { + for separator in separators { + for userinfo in userinfos { + for host in hosts { + for tail in tails { + let body = format!("{scheme}{separator}{userinfo}{host}{tail}"); + for padding in paddings { + corpus.push(format!("{padding}{body}")); + corpus.push(format!("{body}{padding}")); + } + } + } + } + } + } + corpus +} + +#[test] +fn the_guard_agrees_with_the_parser_that_will_actually_read_the_url() { + let mut missed = Vec::new(); + let mut invented = Vec::new(); + let mut compared = 0usize; + + for raw in differential_corpus() { + // The parser is only an oracle for inputs it accepts. Where it refuses + // outright, nothing reaches `lxc-attach` and there is no credential to + // leak, so it has no verdict to compare against. + let Ok(parsed) = Url::parse(&raw) else { + continue; + }; + compared += 1; + + let parser_sees = !parsed.username().is_empty() || parsed.password().is_some(); + let guard_sees = proxy_url_has_credentials(&raw); + + if parser_sees && !guard_sees { + missed.push(format!( + " MISSED: parser found user={:?} pass={:?}, guard found none, in bytes {:?}", + parsed.username(), + parsed.password(), + raw.as_bytes() + )); + } + + // Over-reporting and under-reporting do not cost the same, so they are + // not held to the same standard. A miss puts a password into argv and + // into the error text meant to hide it. An over-report rejects a + // config -- bad, since the caller destroys the container, but not a + // disclosure. So the guard is allowed to fire on any string carrying an + // `@`, since that is the only character that can introduce userinfo and + // its presence makes suspicion defensible. What the guard may never do + // is claim a credential in a string with no `@` anywhere, which would + // be an invention rather than caution. + if !parser_sees && guard_sees && !raw.contains('@') { + invented.push(format!( + " INVENTED: guard claimed a credential with no `@` anywhere, in bytes {:?}", + raw.as_bytes() + )); + } + } + + assert!( + compared > 500, + "the corpus degenerated: only {compared} inputs parsed" + ); + assert!( + missed.is_empty() && invented.is_empty(), + "the guard disagreed with the parser on {} of {compared} parseable inputs:\n{}\n{}", + missed.len() + invented.len(), + missed.join("\n"), + invented.join("\n") + ); +} + +// The fifth bypass, and the second one a differential test caught rather than +// a guess. WHATWG treats a backslash as a slash for the special schemes, so +// `http:\/alice:hunter2@host` introduces an authority exactly as `http://` +// does. Counting only forward slashes left the authority as the single +// character `\`, which carries no `@`, so the guard reported no credentials +// and the redaction returned the password verbatim. +#[test] +fn a_backslash_introduces_an_authority_for_the_special_schemes() { + let bypasses = [ + "http:\\/alice:hunter2@10.0.3.1:3128", + "http:/\\alice:hunter2@10.0.3.1:3128", + "http:\\\\alice:hunter2@10.0.3.1:3128", + "https:\\/alice:hunter2@10.0.3.1:3128", + "HTTP:\\/alice:hunter2@10.0.3.1:3128", + ]; + + for url in bypasses { + assert!( + proxy_url_has_credentials(url), + "a backslash hid the credentials in {:?} (bytes {:?})", + url, + url.as_bytes() + ); + assert!( + !redact_proxy_url(url).contains("hunter2"), + "the redaction returned the password for {url:?}" + ); + } +} + +// The equivalence belongs to the special schemes only, which is what keeps the +// guard from rejecting proxies that leak nothing. A backslash after a +// non-special scheme is an opaque path to the parser, not an authority. +#[test] +fn a_backslash_still_ends_an_authority_it_does_not_only_begin_one() { + // The authority ends at the backslash, so the `@` belongs to the path and + // names no credential -- exactly as the parser reads it. + assert!(!proxy_url_has_credentials( + "http://10.0.3.1:3128\\path@notuserinfo" + )); + assert_eq!( + redact_proxy_url("http://10.0.3.1:3128\\path@notuserinfo"), + "http://10.0.3.1:3128\\path@notuserinfo" + ); +} + +// The backslash equivalence is applied to every scheme, not only the special +// ones, and that is a deliberate divergence from the parser. The parser reads +// `socks5:\/alice:hunter2@host` as an opaque path -- measured directly, it +// reports `cannot_be_a_base = true`, an empty username, and no host -- so by +// its rules there is no credential. But the password is still sitting in the +// string, and that string reaches argv and the failure diagnostic. The two +// ways of being wrong do not cost the same: over-reporting rejects a config, +// under-reporting publishes a password. So the guard is allowed to be more +// suspicious than the parser here, and the redactor has to strip it. +#[test] +fn a_backslash_does_not_hide_a_credential_behind_an_unusual_scheme() { + let opaque = "socks5:\\/alice:hunter2@10.0.3.1:1080"; + + let parsed = Url::parse(opaque).expect("the parser accepts it as an opaque path"); + assert!( + parsed.username().is_empty() && parsed.password().is_none(), + "the parser is supposed to see no userinfo here -- that is the whole point" + ); + + assert!( + proxy_url_has_credentials(opaque), + "the password is in the string and reaches argv, so the guard must fire" + ); + let redacted = redact_proxy_url(opaque); + assert!( + !redacted.contains("hunter2"), + "password survived redaction: {redacted}" + ); + assert!( + redacted.contains("10.0.3.1"), + "the host must survive so the error still diagnoses something: {redacted}" + ); + + // The ordinary `//` form of the same scheme is an authority by anyone's + // reading, and it carries a credential too. + assert!(proxy_url_has_credentials( + "socks5://alice:hunter2@10.0.3.1:1080" + )); +} diff --git a/tests/configs/lxc_network_deny_precedence_control.json b/tests/configs/lxc_network_deny_precedence_control.json new file mode 100644 index 000000000..8c7c11780 --- /dev/null +++ b/tests/configs/lxc_network_deny_precedence_control.json @@ -0,0 +1,21 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Net-DenyCtl", + "containment": "lxc", + "process": { + "commandLine": "sh -c \"wget -qO- --timeout=10 https://api.github.com/zen >/dev/null 2>&1 && echo MXC_NET_ALLOWED || echo MXC_NET_BLOCKED\"" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": ["0.0.0.0/0", "::/0"], + "blockedHosts": [] + } +} diff --git a/tests/configs/lxc_network_deny_precedence_overlap.json b/tests/configs/lxc_network_deny_precedence_overlap.json new file mode 100644 index 000000000..15e3277c5 --- /dev/null +++ b/tests/configs/lxc_network_deny_precedence_overlap.json @@ -0,0 +1,21 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Net-DenyWins", + "containment": "lxc", + "process": { + "commandLine": "sh -c \"wget -qO- --timeout=10 https://api.github.com/zen >/dev/null 2>&1 && echo MXC_NET_ALLOWED || echo MXC_NET_BLOCKED\"" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": ["0.0.0.0/0", "::/0"], + "blockedHosts": ["0.0.0.0/0", "::/0"] + } +} diff --git a/tests/configs/lxc_network_enforcement_allow.json b/tests/configs/lxc_network_enforcement_allow.json new file mode 100644 index 000000000..618688d9d --- /dev/null +++ b/tests/configs/lxc_network_enforcement_allow.json @@ -0,0 +1,21 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Net-Allow", + "containment": "lxc", + "process": { + "commandLine": "sh -c \"wget -qO- --timeout=10 https://api.github.com/zen >/dev/null 2>&1 && echo MXC_NET_ALLOWED || echo MXC_NET_BLOCKED\"" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": ["api.github.com"], + "blockedHosts": [] + } +} \ No newline at end of file diff --git a/tests/configs/lxc_network_enforcement_deny.json b/tests/configs/lxc_network_enforcement_deny.json new file mode 100644 index 000000000..73cd2a35b --- /dev/null +++ b/tests/configs/lxc_network_enforcement_deny.json @@ -0,0 +1,21 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Net-Deny", + "containment": "lxc", + "process": { + "commandLine": "sh -c \"wget -qO- --timeout=8 https://api.github.com/zen >/dev/null 2>&1 && echo MXC_NET_ALLOWED || echo MXC_NET_BLOCKED\"" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": [], + "blockedHosts": [] + } +} \ No newline at end of file diff --git a/tests/configs/lxc_network_proxy.json b/tests/configs/lxc_network_proxy.json new file mode 100644 index 000000000..2760693dc --- /dev/null +++ b/tests/configs/lxc_network_proxy.json @@ -0,0 +1,20 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Network-Proxy", + "containment": "lxc", + "process": { + "commandLine": "ok=PROXY_FAIL; if wget -qO- --timeout=10 http://sentinel.invalid/ 2>/dev/null | grep -q MXC_PROXY_SENTINEL; then ok=PROXY_OK; fi; echo \"$ok\"; if http_proxy= https_proxy= HTTP_PROXY= HTTPS_PROXY= all_proxy= ALL_PROXY= wget -qO- --timeout=5 http://1.1.1.1/ >/dev/null 2>&1; then echo DIRECT_IPV4_LEAK; else echo DIRECT_IPV4_BLOCKED; fi; if ip -6 addr show scope global 2>/dev/null | grep -q inet6; then if http_proxy= https_proxy= HTTP_PROXY= HTTPS_PROXY= all_proxy= ALL_PROXY= wget -qO- --timeout=5 'http://[2606:4700:4700::1111]/' >/dev/null 2>&1; then echo DIRECT_IPV6_LEAK; else echo DIRECT_IPV6_BLOCKED; fi; else echo DIRECT_IPV6_SKIP_NO_STACK; fi; if nslookup example.com 1.1.1.1 >/dev/null 2>&1; then echo FORWARDED_DNS_LEAK; else echo FORWARDED_DNS_BLOCKED; fi; if nslookup example.com >/dev/null 2>&1; then echo GATEWAY_DNS_REACHED; else echo GATEWAY_DNS_BLOCKED; fi" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "proxy": { "url": "http://10.0.3.1:3128" } + } +} diff --git a/tests/configs/lxc_network_proxy_credentials_rejected.json b/tests/configs/lxc_network_proxy_credentials_rejected.json new file mode 100644 index 000000000..802e0e868 --- /dev/null +++ b/tests/configs/lxc_network_proxy_credentials_rejected.json @@ -0,0 +1,20 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Network-Proxy-Credentials-Rejected", + "containment": "lxc", + "process": { + "commandLine": "echo THIS_MUST_NEVER_RUN" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "proxy": { "url": "http://alice:hunter2@10.0.3.1:3128" } + } +} diff --git a/tests/scripts/run_lxc_all_tests.sh b/tests/scripts/run_lxc_all_tests.sh index 0510bb4d8..de8c5b864 100644 --- a/tests/scripts/run_lxc_all_tests.sh +++ b/tests/scripts/run_lxc_all_tests.sh @@ -66,6 +66,10 @@ run_test "LXC Network IPv6+CIDR" "$SCRIPT_DIR/run_lxc_network_ipv6_cidr_test.sh" run_test "LXC Network Invalid CIDR" "$SCRIPT_DIR/run_lxc_network_invalid_cidr_test.sh" run_test "LXC Network Dual-Stack Hostname" "$SCRIPT_DIR/run_lxc_network_dualstack_test.sh" run_test "LXC Network CIDR Boundary" "$SCRIPT_DIR/run_lxc_network_cidr_boundary_test.sh" +run_test "LXC Network Enforcement" "$SCRIPT_DIR/run_lxc_network_enforcement_test.sh" +run_test "LXC Network Deny Precedence" "$SCRIPT_DIR/run_lxc_network_deny_precedence_test.sh" +run_test "LXC Network Proxy" "$SCRIPT_DIR/run_lxc_network_proxy_test.sh" +run_test "LXC Network Proxy Credentials" "$SCRIPT_DIR/run_lxc_network_proxy_credentials_test.sh" run_test "LXC Timeout" "$SCRIPT_DIR/run_lxc_timeout_test.sh" run_test "LXC Env+Cwd" "$SCRIPT_DIR/run_lxc_env_cwd_test.sh" @@ -79,6 +83,22 @@ fi if [ "$PASSED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then echo "WARNING: no tests actually executed; every test was skipped." fi +# Strict mode, for continuous integration. A developer box legitimately lacks +# ip6tables or LXC and should be able to run what it can, so a skip is only a +# warning there. On a runner provisioned to execute this suite, a skip means a +# prerequisite silently disappeared, and the gate would then go green while +# testing nothing -- which is the precise way an unenforced firewall shipped. +if [ "${MXC_LXC_TESTS_REQUIRE_EXECUTION:-0}" != "0" ]; then + if [ "$PASSED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then + echo "ERROR: strict mode: no test executed. Refusing to report success." + exit 1 + fi + if [ "$SKIPPED" -gt 0 ]; then + echo "ERROR: strict mode: $SKIPPED test(s) skipped a prerequisite that this" + echo "environment is supposed to provide. Refusing to report success." + exit 1 + fi +fi if [ $FAILED -gt 0 ]; then echo -e "Failures:$FAILURES" exit 1 diff --git a/tests/scripts/run_lxc_network_deny_precedence_test.sh b/tests/scripts/run_lxc_network_deny_precedence_test.sh new file mode 100644 index 000000000..7c813e893 --- /dev/null +++ b/tests/scripts/run_lxc_network_deny_precedence_test.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# LXC deny-precedence enforcement test +# +# A destination named in both allowedHosts and blockedHosts must be blocked. +# The chain is first-match-wins, so this is decided entirely by which list is +# emitted first -- there is no separate precedence pass to assert on. That +# makes it invisible to any test that only inspects rules individually, and it +# is why this assertion is behavioral rather than a log grep. +# +# Both configs name the same destination set, 0.0.0.0/0 and ::/0, so the rules +# are literal CIDRs rather than a hostname resolved once per list entry. A +# hostname would be resolved separately for the allow entry and the block +# entry, and round-robin DNS could hand back different addresses for the two, +# which would make the outcome depend on which address wget happened to pick. +# +# The control run is what makes the overlap run mean anything. Without it, a +# host with no working egress at all -- or a change that broke networking +# outright -- would produce the same blocked verdict and look like a pass. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" + +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. A suite that could not run must not look green. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for iptables/ip6tables and LXC." +command -v iptables >/dev/null 2>&1 || skip "iptables is not installed." +command -v ip6tables >/dev/null 2>&1 || skip "ip6tables is not installed." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +OVERLAP_CONFIG="$REPO_DIR/tests/configs/lxc_network_deny_precedence_overlap.json" +CONTROL_CONFIG="$REPO_DIR/tests/configs/lxc_network_deny_precedence_control.json" + +fail() { + echo "FAIL: $1" + exit 1 +} + +# List the MXC-owned chains a tool currently holds. The chain name is derived +# from a digest of the container name, so a hard-coded literal names a chain +# that cannot exist: `iptables -S ` always fails, the cleanup check +# below reads that failure as "the chain is gone", and the assertion passes +# without inspecting anything. Matching the MXC- prefix stays correct across +# naming changes. +mxc_chains() { + "$1" -S 2>/dev/null | sed -n 's/^-N \(MXC-.*\)$/\1/p' | sort +} + +# Compared against a snapshot taken before the run, so chains left behind by an +# earlier failed run are not blamed on this one. +assert_no_new_mxc_chains() { + local tool="$1" before="$2" after="" leaked="" chain + # Captured before iterating rather than piped in from a process + # substitution, whose exit status is not the loop's. A failed enumeration + # would otherwise read as zero chains and pass this assertion while + # verifying nothing. + if ! after="$(mxc_chains "$tool")"; then + fail "could not enumerate $tool chains, so cleanup was not verified." + fi + while IFS= read -r chain; do + [ -n "$chain" ] || continue + grep -Fxq "$chain" <<<"$before" || leaked="$leaked $chain" + done <<<"$after" + if [ -n "$leaked" ]; then + fail "$tool chain(s) left behind after lxc-exec completed:$leaked" + fi +} + +# The named chain must be gone, and the run must not have leaked any other +# MXC-owned chain either. The first check is specific to the container this +# case ran; the second catches a rename or a partial rollback that leaves a +# differently named chain behind. +assert_firewall_chain_cleaned_up() { + local chain="$1" + if iptables -S "$chain" >/dev/null 2>&1; then + fail "iptables chain '$chain' was left behind after lxc-exec completed." + fi + if ip6tables -S "$chain" >/dev/null 2>&1; then + fail "ip6tables chain '$chain' was left behind after lxc-exec completed." + fi + assert_no_new_mxc_chains iptables "$MXC_CHAINS_BEFORE_V4" + assert_no_new_mxc_chains ip6tables "$MXC_CHAINS_BEFORE_V6" +} + +assert_no_forward_reference() { + if iptables -S FORWARD 2>/dev/null | grep -Fq -- "$1"; then + fail "a FORWARD rule still references chain '$1' after teardown." + fi +} + +# The chain name is a digest of the container name, so it is read back from this +# run's own debug output rather than hard-coded. Every assertion that names a +# chain depends on this having succeeded, so an unparsed name fails the test +# here instead of silently reducing those assertions to no-ops. +derive_chain_name() { + CHAIN_NAME="$(sed -n 's/^.*Creating iptables\/ip6tables chain: \([^ ]*\).*$/\1/p' <<<"$1" | head -n 1)" + if [ -z "$CHAIN_NAME" ]; then + fail "no chain creation was logged, so the chain name could not be determined." + fi + if ! grep -Eq '^MXC-([A-Za-z0-9_-]{1,7}-)?[a-z2-7]{16}$' <<<"$CHAIN_NAME"; then + fail "chain name '$CHAIN_NAME' does not match the documented MXC-- shape." + fi + if [ "${#CHAIN_NAME}" -gt 28 ]; then + fail "chain name '$CHAIN_NAME' exceeds the 28-character iptables ceiling." + fi +} + +echo "Running LXC deny-precedence enforcement test..." + +echo "--- control: destination allowed, nothing blocked ---" +MXC_CHAINS_BEFORE_V4="$(mxc_chains iptables)" +MXC_CHAINS_BEFORE_V6="$(mxc_chains ip6tables)" +CONTROL_OUTPUT=$("$LXC_EXEC" --debug "$CONTROL_CONFIG" 2>&1 || true) +echo "$CONTROL_OUTPUT" + +if ! echo "$CONTROL_OUTPUT" | grep -Fq "MXC_NET_ALLOWED"; then + fail "the control destination was unreachable with an allow-everything policy, so this host cannot distinguish a deny-precedence failure from a broken network." +fi + +derive_chain_name "$CONTROL_OUTPUT" +assert_no_forward_reference "$CHAIN_NAME" +assert_firewall_chain_cleaned_up "$CHAIN_NAME" + +echo "--- overlap: same destination in both allowedHosts and blockedHosts ---" +MXC_CHAINS_BEFORE_V4="$(mxc_chains iptables)" +MXC_CHAINS_BEFORE_V6="$(mxc_chains ip6tables)" +OVERLAP_OUTPUT=$("$LXC_EXEC" --debug "$OVERLAP_CONFIG" 2>&1 || true) +echo "$OVERLAP_OUTPUT" + +if echo "$OVERLAP_OUTPUT" | grep -Fq "MXC_NET_ALLOWED"; then + fail "a destination present in BOTH allowedHosts and blockedHosts was reachable. Allow rules are winning over deny rules, so a blocklist entry can be silently defeated by an overlapping allowlist entry." +fi +if ! echo "$OVERLAP_OUTPUT" | grep -Fq "MXC_NET_BLOCKED"; then + fail "the overlap case produced no verdict at all; the container command did not run." +fi + +derive_chain_name "$OVERLAP_OUTPUT" +assert_no_forward_reference "$CHAIN_NAME" +assert_firewall_chain_cleaned_up "$CHAIN_NAME" + +echo "PASS: a destination in both lists was blocked, and the same destination was reachable when only allowed." +echo "LXC deny-precedence enforcement test complete." diff --git a/tests/scripts/run_lxc_network_enforcement_test.sh b/tests/scripts/run_lxc_network_enforcement_test.sh new file mode 100644 index 000000000..689836c4f --- /dev/null +++ b/tests/scripts/run_lxc_network_enforcement_test.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# LXC network policy enforcement test +# +# Every other network script asserts that the FORWARD hook was *installed*. +# That is a log line, and a hook can install cleanly, name the right chain, +# and still match no packet -- which is exactly how a fully populated deny-all +# chain that filtered nothing once passed every script in this directory. +# +# This script asserts the guarantee itself rather than the log: a destination +# the policy does not allow must be unreachable from inside the container. +# +# Both directions are required, and the allow case is not decoration. A +# blocked-only assertion would also pass on a host with no working network at +# all, or on a change that broke egress outright, so it proves nothing on its +# own. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" + +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. A suite that could not run must not look green. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for iptables/ip6tables and LXC." +command -v iptables >/dev/null 2>&1 || skip "iptables is not installed." +command -v ip6tables >/dev/null 2>&1 || skip "ip6tables is not installed." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +DENY_CONFIG="$REPO_DIR/tests/configs/lxc_network_enforcement_deny.json" +ALLOW_CONFIG="$REPO_DIR/tests/configs/lxc_network_enforcement_allow.json" + +fail() { + echo "FAIL: $1" + exit 1 +} + +# List the MXC-owned chains a tool currently holds. The chain name is derived +# from a digest of the container name, so a hard-coded literal names a chain +# that cannot exist: `iptables -S ` always fails, the cleanup check +# below reads that failure as "the chain is gone", and the assertion passes +# without inspecting anything. Matching the MXC- prefix stays correct across +# naming changes. +mxc_chains() { + "$1" -S 2>/dev/null | sed -n 's/^-N \(MXC-.*\)$/\1/p' | sort +} + +# Compared against a snapshot taken before the run, so chains left behind by an +# earlier failed run are not blamed on this one. +assert_no_new_mxc_chains() { + local tool="$1" before="$2" after="" leaked="" chain + # Captured before iterating rather than piped in from a process + # substitution, whose exit status is not the loop's. A failed enumeration + # would otherwise read as zero chains and pass this assertion while + # verifying nothing. + if ! after="$(mxc_chains "$tool")"; then + fail "could not enumerate $tool chains, so cleanup was not verified." + fi + while IFS= read -r chain; do + [ -n "$chain" ] || continue + grep -Fxq "$chain" <<<"$before" || leaked="$leaked $chain" + done <<<"$after" + if [ -n "$leaked" ]; then + fail "$tool chain(s) left behind after lxc-exec completed:$leaked" + fi +} + +# The named chain must be gone, and the run must not have leaked any other +# MXC-owned chain either. The first check is specific to the container this +# case ran; the second catches a rename or a partial rollback that leaves a +# differently named chain behind. +assert_firewall_chain_cleaned_up() { + local chain="$1" + if iptables -S "$chain" >/dev/null 2>&1; then + fail "iptables chain '$chain' was left behind after lxc-exec completed." + fi + if ip6tables -S "$chain" >/dev/null 2>&1; then + fail "ip6tables chain '$chain' was left behind after lxc-exec completed." + fi + assert_no_new_mxc_chains iptables "$MXC_CHAINS_BEFORE_V4" + assert_no_new_mxc_chains ip6tables "$MXC_CHAINS_BEFORE_V6" +} + +# A hook that references the chain but survives teardown leaves the next +# container's traffic running through a stale rule, so the reference count +# matters as much as the chain itself. +assert_no_forward_reference() { + if iptables -S FORWARD 2>/dev/null | grep -Fq -- "$1"; then + fail "a FORWARD rule still references chain '$1' after teardown." + fi +} + +# The chain name is a digest of the container name, so it is read back from this +# run's own debug output rather than hard-coded. Every assertion that names a +# chain depends on this having succeeded, so an unparsed name fails the test +# here instead of silently reducing those assertions to no-ops. +derive_chain_name() { + CHAIN_NAME="$(sed -n 's/^.*Creating iptables\/ip6tables chain: \([^ ]*\).*$/\1/p' <<<"$1" | head -n 1)" + if [ -z "$CHAIN_NAME" ]; then + fail "no chain creation was logged, so the chain name could not be determined." + fi + if ! grep -Eq '^MXC-([A-Za-z0-9_-]{1,7}-)?[a-z2-7]{16}$' <<<"$CHAIN_NAME"; then + fail "chain name '$CHAIN_NAME' does not match the documented MXC-- shape." + fi + if [ "${#CHAIN_NAME}" -gt 28 ]; then + fail "chain name '$CHAIN_NAME' exceeds the 28-character iptables ceiling." + fi +} + +echo "Running LXC network policy enforcement test..." + +# The container reports the outcome itself rather than relying on its exit +# code, so a wrapper that swallows or rewrites the status cannot turn a +# reachable destination into an apparent block. +echo "--- deny case: default policy blocks, nothing allowed ---" +MXC_CHAINS_BEFORE_V4="$(mxc_chains iptables)" +MXC_CHAINS_BEFORE_V6="$(mxc_chains ip6tables)" +DENY_OUTPUT=$("$LXC_EXEC" --debug "$DENY_CONFIG" 2>&1 || true) +echo "$DENY_OUTPUT" + +if echo "$DENY_OUTPUT" | grep -Fq "MXC_NET_ALLOWED"; then + fail "egress succeeded under a default-block policy with no allowed hosts. The chain is not filtering this container's traffic." +fi +if ! echo "$DENY_OUTPUT" | grep -Fq "MXC_NET_BLOCKED"; then + fail "the deny case produced no verdict at all; the container command did not run." +fi + +derive_chain_name "$DENY_OUTPUT" +assert_no_forward_reference "$CHAIN_NAME" +assert_firewall_chain_cleaned_up "$CHAIN_NAME" + +echo "--- allow case: same default, destination explicitly allowed ---" +MXC_CHAINS_BEFORE_V4="$(mxc_chains iptables)" +MXC_CHAINS_BEFORE_V6="$(mxc_chains ip6tables)" +ALLOW_OUTPUT=$("$LXC_EXEC" --debug "$ALLOW_CONFIG" 2>&1 || true) +echo "$ALLOW_OUTPUT" + +if echo "$ALLOW_OUTPUT" | grep -Fq "MXC_NET_BLOCKED"; then + fail "an explicitly allowed destination was unreachable. The policy is over-blocking, so the deny case above proves nothing." +fi +if ! echo "$ALLOW_OUTPUT" | grep -Fq "MXC_NET_ALLOWED"; then + fail "the allow case produced no verdict at all; the container command did not run." +fi + +derive_chain_name "$ALLOW_OUTPUT" +assert_no_forward_reference "$CHAIN_NAME" +assert_firewall_chain_cleaned_up "$CHAIN_NAME" + +echo "PASS: a disallowed destination was blocked and an allowed destination was reachable." +echo "LXC network policy enforcement test complete." \ No newline at end of file diff --git a/tests/scripts/run_lxc_network_proxy_credentials_test.sh b/tests/scripts/run_lxc_network_proxy_credentials_test.sh new file mode 100644 index 000000000..0267123d5 --- /dev/null +++ b/tests/scripts/run_lxc_network_proxy_credentials_test.sh @@ -0,0 +1,157 @@ +#!/bin/bash +# LXC credentialed-proxy rejection test. +# +# Proves, from the outside, that a proxy URL carrying inline credentials is +# refused rather than handed to `lxc-attach`, and that refusing it does not +# itself print the secret. +# +# Cause : tests/configs/lxc_network_proxy_credentials_rejected.json — an +# otherwise valid LXC request whose network.proxy.url embeds +# `alice:hunter2`. +# Effect : lxc-exec exits non-zero, names the credential rule, and neither the +# password nor the username appears anywhere in its output. The +# container's command line is never reached. +# +# Why the secret matters more than the exit code: LXC passes the proxy URL to +# `lxc-attach` as `--set-var`, and process arguments are world-readable through +# /proc//cmdline. A rejection that echoed the URL back verbatim would leak +# the same secret it just refused to accept, so the redaction is asserted as +# its own observable. +# +# Unlike the other LXC network tests, this one needs no root, no LXC, no +# bridge, and no network: the rule is enforced while the configuration is +# parsed, before any container is created. Only the built binary is required, +# so this runs on any Linux host and is skipped only when the binary is absent. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG="$REPO_DIR/tests/configs/lxc_network_proxy_credentials_rejected.json" + +# Drift guard: these mirror the fixture. A fixture edited to drop the +# credentials would make every assertion below vacuous -- the run would exit +# non-zero for some unrelated reason, or succeed -- so the fixture is checked +# against them first. +# +# Do not run this script under `set -x`. Tracing prints every expansion, +# including these two values and the captured output, which defeats the +# withholding below. The workflow does not enable it. +EXPECTED_USERNAME="alice" +EXPECTED_PASSWORD="hunter2" +EXPECTED_PROXY_URL="http://alice:hunter2@10.0.3.1:3128" + +fail() { + echo "FAIL: $*" + exit 1 +} + +# --------------------------------------------------------------------------- +# Always-run assertions: the fixture must exist and still carry the credentials +# this test is about. +# --------------------------------------------------------------------------- +[ -f "$CONFIG" ] || fail "fixture not found: $CONFIG" + +read_json_field() { + # $1 = dotted path under the JSON root (python) ; prints the value. + local path="$1" + if command -v python3 >/dev/null 2>&1; then + python3 - "$CONFIG" "$path" <<'PY' +import json, sys +doc = json.load(open(sys.argv[1])) +cur = doc +for key in sys.argv[2].split("."): + cur = cur[key] +print(cur) +PY + else + # Fallback for hosts without python3: grep the leaf key. Works because + # the fixture keeps these on one line with simple string values. + local leaf="${path##*.}" + grep -o "\"$leaf\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$CONFIG" \ + | head -1 | sed 's/.*:[[:space:]]*"\([^"]*\)".*/\1/' + fi +} + +actual_url="$(read_json_field network.proxy.url)" +if [ "$actual_url" != "$EXPECTED_PROXY_URL" ]; then + # Naming either URL here would publish the password on exactly the failure + # that says this fixture can no longer be trusted, so the mismatch is + # described rather than quoted. + if echo "$actual_url" | grep -qF "@"; then + drift_detail="it still carries userinfo, but not the pair this test asserts" + else + drift_detail="it carries no userinfo at all, so every assertion below would be vacuous" + fi + fail "fixture proxy.url changed; both values withheld because they carry credentials -- $drift_detail" +fi +echo "Fixture drift guard passed (proxy.url carries inline credentials)." + +# --------------------------------------------------------------------------- +# Conditional assertion: the live rejection. Only the binary is a prerequisite. +# --------------------------------------------------------------------------- +SKIP_EXIT=77 + +skip_live() { + echo "SKIP: credentialed-proxy rejection UNVERIFIED — $*" + echo " (fixture drift guard still ran and passed)" + exit "$SKIP_EXIT" +} + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +[ -f "$LXC_EXEC" ] || LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +[ -f "$LXC_EXEC" ] || skip_live "lxc-exec not built (run build.sh first)" + +# Which binary, and how old. `release` is preferred, so a stale one left over +# from before this rule existed is picked ahead of a freshly built `debug` -- +# and it fails exactly as a genuine regression would, because a binary without +# the rule really does accept the URL. Naming the artifact turns that hour of +# hunting a phantom regression into one line. +echo "Using $LXC_EXEC (built $(date -r "$LXC_EXEC" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo 'unknown'))." + +OUT="$("$LXC_EXEC" "$CONFIG" 2>&1)" +STATUS=$? + +# Publishing the capture helps diagnose every failure except the one this test +# exists to catch. On that one the capture *is* the secret, so echoing it first +# would publish the password to the CI log before the assertion below could +# fail the run -- the test would leak what it is guarding. Withhold it in that +# case; the assertions still name what went wrong. +if echo "$OUT" | grep -qF "$EXPECTED_PASSWORD" || echo "$OUT" | grep -qF "$EXPECTED_USERNAME"; then + echo "--- lxc-exec output WITHHELD: it contains a credential ---" +else + echo "--- lxc-exec output ---" + echo "$OUT" + echo "-----------------------" +fi + +# The request must be refused. A zero exit means the credentialed URL was +# accepted, which is the whole defect. +[ "$STATUS" -ne 0 ] \ + || fail "lxc-exec accepted a proxy URL carrying credentials (exit 0). If this is + unexpected, check that $LXC_EXEC is current -- a binary built before this + rule existed fails here in exactly the same way as a regression." + +# Refused for the right reason, not by coincidence. Without this, a fixture +# broken in some unrelated way would still pass the exit-code check. +echo "$OUT" | grep -qi "must not carry credentials" \ + || fail "rejected, but not for carrying credentials" + +# The rejection must not become the leak it is rejecting. +if echo "$OUT" | grep -qF "$EXPECTED_PASSWORD"; then + fail "the proxy password appeared in lxc-exec output" +fi +if echo "$OUT" | grep -qF "$EXPECTED_USERNAME"; then + fail "the proxy username appeared in lxc-exec output" +fi + +# The redacted host must survive, or the message names no URL at all and gives +# the operator nothing to act on. +echo "$OUT" | grep -qF "10.0.3.1:3128" \ + || fail "the rejection redacted the host as well as the credentials" + +# The process must never have started. +if echo "$OUT" | grep -qF "THIS_MUST_NEVER_RUN"; then + fail "the container command ran despite the rejected proxy URL" +fi + +echo "PASS: credentialed proxy URL refused, secret not echoed, command never ran." diff --git a/tests/scripts/run_lxc_network_proxy_test.sh b/tests/scripts/run_lxc_network_proxy_test.sh new file mode 100644 index 000000000..d4f42fbcb --- /dev/null +++ b/tests/scripts/run_lxc_network_proxy_test.sh @@ -0,0 +1,228 @@ +#!/bin/bash +# LXC deny-all-except-proxy (model 2) integration test. +# +# Proves, from the outside, the whole point of the model: with a network +# proxy configured under defaultPolicy=block/enforcementMode=firewall, the +# container reaches the world *only* through the proxy, and every direct path +# is dropped. +# +# Cause : tests/configs/lxc_network_proxy.json — an LXC request whose only +# allowed egress is the proxy at http://10.0.3.1:3128 (the default +# lxcbr0 gateway, i.e. the host as seen from the container). +# Effect : the container's stdout carries one sentinel per observable: +# PROXY_OK proxy fetch succeeded +# DIRECT_IPV4_BLOCKED direct IPv4 (proxy bypassed) was dropped +# DIRECT_IPV6_BLOCKED direct IPv6 was dropped +# DIRECT_IPV6_SKIP_NO_STACK container has no global IPv6 (honest skip) +# FORWARDED_DNS_BLOCKED DNS to an off-bridge resolver was dropped +# GATEWAY_DNS_* DNS to the bridge gateway's own resolver +# The *_LEAK counterparts mean the isolation failed. +# +# Scope of the DNS assertions, measured rather than assumed. The chain is +# hooked into FORWARD, so it sees traffic the host *routes* for the container. +# Traffic addressed to the bridge gateway itself — 10.0.3.1, where LXC's +# dnsmasq listens — is delivered locally and traverses INPUT, never FORWARD. +# Counting rules installed in both chains during a live run recorded 2 packets +# on the INPUT probe and 0 on the FORWARD probe for container DNS. So +# GATEWAY_DNS is reported, not asserted: closing it needs an INPUT hook, which +# is a separate work item. FORWARDED_DNS is what this chain does govern, and it +# is asserted. +# +# The same measurement applies to PROXY_OK when the proxy runs on the host, as +# it does here: the proxy ACCEPT rule is not what admits that traffic, because +# the packet never reaches the chain (6 packets on the INPUT probe, 0 in +# FORWARD). PROXY_OK proves the env-var injection is right and that the +# deny-all posture did not break the proxy path; it does not exercise the +# ACCEPT rule. That rule is exercised by the unit specs in +# network_iptables_proxy_spec.rs, and in production by an off-host proxy. +# +# It does not exercise the hosts pin either. This fixture names the proxy by IP +# literal (10.0.3.1), and `ProxyAddress::host_pin` returns no pin for a literal +# because there is no name to resolve, so no hosts entry is written on this +# path at all. The pin is covered by tests/proxy_address_spec.rs; a fixture +# naming the proxy by hostname would be needed to exercise it here. +# +# The proxy is locally controlled: a tiny forward proxy started by this script +# on the host bridge IP, so the positive path needs no external internet and +# the negative paths target fixed public IPs that never resolve in-container. +# +# Requires Linux, root, LXC, and python3. It cannot run on the Windows dev box, +# so it is exercised by the LXC E2E Tests workflow (.github/workflows/lxc-e2e.yml), +# which runs the suite on ubuntu-latest with MXC_LXC_TESTS_REQUIRE_EXECUTION=1 +# so a missing prerequisite fails the gate instead of skipping quietly. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONFIG="$REPO_DIR/tests/configs/lxc_network_proxy.json" + +# Drift guard: these mirror the fixture. If someone edits one without the +# other, the always-run block below fails loudly rather than testing a stale +# assumption. +EXPECTED_PROXY_URL="http://10.0.3.1:3128" +EXPECTED_DEFAULT_POLICY="block" +PROXY_BIND_IP="10.0.3.1" +PROXY_PORT="3128" + +fail() { + echo "FAIL: $*" + exit 1 +} + +# --------------------------------------------------------------------------- +# Always-run assertions (offline-safe): the fixture must exist, parse, and +# still say what this test assumes. These run even without root/LXC/python3 so +# the file is never wholly conditional. +# --------------------------------------------------------------------------- +[ -f "$CONFIG" ] || fail "fixture not found: $CONFIG" + +read_json_field() { + # $1 = dotted path under the JSON root (python) ; prints the value. + local path="$1" + if command -v python3 >/dev/null 2>&1; then + python3 - "$CONFIG" "$path" <<'PY' +import json, sys +doc = json.load(open(sys.argv[1])) +cur = doc +for key in sys.argv[2].split("."): + cur = cur[key] +print(cur) +PY + else + # Fallback for hosts without python3: grep the leaf key. Works because + # the fixture keeps these on one line with simple string values. + local leaf="${path##*.}" + grep -o "\"$leaf\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$CONFIG" \ + | head -1 | sed 's/.*:[[:space:]]*"\([^"]*\)".*/\1/' + fi +} + +actual_url="$(read_json_field network.proxy.url)" +actual_policy="$(read_json_field network.defaultPolicy)" +[ "$actual_url" = "$EXPECTED_PROXY_URL" ] \ + || fail "fixture proxy.url is '$actual_url', test expects '$EXPECTED_PROXY_URL'" +[ "$actual_policy" = "$EXPECTED_DEFAULT_POLICY" ] \ + || fail "fixture defaultPolicy is '$actual_policy', test expects '$EXPECTED_DEFAULT_POLICY'" +echo "Fixture drift guard passed (proxy.url=$actual_url, defaultPolicy=$actual_policy)." + +# --------------------------------------------------------------------------- +# Conditional assertions: the live container run. Skip with exit 77 when a +# prerequisite is missing, matching run_bwrap_network_firewall_test.sh, so a +# skip is never tallied as a pass. run_lxc_all_tests.sh classifies 77 as a +# skip and reports it separately, and fails the suite outright when every +# test skipped, so a run that verified nothing cannot look green. +# --------------------------------------------------------------------------- +SKIP_EXIT=77 + +skip_live() { + echo "SKIP: LXC deny-all-except-proxy behaviour UNVERIFIED — $*" + echo " (fixture drift guard still ran and passed)" + exit "$SKIP_EXIT" +} + +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" +[ -f "$LXC_EXEC" ] || LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +[ -f "$LXC_EXEC" ] || skip_live "lxc-exec not built (run build.sh first)" + +[ "$(id -u)" -eq 0 ] || skip_live "not root; LXC needs root for containers and iptables" +command -v python3 >/dev/null 2>&1 || skip_live "python3 not available to run the local proxy" + +# The fixture points the proxy at the default lxcbr0 gateway. Verify that IP is +# actually a local address before binding to it; otherwise the container could +# not reach the proxy and the test would be meaningless. +if ! ip -4 addr show 2>/dev/null | grep -qw "$PROXY_BIND_IP"; then + skip_live "$PROXY_BIND_IP is not a local address (non-default lxc bridge?); \ +cannot host the proxy where the container expects it" +fi + +# --------------------------------------------------------------------------- +# Start the locally controlled forward proxy on the bridge IP. It answers any +# request with the sentinel body, so the positive path needs no real internet. +# --------------------------------------------------------------------------- +PROXY_PID="" +cleanup() { + [ -n "$PROXY_PID" ] && kill "$PROXY_PID" >/dev/null 2>&1 +} +trap cleanup EXIT + +python3 - "$PROXY_BIND_IP" "$PROXY_PORT" >/dev/null 2>&1 <<'PY' & +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +class Proxy(BaseHTTPRequestHandler): + def do_GET(self): + body = b"MXC_PROXY_SENTINEL\n" + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_): + pass + +HTTPServer((sys.argv[1], int(sys.argv[2])), Proxy).serve_forever() +PY +PROXY_PID=$! + +# Give the proxy a moment to bind, then confirm it is actually listening. +sleep 1 +if ! kill -0 "$PROXY_PID" >/dev/null 2>&1; then + fail "local proxy failed to start on $PROXY_BIND_IP:$PROXY_PORT" +fi + +# --------------------------------------------------------------------------- +# Run the sandbox and capture its stdout. +# --------------------------------------------------------------------------- +echo "Running LXC network proxy test..." +if ! OUT=$("$LXC_EXEC" "$CONFIG" 2>&1); then + echo "$OUT" + fail "lxc-exec returned non-zero" +fi +echo "$OUT" + +# --------------------------------------------------------------------------- +# Assert cause and effect. Each sentinel is part of the contract this test +# declares; the container fixture prints exactly these strings. +# --------------------------------------------------------------------------- +require_sentinel() { + grep -q "$1" <<<"$OUT" || fail "expected sentinel '$1' not in container output" +} +reject_sentinel() { + grep -q "$1" <<<"$OUT" && fail "isolation breach: saw '$1' in container output" + return 0 +} + +require_sentinel "PROXY_OK" +reject_sentinel "PROXY_FAIL" + +require_sentinel "DIRECT_IPV4_BLOCKED" +reject_sentinel "DIRECT_IPV4_LEAK" + +# DNS to a resolver off the bridge is forwarded traffic, so the chain governs +# it and the deny-all posture must drop it. +require_sentinel "FORWARDED_DNS_BLOCKED" +reject_sentinel "FORWARDED_DNS_LEAK" + +# DNS to the bridge gateway's own resolver is delivered locally and traverses +# INPUT, which this chain does not hook. Report the verdict rather than +# asserting it, so the gap is visible in the output instead of being either a +# false pass or a failure of something this work item does not cover. +if grep -q "GATEWAY_DNS_REACHED" <<<"$OUT"; then + echo "NOTE: DNS to the bridge gateway resolver is still reachable — it is an" + echo " INPUT path, and this chain hooks FORWARD only. Tracked separately." +elif ! grep -q "GATEWAY_DNS_BLOCKED" <<<"$OUT"; then + fail "no gateway-DNS verdict in container output" +fi + +# IPv6 is honestly conditional: a container with no global IPv6 cannot exercise +# the drop, so it reports a skip marker rather than a false pass. +reject_sentinel "DIRECT_IPV6_LEAK" +if grep -q "DIRECT_IPV6_SKIP_NO_STACK" <<<"$OUT"; then + echo "SKIP: direct-IPv6 drop UNVERIFIED — container has no global IPv6 stack" +elif ! grep -q "DIRECT_IPV6_BLOCKED" <<<"$OUT"; then + fail "no IPv6 verdict in container output (expected DIRECT_IPV6_BLOCKED or the skip marker)" +fi + +echo "PASS: LXC deny-all-except-proxy — proxy reachable, forwarded IPv4/IPv6/DNS blocked." +echo "LXC network proxy test complete."