From d0705a073a79473a604662a53941a4cf79a4e08e Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 15:58:08 -0700 Subject: [PATCH 01/55] [LXC] Scrub and apply proxy env vars through the shared helper LXC did not scrub proxy environment variables from caller-supplied env, so a caller could point a sandboxed process at an egress path the network policy never authorized, or disable the cooperative proxy outright. Add `apply_proxy_env` to `wxc_common::proxy_env`, 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. With the proxy disabled the vars are still stripped. It returns `true` unconditionally, including for an empty env: 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. Add `FTP_PROXY`/`ftp_proxy` to `PROXY_ENV_KEYS`. Both spellings of every family are now present, and the doc comment records why the lower-case duplicates are kept. Tests are black-box integration tests in `tests/proxy_env_spec.rs`, written against the public API by an author who did not see the implementation. All 22 pass; 7 of 7 seeded mutants are caught with no survivors. This is slice 1 of the work previously attempted in PR 632, re-cut from main so each slice is reviewable on its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/proxy_env.rs | 38 ++ src/core/wxc_common/tests/proxy_env_spec.rs | 425 ++++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 src/core/wxc_common/tests/proxy_env_spec.rs diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index 484a1a698..22ca02cce 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -29,15 +29,25 @@ //! Functions here operate on `"KEY=VALUE"` strings, so they are //! platform-agnostic and unit-testable on every host. +use crate::models::ProxyConfig; + /// 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", ]; @@ -124,6 +134,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_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs new file mode 100644 index 000000000..39c494daa --- /dev/null +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -0,0 +1,425 @@ +//! 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 -- calls `apply_proxy_env`, uses the returned bool to +//! decide whether to pass `--clear-env` to `lxc-attach`. +//! (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 wxc_common::models::{ProxyAddress, ProxyConfig}; +use wxc_common::proxy_env::{ + apply_cooperative_proxy_env, apply_proxy_env, is_managed_proxy_key, 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); +} From b74f5bf88ececb1e49d2a381e2530252074b7ec0 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 16:07:49 -0700 Subject: [PATCH 02/55] Correct the LXC client note: the integration is planned, not wired The test module header described client (a) in the present tense, which read as though the LXC backend already calls `apply_proxy_env`. It does not: the helper has no call site yet, and `attach_run` still derives `--clear-env` solely from `env` being non-empty (`lxc_bindings.rs:90`). Record the divergence while it is cheap to see. `apply_proxy_env` returns `true` even for an empty env so the MXC host environment cannot leak into the container, whereas current code emits no `--clear-env` in that case and pins the behavior with a test at `lxc_bindings.rs:743`. The integration slice has to update both. Comment only. No assertion changed; the tests validate the helper contract, which is what they are for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/tests/proxy_env_spec.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index 39c494daa..57404d01e 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -5,8 +5,16 @@ //! behavior it protects, in the sense of Khorikov's "observable behavior is //! relative to a named client and its goals": //! -//! (a) LXC backend -- calls `apply_proxy_env`, uses the returned bool to -//! decide whether to pass `--clear-env` to `lxc-attach`. +//! (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 From 40514a65dafd7a351222e9af3cc4310d8e30e5e0 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 17:04:18 -0700 Subject: [PATCH 03/55] [LXC] Pin the proxy hostname instead of rewriting the URL host Model 2 needs the sandbox and the firewall to agree on exactly one proxy endpoint. Otherwise the sandbox re-resolves the hostname itself and, under round-robin or split-horizon DNS, reaches an address the firewall never authorized. PR 632 solved this by rewriting the proxy URL's host to the resolved IP. Review rejected that (comment 3724788051): an `https://`-scheme proxy would then be contacted at an IP literal, so SNI and certificate validation fail unless the proxy certificate carries an IP SAN. Add `ProxyHostPin` and `ProxyAddress::host_pin` instead. These express the mapping as a hosts-file pin, so the hostname stays in the URL and TLS identity is preserved while the endpoint is still forced. `host_pin` returns `None` when the address is already an IP literal, because there is then nothing to resolve. `hosts_line` writes the address bare: a hosts file takes an unbracketed IPv6 literal, unlike a URL host component. Also fix `to_url`. It hardcoded `127.0.0.1` whenever no original URL was recorded, regardless of the actual address. That is reachable: `unix_proxy_coordinator.rs:234` builds a `ProxyAddress` from the configured bind address with no original URL, so a proxy bound to a non-loopback address reported an endpoint it was not listening on -- the same class of defect as the objection above. Every existing caller passes `127.0.0.1`, so their output is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/models.rs | 87 ++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 329c4bb7d..cb2942bb0 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -315,6 +315,39 @@ 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. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyHostPin { + /// The proxy hostname as it appears in the URL handed to the sandbox. + pub hostname: String, + /// The address the host resolved that hostname to, and the only address + /// the firewall authorizes for it. + pub ip: String, +} + +impl ProxyHostPin { + /// 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, so this must not reuse the + /// bracketing applied by [`ProxyAddress::to_url`]. + pub fn hosts_line(&self) -> String { + format!("{} {}", self.ip, self.hostname) + } +} + #[derive(Debug, Clone)] pub struct ProxyAddress { pub address: String, @@ -350,12 +383,62 @@ 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`, or `None` when no pin is needed or possible. + /// + /// `None` is returned when the address is already an IP literal, since + /// there is nothing to resolve, and when it is empty. Callers treat `None` + /// as "no hosts entry required", not as an error. + /// + /// 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: &str) -> Option { + let hostname = Self::unbracket(&self.address); + if hostname.is_empty() || hostname.parse::().is_ok() { + return None; + } + + Some(ProxyHostPin { + hostname: hostname.to_string(), + ip: Self::unbracket(ip).to_string(), + }) + } + + /// Wraps `host` in `[` `]` when it is a bare IPv6 literal, so it is valid + /// as a URL host component. Any other host, including one that is already + /// bracketed, is returned unchanged. + fn bracket_if_ipv6(host: &str) -> std::borrow::Cow<'_, str> { + if host.starts_with('[') { + return std::borrow::Cow::Borrowed(host); + } + match host.parse::() { + Ok(std::net::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) } } From 764900b4b2eaba4de70baad31875a6615862099b Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 17:20:15 -0700 Subject: [PATCH 04/55] Drop a dead bracket guard and document why unbracketing is load-bearing Mutation testing surfaced an equivalent mutant: deleting the `starts_with('[')` early return from `bracket_if_ipv6` changed no observable behavior. Verified why, rather than assuming the tests were weak -- `IpAddr::from_str` rejects brackets, so `[::1]` already fell through the catch-all arm unchanged and could never be bracketed twice. The guard was dead code. Remove it and record the reason. The mirror case is NOT dead, and mutation proves it: replacing `Self::unbracket(&self.address)` in `host_pin` with the raw field fails a test. Unbracketing there is what lets a bracketed IPv6 literal be classified as a literal instead of pinned as though it were a hostname. Say so in the doc comment, which previously described it as mere normalization. Comment and dead-code only. All 566 library tests and 19 spec tests pass unchanged, and the seeded-mutant suite now runs 9 for 9 with no survivors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/models.rs | 22 +- .../wxc_common/tests/proxy_address_spec.rs | 303 ++++++++++++++++++ 2 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 src/core/wxc_common/tests/proxy_address_spec.rs diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index cb2942bb0..4e411152c 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -403,10 +403,18 @@ impl ProxyAddress { /// Returns the pin required for a sandbox to resolve this proxy's hostname /// to `ip`, or `None` when no pin is needed or possible. /// - /// `None` is returned when the address is already an IP literal, since - /// there is nothing to resolve, and when it is empty. Callers treat `None` + /// `None` is returned when the address is empty, and when it is an IP + /// literal, since there is then nothing to resolve. Callers treat `None` /// as "no hosts entry required", not as an error. /// + /// The address is unbracketed before that classification 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. + /// + /// `ip` is recorded bare for the same reason [`ProxyHostPin::hosts_line`] + /// writes it bare -- a hosts file takes an unbracketed IPv6 literal. + /// /// 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: &str) -> Option { @@ -422,12 +430,12 @@ impl ProxyAddress { } /// Wraps `host` in `[` `]` when it is a bare IPv6 literal, so it is valid - /// as a URL host component. Any other host, including one that is already - /// bracketed, is returned unchanged. + /// 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> { - if host.starts_with('[') { - return std::borrow::Cow::Borrowed(host); - } match host.parse::() { Ok(std::net::IpAddr::V6(_)) => std::borrow::Cow::Owned(format!("[{host}]")), _ => std::borrow::Cow::Borrowed(host), 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..4b6a64bca --- /dev/null +++ b/src/core/wxc_common/tests/proxy_address_spec.rs @@ -0,0 +1,303 @@ +// 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`) has no callers +//! yet. It is planned wiring for the firewall / hosts-file consumer, so the +//! tests below name that consumer as planned, not present. +//! +//! 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 a pin for a hostname and `None` for every IP literal +//! and for the empty address, stripping brackets from the supplied IP. +//! 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 wxc_common::models::{ProxyAddress, ProxyHostPin}; + +// 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 a mapping carrying the +// hostname and the supplied IP unchanged when neither is bracketed. +#[test] +fn host_pin_returns_pin_for_hostname() { + let addr = ProxyAddress::new("proxy.example.com".to_string(), 8080); + + assert_eq!( + addr.host_pin("10.0.0.5"), + Some(ProxyHostPin { + hostname: "proxy.example.com".to_string(), + ip: "10.0.0.5".to_string(), + }) + ); +} + +// Protects the planned firewall / hosts-file consumer. When the resolved IP is +// supplied in bracketed IPv6 form, the pin must strip the brackets, because a +// hosts file takes a bare address. +#[test] +fn host_pin_strips_brackets_from_ipv6_ip_argument() { + let addr = ProxyAddress::new("proxy.example.com".to_string(), 8080); + + assert_eq!( + addr.host_pin("[2001:db8::1]"), + Some(ProxyHostPin { + hostname: "proxy.example.com".to_string(), + ip: "2001:db8::1".to_string(), + }) + ); +} + +// Protects the planned firewall / hosts-file consumer. An IPv4 literal is +// already an endpoint, so there is nothing to resolve and no hosts entry is +// required; `None` means "no entry", not an error. +#[test] +fn host_pin_returns_none_for_ipv4_literal() { + let addr = ProxyAddress::new("127.0.0.1".to_string(), 8080); + + assert_eq!(addr.host_pin("10.0.0.5"), None); +} + +// 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_none_for_bare_ipv6_literal() { + let addr = ProxyAddress::new("2001:db8::1".to_string(), 8080); + + assert_eq!(addr.host_pin("10.0.0.5"), None); +} + +// Protects the planned firewall / hosts-file consumer. A bracketed IPv6 literal +// is still an IP literal and must be treated the same as the bare form. +#[test] +fn host_pin_returns_none_for_bracketed_ipv6_literal() { + let addr = ProxyAddress::new("[2001:db8::1]".to_string(), 8080); + + assert_eq!(addr.host_pin("10.0.0.5"), None); +} + +// Protects the planned firewall / hosts-file consumer. An empty address names +// nothing to resolve, so no hosts entry is required. +#[test] +fn host_pin_returns_none_for_empty_address() { + let addr = ProxyAddress::new(String::new(), 8080); + + assert_eq!(addr.host_pin("10.0.0.5"), None); +} + +// 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 before = addr.to_url(); + let pin = addr.host_pin("10.0.0.5"); + + assert!(pin.is_some(), "a hostname address should yield a pin"); + 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. +#[test] +fn hosts_line_writes_ip_then_hostname() { + let pin = ProxyHostPin { + hostname: "proxy.example.com".to_string(), + ip: "10.0.0.5".to_string(), + }; + + 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 pin = ProxyHostPin { + hostname: "proxy.example.com".to_string(), + ip: "2001:db8::1".to_string(), + }; + + assert_eq!(pin.hosts_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 url = ProxyAddress::new("2001:db8::1".to_string(), 8080).to_url(); + let line = ProxyHostPin { + hostname: "proxy.example.com".to_string(), + ip: "2001:db8::1".to_string(), + } + .hosts_line(); + + assert_eq!(url, "http://[2001:db8::1]:8080"); + assert_eq!(line, "2001:db8::1 proxy.example.com"); +} From bf37b126c5d3c1a3d2a3ec1b597f81d1571ba643 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 17:31:20 -0700 Subject: [PATCH 05/55] [LXC] Make an unpinnable proxy address unrepresentable Review on PR 789 pointed out that ProxyHostPin's fields were public Strings, so a caller could set ip to "[::1]", to the empty string, or to text containing a newline, and hosts_line() would emit it verbatim. That is an injection into /etc/hosts: a newline ends the record and starts a second, unauthorized mapping. The type exists to guarantee the sandbox and the firewall agree on one endpoint, so a value that denotes two mappings defeats its whole purpose. The fields are now private and the address is an IpAddr, so no such value can be constructed. IpAddr also renders IPv6 bare, which is what a hosts file requires -- the difference from to_url, which brackets, is now structural instead of a convention a caller has to remember. host_pin returns Result, WxcError>. Ok(None) keeps its single meaning: the address is an IP literal, so there is nothing to resolve. An empty or malformed hostname is now Err, not None. Folding it into None would have told the caller "no hosts entry required", so a malformed address would silently skip the pin and let the sandbox re-resolve the name -- failing open, which is the defect review objected to elsewhere in this work. Tests are updated in a separate commit by the author who did not write this implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/models.rs | 100 ++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 4e411152c..02288b45c 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)] @@ -328,21 +331,38 @@ impl From for NetworkEnforcementMode { /// 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 { - /// The proxy hostname as it appears in the URL handed to the sandbox. - pub hostname: String, - /// The address the host resolved that hostname to, and the only address - /// the firewall authorizes for it. - pub ip: String, + 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, so this must not reuse the - /// bracketing applied by [`ProxyAddress::to_url`]. + /// 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) } @@ -401,32 +421,62 @@ impl ProxyAddress { } /// Returns the pin required for a sandbox to resolve this proxy's hostname - /// to `ip`, or `None` when no pin is needed or possible. + /// to `ip`. /// - /// `None` is returned when the address is empty, and when it is an IP - /// literal, since there is then nothing to resolve. Callers treat `None` - /// as "no hosts entry required", not as an error. + /// `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. /// - /// The address is unbracketed before that classification 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. + /// `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. /// - /// `ip` is recorded bare for the same reason [`ProxyHostPin::hosts_line`] - /// writes it bare -- a hosts file takes an unbracketed IPv6 literal. + /// 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: &str) -> Option { + pub fn host_pin(&self, ip: IpAddr) -> Result, WxcError> { let hostname = Self::unbracket(&self.address); - if hostname.is_empty() || hostname.parse::().is_ok() { - return None; + + // 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 + ))); } - Some(ProxyHostPin { + Ok(Some(ProxyHostPin { hostname: hostname.to_string(), - ip: Self::unbracket(ip).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 @@ -436,8 +486,8 @@ impl ProxyAddress { /// 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(std::net::IpAddr::V6(_)) => std::borrow::Cow::Owned(format!("[{host}]")), + match host.parse::() { + Ok(IpAddr::V6(_)) => std::borrow::Cow::Owned(format!("[{host}]")), _ => std::borrow::Cow::Borrowed(host), } } From 445ea2e7dfe482aad02c9d4c831dce2dcda3d206 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 17:47:17 -0700 Subject: [PATCH 06/55] [LXC] Update proxy address spec for the unpinnable-address fix 22 black-box tests against the new host_pin contract, written by an author who has not read models.rs. The empty address moved from Ok(None) to Err, so the test that covered it was rewritten to match on all three arms by name. Asserting is_err() || is_none() would have passed either way, and the whole point of the change is that those two answers are not interchangeable: Ok(None) tells the caller no hosts entry is needed, which is how a malformed address ends up unpinned and the firewall bypassed. Added coverage for the injection strings review called out -- a hostname carrying a newline or a space must be Err and must never reach hosts_line. Dropped the test that stripped brackets from the ip argument; ip is an IpAddr now, so there is no textual form to strip and the behavior no longer exists. Mutation harness: 11 mutants, 11 caught by a failing test, 0 survivors. Four of them removed the last call to a private helper, which the crate's deny-warnings turns into a build failure -- real detection, but by the compiler, which proves nothing about the tests. The harness now suppresses those lints for the mutated build so the suite has to answer for itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../wxc_common/tests/proxy_address_spec.rs | 234 +++++++++++++----- 1 file changed, 170 insertions(+), 64 deletions(-) diff --git a/src/core/wxc_common/tests/proxy_address_spec.rs b/src/core/wxc_common/tests/proxy_address_spec.rs index 4b6a64bca..b5e40fe41 100644 --- a/src/core/wxc_common/tests/proxy_address_spec.rs +++ b/src/core/wxc_common/tests/proxy_address_spec.rs @@ -30,9 +30,24 @@ //! 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`) has no callers -//! yet. It is planned wiring for the firewall / hosts-file consumer, so the -//! tests below name that consumer as planned, not present. +//! * 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): @@ -43,14 +58,31 @@ //! 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 a pin for a hostname and `None` for every IP literal -//! and for the empty address, stripping brackets from the supplied IP. +//! 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. @@ -172,72 +204,141 @@ fn from_url_records_original_url_and_new_does_not() { } // Protects the planned firewall / hosts-file consumer. A hostname address -// requires resolution, so `host_pin` must return a mapping carrying the -// hostname and the supplied IP unchanged when neither is bracketed. +// 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 addr = ProxyAddress::new("proxy.example.com".to_string(), 8080); + let ip: IpAddr = "10.0.0.5".parse().unwrap(); - assert_eq!( - addr.host_pin("10.0.0.5"), - Some(ProxyHostPin { - hostname: "proxy.example.com".to_string(), - ip: "10.0.0.5".to_string(), - }) - ); + 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. When the resolved IP is -// supplied in bracketed IPv6 form, the pin must strip the brackets, because a -// hosts file takes a bare address. +// 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_strips_brackets_from_ipv6_ip_argument() { - let addr = ProxyAddress::new("proxy.example.com".to_string(), 8080); +fn host_pin_accepts_hostname_with_hyphen() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); - assert_eq!( - addr.host_pin("[2001:db8::1]"), - Some(ProxyHostPin { - hostname: "proxy.example.com".to_string(), - ip: "2001:db8::1".to_string(), - }) - ); + let pin = pin_for("my-proxy.example.com", ip); + + assert_eq!(pin.hostname(), "my-proxy.example.com"); } -// Protects the planned firewall / hosts-file consumer. An IPv4 literal is -// already an endpoint, so there is nothing to resolve and no hosts entry is -// required; `None` means "no entry", not an error. +// 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_returns_none_for_ipv4_literal() { - let addr = ProxyAddress::new("127.0.0.1".to_string(), 8080); +fn host_pin_ip_accessor_returns_typed_ipv6_address() { + let ip: IpAddr = "2001:db8::1".parse().unwrap(); - assert_eq!(addr.host_pin("10.0.0.5"), None); + 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_none_for_bare_ipv6_literal() { - let addr = ProxyAddress::new("2001:db8::1".to_string(), 8080); +fn host_pin_returns_ok_none_for_bare_ipv6_literal() { + let ip: IpAddr = "10.0.0.5".parse().unwrap(); - assert_eq!(addr.host_pin("10.0.0.5"), None); + 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 still an IP literal and must be treated the same as the bare form. +// 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_none_for_bracketed_ipv6_literal() { - let addr = ProxyAddress::new("[2001:db8::1]".to_string(), 8080); +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"), + } +} - assert_eq!(addr.host_pin("10.0.0.5"), None); +// 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. An empty address names -// nothing to resolve, so no hosts entry is required. +// 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_none_for_empty_address() { - let addr = ProxyAddress::new(String::new(), 8080); +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") + } + } +} - assert_eq!(addr.host_pin("10.0.0.5"), None); +// 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 @@ -250,24 +351,27 @@ fn host_pin_does_not_change_to_url() { "proxy.example.com".to_string(), 8443, ); + let ip: IpAddr = "10.0.0.5".parse().unwrap(); let before = addr.to_url(); - let pin = addr.host_pin("10.0.0.5"); + 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!(pin.is_some(), "a hostname address should yield a pin"); 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. +// space, with no trailing newline. #[test] fn hosts_line_writes_ip_then_hostname() { - let pin = ProxyHostPin { - hostname: "proxy.example.com".to_string(), - ip: "10.0.0.5".to_string(), - }; + 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"); } @@ -277,26 +381,28 @@ fn hosts_line_writes_ip_then_hostname() { // deliberate opposite of how `to_url` renders IPv6. #[test] fn hosts_line_writes_ipv6_address_without_brackets() { - let pin = ProxyHostPin { - hostname: "proxy.example.com".to_string(), - ip: "2001:db8::1".to_string(), - }; + let ip: IpAddr = "2001:db8::1".parse().unwrap(); + + let pin = pin_for("proxy.example.com", ip); + let line = pin.hosts_line(); - assert_eq!(pin.hosts_line(), "2001:db8::1 proxy.example.com"); + 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 +// 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 = ProxyHostPin { - hostname: "proxy.example.com".to_string(), - ip: "2001:db8::1".to_string(), - } - .hosts_line(); + 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"); From 91b3760caec45e4274e76b6d940550524252d080 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 18:59:03 -0700 Subject: [PATCH 07/55] [LXC] Fail closed when firewall rules cannot be scoped to the container install_firewall_rules built the full deny-all chain and then, when no veth interface was known, logged a warning and returned Ok(()). The chain is only ever reached from FORWARD via `-i `, so without that hook nothing traverses it: the caller was told the network policy was applied while zero packets were filtered. That is the worst of the three possible outcomes. Installing the rules host-wide instead would at least filter, but unscoped they would hit every container and the host's own traffic. Returning an error loses nothing, because there was no enforcement to lose. This path is only reachable when the caller explicitly asked for firewall enforcement -- apply_firewall_rules returns early unless the mode is Firewall or Both, and NetworkEnforcementMode defaults to Capabilities. So the change cannot affect containers that never wanted a firewall. Rollback and teardown already handle the Err: apply_firewall_rules_inner converts it into a precise teardown of exactly what was created plus residual ownership, and lxc_runner destroys the container rather than starting a workload that believes it is confined. No existing test pinned the old behavior (115/115 still pass), which is itself the point: the fail-open was untested. The four Linux E2E scripts that exercise firewall enforcement already require "FORWARD hook installed" in the output and fail without it, so veth discovery demonstrably succeeds there and this change is a no-op for every run that passes today. Slice 3 of the PR 632 re-cut. Refs AB#62830341. --- .../lxc/common/src/network_iptables.rs | 35 +++++++++++++++---- .../common/src/network_iptables_veth_spec.rs | 7 ++++ 2 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 src/backends/lxc/common/src/network_iptables_veth_spec.rs diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 79f5fe96f..888f61239 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -1049,12 +1049,26 @@ impl NetworkIptablesManager { )); } } 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. - logger.log_line( - "Warning: No veth interface set for container. \ - Cannot scope iptables rules. Skipping FORWARD hook.", - ); + // Without a veth interface there is nothing to hook the chain to, + // and an unhooked chain is never traversed: FORWARD only reaches it + // via `-i `. 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. + 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 + )); } Ok(()) @@ -1228,6 +1242,15 @@ 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; + #[cfg(test)] mod test_firewall { use std::cell::RefCell; 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..8f6085c4c --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_veth_spec.rs @@ -0,0 +1,7 @@ +//! 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. From a3d82c01864f809c9b7283cec251ad07094fa961 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 19:15:27 -0700 Subject: [PATCH 08/55] [LXC] Spec the fail-closed contract for unscopeable firewall rules Six black-box tests for apply_firewall_rules, written against the documented contract by an author who did not read the implementation, so they describe the behavior that was intended rather than mirroring whatever the code does. They pin: - refusal when the veth interface is unknown, under Firewall and under Both, separately, so a fix scoped to one enforcement mode cannot pass - the error names the chain left unenforced, so an operator has something to search for - the negative control: the same policy succeeds once an interface is set. Without it, an apply that always returned Err would pass every other test - teardown of the chain created before the refusal, asserted as ordering against the creation command rather than mere presence - Capabilities-only containers issue no firewall commands at all, which is what bounds this change's blast radius Mutation tested: seven seeded defects, all caught by a failing test, no survivors. The seeds include restoring the old Ok(()) fail-open, dropping the chain name from the message, applying the check to Firewall but not Both, inverting the interface check, skipping rollback, and swallowing the error one layer up in record_apply_outcome. Each mutant compiles with lints silenced, so a defect detected only by the compiler counts as a harness failure rather than a pass -- the tests have to answer for themselves. Attached as a #[path] child module because the fake-firewall seam is #[cfg(test)] and private, which an integration test -- a separate crate -- cannot reach. Slice 3 of the PR 632 re-cut. Refs AB#62830341. --- .../common/src/network_iptables_veth_spec.rs | 200 +++++++++++++++++- 1 file changed, 193 insertions(+), 7 deletions(-) diff --git a/src/backends/lxc/common/src/network_iptables_veth_spec.rs b/src/backends/lxc/common/src/network_iptables_veth_spec.rs index 8f6085c4c..1b565cff6 100644 --- a/src/backends/lxc/common/src/network_iptables_veth_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_veth_spec.rs @@ -1,7 +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. +//! 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 = "MXC-acme-web"; + 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 = "MXC-ctrl-teardown"; + 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() + ); +} From 47e00a97fb6bd1f5f96df4c8699c6bf6d43120f8 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 20:00:57 -0700 Subject: [PATCH 09/55] [LXC] Hook the firewall chain onto the bridge port so it actually filters The per-container chain was hooked into FORWARD with `-i ` only. That matches nothing whenever the veth is enslaved to a bridge, which is the default LXC topology: the packet is bridged onto `lxcbr0` and then routed off it, so FORWARD sees the bridge as the input interface and never the veth. The chain was built correctly, populated correctly, hooked without error, and traversed by zero packets. Measured on a live container before this change, with `defaultPolicy: block` and no allowed hosts: every counter in the chain read 0, the closing DROP included, and a fetch from inside the container succeeded. Adding a counting rule on the same traffic in the same FORWARD chain gave 11 packets for `-i lxcbr0` against 0 for `-i `. Install a second hook per family matching `-m physdev --physdev-in `, which identifies the bridge port the packet entered on and so stays scoped to one container -- matching the bridge itself would apply one container's policy to every container sharing it. The two rules are mutually exclusive for any given packet, so a directly routed veth is still carried by the `-i` rule and nothing is counted twice. Fail closed on the two conditions that would leave the chain unreachable again, in the same voice as the missing-veth refusal: a bridged veth whose `bridge-nf-call-{ip,ip6}tables` toggle is absent or 0, and a bridged veth whose physdev hook will not install. On a directly routed veth the physdev rule is redundant, so a kernel without the match warns instead of failing. Teardown removes both forms, built from the same builders used at insertion so a delete cannot drift from the insert it has to match, and the chain delete now waits on both hooks because either surviving one still references the chain. Verified on a live container: `defaultPolicy: block` with no allowed hosts now blocks, the same policy with `api.github.com` allowed still reaches it, all five network E2E scripts pass, and teardown leaves no FORWARD reference and no chain behind. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../lxc/common/src/network_iptables.rs | 299 ++++++++++++++++-- .../src/network_iptables_forward_hook_spec.rs | 8 + 2 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 888f61239..7a8555ffe 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -8,6 +8,7 @@ //! interface. use std::net::{IpAddr, Ipv6Addr, ToSocketAddrs}; +use std::path::Path; use std::process::Command; use wxc_common::logger::Logger; @@ -19,6 +20,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. @@ -68,6 +79,8 @@ pub(crate) struct CreatedResources { v6_chain: bool, v4_hook: bool, v6_hook: bool, + v4_physdev_hook: bool, + v6_physdev_hook: bool, } /// Flush and delete the chain, reporting whether it is still owned afterward. @@ -106,7 +119,12 @@ 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 } /// Test-only constructor so `signal_cleanup`'s tests can build a @@ -120,6 +138,8 @@ impl CreatedResources { v6_chain, v4_hook, v6_hook, + v4_physdev_hook: false, + v6_physdev_hook: false, } } } @@ -240,6 +260,126 @@ impl NetworkIptablesManager { self.veth_interface = Some(iface.to_string()); } + /// 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(), + ] + } + + /// Whether `iface` is enslaved to a bridge, looked up under an injectable + /// sysfs root so this is testable without a live interface. + /// + /// The kernel exposes `master` only for an enslaved interface, so its mere + /// presence is the answer. + fn veth_is_bridge_enslaved_in(sysfs_net_root: &Path, iface: &str) -> bool { + sysfs_net_root.join(iface).join("master").exists() + } + + /// 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) + } + + /// Production wrapper over [`Self::veth_is_bridge_enslaved_in`]. + fn veth_is_bridge_enslaved(iface: &str) -> bool { + Self::veth_is_bridge_enslaved_in(Path::new(SYSFS_NET_ROOT), iface) + } + + /// 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) + } + } + } + /// Resolve a destination string to IPv4 and IPv6 firewall destinations. /// /// Bare IPv4/IPv6 literals are retained in their matching family. CIDR @@ -1022,36 +1162,103 @@ 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` would instead match traffic flowing toward the + // container. if let Some(ref iface) = self.veth_interface { - Self::run_iptables( - &["-I", "FORWARD", "-i", iface, "-j", &self.chain_name], + let bridged = Self::veth_is_bridge_enslaved(iface); + 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 + )); + } + + Self::run_iptables_rule_args( + &[Self::build_forward_hook_iface_rule_args( + "-I", + iface, + &chain_name, + )], logger, )?; created.v4_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); 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 + )); + } + + Self::run_ip6tables_rule_args( + &[Self::build_forward_hook_iface_rule_args( + "-I", + iface, + &chain_name, + )], logger, )?; created.v6_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, + )?; + 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 there is nothing to hook the chain to, - // and an unhooked chain is never traversed: FORWARD only reaches it - // via `-i `. Reporting success here would hand the caller a + // 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. // @@ -1106,32 +1313,68 @@ 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; + } } // 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); @@ -1140,7 +1383,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); @@ -1251,6 +1494,12 @@ impl Drop for NetworkIptablesManager { #[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; + #[cfg(test)] mod test_firewall { use std::cell::RefCell; 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..f82ad199a --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs @@ -0,0 +1,8 @@ +// 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. From 3a05f6acdccbd021c010a94329158831717d27ec Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sat, 8 Aug 2026 20:15:18 -0700 Subject: [PATCH 10/55] [LXC] Spec the FORWARD hook contract and assert enforcement end to end Two kinds of test, because the defect this slice fixes was invisible to both kinds the repository already had. The unit specs pin the four seams the hook is built from: the two rule-args builders, bridge-enslavement detection, and the bridge-netfilter toggle read. They are written against the documented contract by an author who did not read the implementation. The guarantees that matter most are that the physdev builder never collapses into an input-interface match, that it names one specific bridge port rather than a wildcard, that a delete specification differs from its insert only by the operation -- iptables deletes by full rule specification, so a drifted delete silently leaks the hook -- and that an absent bridge-netfilter toggle reads as inactive, never as safe. Mutation testing over nine seeded defects, including the exact bug this slice fixes: 9 caught, 0 survivors. The E2E script exists because unit tests cannot see the failure at all. Every existing network script asserts that the FORWARD hook was *installed*, which is a log line; the hook installed cleanly, named the right chain, and matched zero packets. So this script asserts the guarantee instead: a destination the policy does not allow must be unreachable from inside the container, and an explicitly allowed one must still be reachable. The allow case is not decoration -- a blocked-only assertion would also pass on a host with no working network, or on a change that broke egress outright. Verified in both directions on live containers. Against the fixed implementation the script passes. Against the implementation from the parent commit it fails on the deny case with "egress succeeded under a default-block policy with no allowed hosts", which is the regression it exists to catch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../src/network_iptables_forward_hook_spec.rs | 450 ++++++++++++++++++ .../lxc_network_enforcement_allow.json | 21 + .../configs/lxc_network_enforcement_deny.json | 21 + tests/scripts/run_lxc_all_tests.sh | 1 + .../run_lxc_network_enforcement_test.sh | 102 ++++ 5 files changed, 595 insertions(+) create mode 100644 tests/configs/lxc_network_enforcement_allow.json create mode 100644 tests/configs/lxc_network_enforcement_deny.json create mode 100644 tests/scripts/run_lxc_network_enforcement_test.sh 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 index f82ad199a..c87356eea 100644 --- a/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs @@ -6,3 +6,453 @@ //! //! 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_is_bridge_enslaved_in(&root, "veth-a1b2"); + + fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); + assert!( + result, + "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_is_bridge_enslaved_in(&root, "veth-d4e5"); + + fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); + assert!( + !result, + "an interface directory with no master entry must not be reported as bridge-enslaved" + ); +} + +// If the interface itself has no sysfs directory at all -- for example a +// name that does not exist on the host -- there is nothing to be enslaved, +// and the function must say so rather than erroring. +#[test] +fn a_missing_interface_directory_is_not_bridge_enslaved() { + let root = fresh_fixture_dir("missing-iface"); + fs::create_dir_all(&root).expect("failed to create the fake sysfs root"); + + let result = NetworkIptablesManager::veth_is_bridge_enslaved_in(&root, "veth-ghost"); + + fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); + assert!( + !result, + "an interface with no sysfs directory at all must not be reported as bridge-enslaved" + ); +} + +// 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" + ); +} 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/scripts/run_lxc_all_tests.sh b/tests/scripts/run_lxc_all_tests.sh index 0510bb4d8..6410c85b0 100644 --- a/tests/scripts/run_lxc_all_tests.sh +++ b/tests/scripts/run_lxc_all_tests.sh @@ -66,6 +66,7 @@ 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 Timeout" "$SCRIPT_DIR/run_lxc_timeout_test.sh" run_test "LXC Env+Cwd" "$SCRIPT_DIR/run_lxc_env_cwd_test.sh" 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..9887c87e9 --- /dev/null +++ b/tests/scripts/run_lxc_network_enforcement_test.sh @@ -0,0 +1,102 @@ +#!/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" +DENY_CHAIN="MXC-CLI-LXC-Net-Deny" +ALLOW_CHAIN="MXC-CLI-LXC-Net-Allow" + +fail() { + echo "FAIL: $1" + exit 1 +} + +assert_firewall_chain_cleaned_up() { + if iptables -S "$1" >/dev/null 2>&1; then + fail "iptables chain '$1' was left behind after lxc-exec completed." + fi + if ip6tables -S "$1" >/dev/null 2>&1; then + fail "ip6tables chain '$1' was left behind after lxc-exec completed." + fi +} + +# 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 +} + +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 ---" +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 + +assert_no_forward_reference "$DENY_CHAIN" +assert_firewall_chain_cleaned_up "$DENY_CHAIN" + +echo "--- allow case: same default, destination explicitly allowed ---" +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 + +assert_no_forward_reference "$ALLOW_CHAIN" +assert_firewall_chain_cleaned_up "$ALLOW_CHAIN" + +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 From 792d1aec0acfd5c48a0bafb104abab0cd87573d0 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sun, 9 Aug 2026 12:49:19 -0700 Subject: [PATCH 11/55] [LXC] Make deny rules win over allow rules and fail closed on an unresolvable block The per-container chain emitted allow-list rules before block-list rules, and iptables applies first-match-wins within a chain, so a destination named in both `allowedHosts` and `blockedHosts` was ACCEPTed. A code comment recorded that as interim behavior owned by AB#62830341. Emit the block list first so the deny wins. Emission order is the entire precedence mechanism -- there is no separate resolution pass -- so the comment now says that outright, because swapping the two iterators back would reverse the security semantics without failing to compile. A block entry that resolved to no address programmed no rule and logged only a warning. Where the chain ends in ACCEPT that is a fail-open: the unwritten deny rule was the only thing that would have stopped the traffic, and the apply still reported success. `build_policy_rules_logged` now returns `Result` and errors in exactly that case, so the caller rolls back the chains it created rather than leaving a policy it did not enforce. The error is conditioned on the default policy rather than raised for every unresolvable block entry. Where the chain ends in DROP, an entry that resolves to nothing is redundant rather than missing -- the closing rule already denies every destination the allow list did not name -- and erroring there would refuse to start containers whose blocklists name hosts that do not exist, which is the ordinary case. `tests/configs/lxc_network_test.json` blocks `evil.example.com` under `defaultPolicy: block`, and that name is NXDOMAIN. The two tests that pinned allow-before-block ordering are deleted rather than inverted. They asserted the contract this change replaces, and the replacement assertions belong to the `deny_precedence_spec` module, which is authored separately so that the tests proving this change correct are not written by its author. The family-split test kept its subject and gave up only its incidental dependency on rule sequence. Residual gap, documented in the code rather than papered over: under a DROP default, a sufficiently broad allow entry can still cover a destination whose deny rule went unwritten. Detecting that needs the address the entry failed to resolve to, so no predicate over the policy text can be complete, and a partial check would imply a guarantee this code cannot make. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../lxc/common/src/network_iptables.rs | 174 ++++++++++-------- .../network_iptables_deny_precedence_spec.rs | 11 ++ 2 files changed, 106 insertions(+), 79 deletions(-) create mode 100644 src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 7a8555ffe..7f9ca0eea 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -626,17 +626,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 @@ -650,32 +657,63 @@ 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. + /// + /// Residual gap, deliberately not closed here: under [`NetworkPolicy::Block`] + /// a sufficiently broad allow entry can still cover a destination whose deny + /// rule went unwritten. Detecting that needs the address the entry failed to + /// resolve to, so no predicate over the policy text can be complete, and a + /// partial check would imply a guarantee this code cannot make. + /// + /// 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 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 + )); + } logger.log_line(&format!("Warning: could not resolve host '{}'", host)); } let rule_args = @@ -694,7 +732,7 @@ impl NetworkIptablesManager { } args.extend(rule_args); } - args + Ok(args) } /// Run an iptables command and return success/failure. @@ -1135,8 +1173,11 @@ impl NetworkIptablesManager { // 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); + // 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)?; @@ -1500,6 +1541,13 @@ mod veth_spec; #[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; + #[cfg(test)] mod test_firewall { use std::cell::RefCell; @@ -2188,20 +2236,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] @@ -2745,52 +2807,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"; 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..f80337468 --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs @@ -0,0 +1,11 @@ +// 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. +//! +//! Add `use super::*;` when the first test lands; an unused import fails the +//! `-D warnings` gate while this module is still empty. From 988b609dee57ba742f7675bbe8714675a7eca9fe Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sun, 9 Aug 2026 13:07:25 -0700 Subject: [PATCH 12/55] [LXC] Spec deny precedence and assert an overlapping allow cannot defeat a block The implementation commit changed emission order and made an unresolvable deny entry fatal under an accepting default. This commit is the evidence that both hold, written against the documented contract rather than against the code. Twelve unit tests in a new spec module, authored from headers only by an agent that never opened `network_iptables.rs`. The author that wrote the implementation cannot write its tests: a test derived from the implementation encodes that implementation's bugs as expected behavior and will pass forever without catching anything. The tests assert the contract, not the current output: - a destination in both lists is dropped, for IPv4, for IPv6, and with several entries in each list - every DROP is emitted before every ACCEPT, checked by index rather than by comparing against a fixed expected vector - an unresolvable blocked host errors under an accepting default and the error names the host - the same unresolvable blocked host does not error under a blocking default, because the closing DROP already denies it - an unresolvable allowed host never errors under either default - an unresolvable entry does not suppress a sibling entry's rule or log line - v4 and v6 destinations land in their own buckets, asserted by parsing each destination rather than by matching a known list, so the assertion cannot be satisfied by an implementation that happens to emit the expected values Mutation testing supplies the proof that these tests can actually fail. Nine mutants, each a mistake a person could plausibly make in this function: restore the old emission order, error on every unresolvable block entry, error on unresolvable allow entries, never error at all, invert the default-policy test, swap the jump targets, drop the warning line, leak IPv6 destinations into the IPv4 bucket, and omit the host name from the error. caught=9 survived=0 harness_bugs=0 source restored byte-identical: True Mutant 1 is the load-bearing one. Two tests pinning the old allow-before-block order were deleted in the implementation commit, and a deletion with no replacement would have dropped coverage silently while the suite stayed green. Killing mutant 1 proves the replacement exists. The end-to-end guard runs the real binary against a config whose allowed and blocked lists both contain `0.0.0.0/0` and `::/0`. Literal CIDRs rather than a hostname, because a hostname is resolved separately for each list entry and round-robin DNS could hand back different addresses for the allow and the deny, making the verdict depend on which address the fetch picked. The control config is load-bearing. It allows the same destination and blocks nothing, so it must come back reachable. Without it, a host with no egress at all would produce the same blocked verdict on the overlap case and look exactly like a pass. The guard was verified to discriminate by running it against the previous commit's binary: b9946e3 ACCEPT then DROP overlap MXC_NET_ALLOWED guard FAILS, exit 1 447f10f DROP then ACCEPT overlap MXC_NET_BLOCKED guard PASSES Same script, same host, same configs. The control passed in both runs, so the difference is the rule ordering and not a host that lost its network. Gates: 154 unit tests pass, clippy -D warnings clean, fmt clean, all seven LXC end-to-end scripts pass. --- .../network_iptables_deny_precedence_spec.rs | 642 +++++++++++++++++- .../lxc_network_deny_precedence_control.json | 21 + .../lxc_network_deny_precedence_overlap.json | 21 + tests/scripts/run_lxc_all_tests.sh | 1 + .../run_lxc_network_deny_precedence_test.sh | 96 +++ 5 files changed, 770 insertions(+), 11 deletions(-) create mode 100644 tests/configs/lxc_network_deny_precedence_control.json create mode 100644 tests/configs/lxc_network_deny_precedence_overlap.json create mode 100644 tests/scripts/run_lxc_network_deny_precedence_test.sh 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 index f80337468..14cd0ea26 100644 --- a/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs @@ -1,11 +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. -//! -//! Add `use super::*;` when the first test lands; an unused import fails the -//! `-D warnings` gate while this module is still empty. +// 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/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/scripts/run_lxc_all_tests.sh b/tests/scripts/run_lxc_all_tests.sh index 6410c85b0..8caa0579f 100644 --- a/tests/scripts/run_lxc_all_tests.sh +++ b/tests/scripts/run_lxc_all_tests.sh @@ -67,6 +67,7 @@ run_test "LXC Network Invalid CIDR" "$SCRIPT_DIR/run_lxc_network_invalid_cidr_te 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 Timeout" "$SCRIPT_DIR/run_lxc_timeout_test.sh" run_test "LXC Env+Cwd" "$SCRIPT_DIR/run_lxc_env_cwd_test.sh" 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..770ffbfb2 --- /dev/null +++ b/tests/scripts/run_lxc_network_deny_precedence_test.sh @@ -0,0 +1,96 @@ +#!/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" +OVERLAP_CHAIN="MXC-CLI-LXC-Net-DenyWins" +CONTROL_CHAIN="MXC-CLI-LXC-Net-DenyCtl" + +fail() { + echo "FAIL: $1" + exit 1 +} + +assert_firewall_chain_cleaned_up() { + if iptables -S "$1" >/dev/null 2>&1; then + fail "iptables chain '$1' was left behind after lxc-exec completed." + fi + if ip6tables -S "$1" >/dev/null 2>&1; then + fail "ip6tables chain '$1' was left behind after lxc-exec completed." + fi +} + +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 +} + +echo "Running LXC deny-precedence enforcement test..." + +echo "--- control: destination allowed, nothing blocked ---" +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 + +assert_no_forward_reference "$CONTROL_CHAIN" +assert_firewall_chain_cleaned_up "$CONTROL_CHAIN" + +echo "--- overlap: same destination in both allowedHosts and blockedHosts ---" +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 + +assert_no_forward_reference "$OVERLAP_CHAIN" +assert_firewall_chain_cleaned_up "$OVERLAP_CHAIN" + +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." From 8595d5d1980f6e1a179e9da0792793a930420f83 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sun, 9 Aug 2026 13:26:38 -0700 Subject: [PATCH 13/55] [LXC] Correct the network policy docs and make the E2E suite gate in CI The documentation described a firewall that no longer exists. Slices 3, 4, and 5 changed what happens on a missing veth, how the chains reach FORWARD, and which rule wins when the two host lists overlap, and none of it was written down. Four claims were false against the code: - The policy table left precedence unspecified. It is now deny-wins, and the reason -- first match ends chain evaluation -- belongs in the doc, because the ordering is the whole mechanism. - Unresolvable entries were described as always "reported as unresolved and skipped, leaving the rest of the policy in force". That is now conditional: under an accepting default an unresolvable blocked host is fatal. - The FORWARD hook was described as matching the host-side veth as the input interface. That omits the `--physdev-in` bridge-port rule and the `br_netfilter` requirement, which is precisely the omission that let a populated deny-all chain filter nothing. - "If MXC cannot discover the container veth, it skips the FORWARD hook with a warning" was flatly wrong. That path returns an error and rolls back. An independent review caught three further overstatements in the first draft of this text, all of which were mine and all of which were the comfortable direction to be wrong in: - "A deny always wins" is not true. The base chain accepts UDP and TCP port 53 unconditionally and is installed ahead of the policy rules, so DNS to a blocked destination is accepted before its DROP is reached. Narrowing that needs to know which resolver addresses are legitimate and no schema field carries them, so the honest move is to document the exemption rather than imply a guarantee the chain does not provide. - A hostname appearing in both lists is resolved once per entry, so round-robin DNS can return an address for the allow that the deny never saw. The guarantee holds for addresses, not for names. This was already known -- it is why the deny-precedence E2E guard uses literal CIDRs -- and it still did not make it into the prose. - "Two rules per family" is not unconditional. On a directly routed veth a missing physdev match warns and continues, because the interface rule is the one that matches there. Only on a bridged veth is it fatal. The IPv6 bridge toggle is also checked separately and was not mentioned. ## CI `lxc-e2e.yml` runs the suite on a provisioned Ubuntu runner. Until now no workflow executed these scripts at all, which is much of how a firewall that filtered nothing shipped green: the assertions existed and nothing ran them. The workflow enables `br_netfilter` explicitly. Without it a bridged veth never reaches FORWARD, every rule installs cleanly, nothing fires, and the network tests pass against a firewall that filters nothing -- the exact failure they are supposed to detect. `MXC_LXC_TESTS_REQUIRE_EXECUTION` turns an honest skip into a failure. A developer box legitimately lacks ip6tables or LXC and should run what it can, so a skip stays a warning there. A runner provisioned specifically to execute this suite is different: a skip means a prerequisite disappeared, and without this the gate goes green while testing nothing. Verified by running the suite four ways: normal and strict with prerequisites present both pass, and strict with the binary removed exits 1 naming the six skipped tests rather than reporting success. --- .github/workflows/lxc-e2e.yml | 99 ++++++++++++++++++++++++++++++ docs/lxc-support/lxc-backend.md | 81 ++++++++++++++++++++++-- tests/scripts/run_lxc_all_tests.sh | 16 +++++ 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/lxc-e2e.yml diff --git a/.github/workflows/lxc-e2e.yml b/.github/workflows/lxc-e2e.yml new file mode 100644 index 000000000..33b38ad65 --- /dev/null +++ b/.github/workflows/lxc-e2e.yml @@ -0,0 +1,99 @@ +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 + + - 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 "--- 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..1f332ebab 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,7 +170,40 @@ 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. diff --git a/tests/scripts/run_lxc_all_tests.sh b/tests/scripts/run_lxc_all_tests.sh index 8caa0579f..e72108311 100644 --- a/tests/scripts/run_lxc_all_tests.sh +++ b/tests/scripts/run_lxc_all_tests.sh @@ -81,6 +81,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 From 4ed9d4d642f59024d91b79beb2d37f16c0e95c73 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sun, 9 Aug 2026 13:41:55 -0700 Subject: [PATCH 14/55] [LXC] Set FORWARD to ACCEPT in CI so only MXC rules can block The first run of this workflow failed three tests, and the three were the positive controls doing exactly what they exist for. GitHub-hosted runners ship Docker, and Docker sets the IPv4 FORWARD policy to DROP. That broke the tests twice over. Outright: MXC hooks its chain on traffic leaving the container, 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. DNS still resolved, because dnsmasq on lxcbr0 is host-local and never traverses FORWARD, so the symptom was a resolved address that then timed out: \wget: can't connect to remote host (140.82.116.5)\. IPv4 only, which matches Docker leaving the IPv6 policy at ACCEPT. And silently: under a DROP policy a container with no working MXC hook at all is equally unreachable, so the deny cases would have reported success against a firewall that filters nothing. That is the exact bug this suite exists to detect and the reason these tests carry positive controls. Without the controls this run would have been a green gate over a dead network. 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, and a missing hook fails the deny case loudly. A conntrack RELATED,ESTABLISHED rule would have fixed the reply path while leaving the vacuous pass in place, so it is the wrong fix. The environment step now prints both FORWARD policies, because a future runner image that reintroduces DROP would otherwise present as an unexplained timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .github/workflows/lxc-e2e.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/lxc-e2e.yml b/.github/workflows/lxc-e2e.yml index 33b38ad65..0d6f81172 100644 --- a/.github/workflows/lxc-e2e.yml +++ b/.github/workflows/lxc-e2e.yml @@ -46,6 +46,37 @@ jobs: 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 would fix the reply path + # but leave the DROP policy, and with it the vacuous pass. + - 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 ---" @@ -55,6 +86,9 @@ jobs: 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" From 76eb242d8377302f347802bb7a357ee7f553ee45 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sun, 9 Aug 2026 15:30:01 -0700 Subject: [PATCH 15/55] Keep Bubblewrap startable when no veth exists to scope the chain to Slice 3 made a missing veth fatal: install_firewall_rules returned Err so a container could never start believing it was confined by a chain that FORWARD never reaches. That is right for LXC, which always names a veth once the container is running, so arriving at rule installation without one means the lookup lost it. Bubblewrap has no veth at all. Unprivileged bwrap either shares the host network namespace or gets a private one, and neither yields a host-side interface to match on -- bwrap_command.rs says so directly. bwrap_runner builds a NetworkIptablesManager and never calls set_veth_interface, so every Bubblewrap sandbox requesting Firewall or Both mode with host rules hit the new Err and failed to start. On main that path logged a warning and continued. No test covered it, so all six CI workflows stayed green. Make the strictness a property the caller declares. The default still fails closed, so both veth-spec tests and the LXC contract are unchanged. Bubblewrap calls allow_missing_veth_interface and keeps the pre-existing warn-and-skip, which leaves its policy unenforced -- a real gap, but a pre-existing one that belongs to Bubblewrap's own work item rather than to this LXC change. Adds three tests: the declared-missing case must succeed under Firewall and Both, and a manager that never declared it must still fail closed, so the two behaviors cannot collapse into one. Found by an independent reviewer auditing whether pre-existing tests needed to change; the regression was invisible because bwrap_common was never in the packages this branch had been testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../bubblewrap/common/src/bwrap_runner.rs | 6 + .../lxc/common/src/network_iptables.rs | 114 +++++++++++++++++- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index d2354eb41..1d38fad41 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -206,6 +206,12 @@ impl BubblewrapScriptRunner { "Bubblewrap: applying iptables rules for host-level network filtering" ); let mut mgr = NetworkIptablesManager::new(&container_name); + // Unprivileged bwrap has no veth to scope a chain to (see + // `local_network_diagnostic` in bwrap_command.rs), so a missing one + // here is structural rather than a failed lookup. Without this the + // manager's fail-fast path would refuse to start every Bubblewrap + // sandbox that asks for firewall mode. + mgr.allow_missing_veth_interface(); match mgr.apply_firewall_rules(&request.policy, logger) { Ok(true) => {} Ok(false) => { diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 7f9ca0eea..e6f8b5c84 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -195,6 +195,9 @@ 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, /// Chains and FORWARD hooks this manager successfully created, so teardown /// and rollback remove only resources this attempt actually installed. created: CreatedResources, @@ -214,6 +217,7 @@ impl NetworkIptablesManager { chain_name: format!("MXC-{}", sanitized), rules_applied: false, veth_interface: None, + veth_scoping_optional: false, created: CreatedResources::default(), } } @@ -260,6 +264,24 @@ impl NetworkIptablesManager { self.veth_interface = Some(iface.to_string()); } + /// 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 @@ -1311,12 +1333,25 @@ impl NetworkIptablesManager { // rolls back the chains recorded in `created`, and `lxc_runner` // destroys the container rather than starting a workload that // believes it is confined. - 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 - )); + // + // 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.", + ); } Ok(()) @@ -1673,6 +1708,73 @@ mod tests { use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode}; + /// 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 + ); + } + + // 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() { assert!( From 3dd4196b8d143f0242786d09c2275c2119b7e980 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sun, 9 Aug 2026 15:33:27 -0700 Subject: [PATCH 16/55] Cover the Bubblewrap veth declaration so deleting it fails a test Mutation M4 -- delete the allow_missing_veth_interface call from bwrap_runner -- survived the whole suite. That is the same blind spot that let the regression land: the declaration lived inline in a 300-line execute function where no test could reach it. Extract build_firewall_manager so the declaration has a seam, and assert on it via a new veth_scoping_is_optional accessor rather than by standing up a real firewall -- lxc_common's fake-firewall seam is cfg(test) and so is invisible to bwrap_common. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../bubblewrap/common/src/bwrap_runner.rs | 40 +++++++++++++++---- .../lxc/common/src/network_iptables.rs | 8 ++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index 1d38fad41..bfd30c602 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -205,13 +205,7 @@ impl BubblewrapScriptRunner { logger, "Bubblewrap: applying iptables rules for host-level network filtering" ); - let mut mgr = NetworkIptablesManager::new(&container_name); - // Unprivileged bwrap has no veth to scope a chain to (see - // `local_network_diagnostic` in bwrap_command.rs), so a missing one - // here is structural rather than a failed lookup. Without this the - // manager's fail-fast path would refuse to start every Bubblewrap - // sandbox that asks for firewall mode. - mgr.allow_missing_veth_interface(); + let mut mgr = build_firewall_manager(&container_name); match mgr.apply_firewall_rules(&request.policy, logger) { Ok(true) => {} Ok(false) => { @@ -501,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 { @@ -646,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/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index e6f8b5c84..d8447b0b7 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -227,6 +227,14 @@ impl NetworkIptablesManager { self.rules_applied } + /// 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. From c098239e6dd23782c191ed86a46a9f00f3703747 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Sun, 9 Aug 2026 15:35:07 -0700 Subject: [PATCH 17/55] Pin the negative case of the missing-veth accessor Mutation M5 -- make veth_scoping_is_optional always return true -- survived, so the Bubblewrap suite would have passed on an accessor that could not say no. Assert a fresh manager reports false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/backends/lxc/common/src/network_iptables.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index d8447b0b7..f6b69e4eb 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -1764,6 +1764,20 @@ mod tests { ); } + // 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. From 76c4c5172e9064abcd7b3f115b3fbfcbd9780a15 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 12:27:45 -0700 Subject: [PATCH 18/55] [LXC] Admit network.proxy for LXC and reject the forms it cannot reach Roadmap rows 17 and 22 (AB#62830341) put an LXC container behind a cooperative proxy, but the parser still refused `network.proxy` for the LXC backend outright. Add `lxc` to the supported list, and add the three validations that make the admitted configs the ones that can actually work. `network.proxy.localhost` maps to 127.0.0.1, which inside an LXC network namespace is the *container's* loopback, not the host's. The injected HTTP(S)_PROXY would point at nothing and the iptables proxy-allow rule would never match, so the container would silently get no working proxy under a deny-all-except-proxy policy. A `url`-form proxy whose host is a loopback literal is unreachable for the same reason, so `host_is_loopback` rejects 127.0.0.0/8, ::1, bracketed `[::1]`, and the name `localhost`. `builtinTestServer` is refused because LXC enforces a configured address with iptables rather than launching the builtin testing proxy. Rejection is at parse time because all three forms are literals visible here. A hostname that only *resolves* to loopback is not caught; that residual gap is recorded in the code comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/wxc_common/src/config_parser.rs | 157 +++++++++++- .../src/config_parser_loopback_spec_tests.rs | 229 ++++++++++++++++++ 2 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 src/core/wxc_common/src/config_parser_loopback_spec_tests.rs diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 3ea3d4f8d..1380ad7c6 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -432,6 +432,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 { @@ -957,19 +977,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 @@ -1531,6 +1603,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::*; @@ -3377,13 +3453,86 @@ 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. + 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 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_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" + ); +} From d1da5d3f92302da771ee7deb12c92f154217dbb4 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 12:30:49 -0700 Subject: [PATCH 19/55] [LXC] Let a caller force lxc-attach to clear the inherited environment `build_attach_args` emitted `--clear-env` only when the caller supplied a non-empty env. That is exactly wrong for proxy-env hygiene (roadmap row 22, AB#62830341): once `apply_proxy_env` has scrubbed every inherited proxy variable the env can legitimately be empty, and the empty case then fell back to keep-env mode and let `lxc-attach` inherit the whole MXC host process environment -- HTTP_PROXY, HTTPS_PROXY, and whatever credentials a CI agent happens to be carrying. Add `build_attach_args_with_env_control` with an explicit `force_clear_env` flag and thread it through `attach_run` on both the Linux path and the Windows clippy stub. `build_attach_args` survives as a `#[cfg(test)]` wrapper pinning `false`, so the existing argv tests keep asserting the legacy shape. The new tests drive `apply_proxy_env` and the argv builder together, so what is asserted is the observable `lxc-attach` command line rather than an intermediate boolean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/backends/lxc/common/src/lxc_bindings.rs | 141 +++++++++++++++++++- 1 file changed, 137 insertions(+), 4 deletions(-) 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:?}" + ); + } } From de6c96af40d1a09dce5d41693cf4d20949dd5500 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 12:31:19 -0700 Subject: [PATCH 20/55] [LXC] Wire the proxy-env scrub into the production execution path `wxc_common::proxy_env::apply_proxy_env` shipped with 44 spec tests and zero production callers -- `git grep apply_proxy_env -- src/backends` returned nothing, so on LXC every inherited proxy variable reached the container untouched and no configured proxy was ever injected. That is roadmap row 22 (AB#62830341) in full, and it was silently dropped when this branch was re-cut from PR #632. Call it on the request env immediately before `attach_run` and hand the returned flag to `force_clear_env`. Both halves matter: the scrub removes a caller-supplied HTTP_PROXY that would otherwise point the sandbox at an egress path the policy never authorized, and the flag stops an emptied env falling back to keep-env mode and inheriting the host's variables instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/backends/lxc/common/src/lxc_runner.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index 002ce5445..c403a75c3 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -242,10 +242,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, ); From 8c6fa725770a735b39ebda13bfaf417daa24361c Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 12:41:37 -0700 Subject: [PATCH 21/55] [LXC] Restrict egress to the proxy endpoint when one is configured Roadmap row 17 (N5). A policy that names a network proxy is a statement that the container reaches the internet through that proxy and not otherwise, but the chain built for it was the ordinary allow/block chain: the proxy was injected into the environment and nothing stopped the container from ignoring it. Resolve the proxy once, in apply_firewall_rules, before any rule is installed, and build the chain from that single resolution. A proxied chain carries the proxy ACCEPTs and its closing DROP and nothing else. The base exemptions are deliberately absent. There is no port 53 accept because an unscoped one is a standing DNS-tunnel exfil path through a posture whose whole point is that the proxy is the only reachable destination; the container resolves the proxy from its hosts-file pin instead. There is no loopback or ESTABLISHED,RELATED accept because neither describes traffic this chain sees. The allow and block lists are not programmed either: a block entry is redundant under the closing DROP, and an allow entry naming anything but the proxy contradicts the model. The catch-all is forced to DROP regardless of defaultPolicy, since an ACCEPT terminal would make the proxy ACCEPT above it meaningless. The IPv6 chain gets its closing DROP alone, because the proxy endpoint is IPv4 -- fail-closed rather than unfiltered. An IPv6 proxy endpoint is refused explicitly instead of falling through IPv4 endpoint selection, which would discard it silently and leave a deny-all container whose proxy was never authorized. The pin comes from this same resolution rather than a second lookup. Two lookups of one name can disagree under round-robin DNS, and a container pinned to an address this chain did not allow cannot reach its proxy at all. Every resolved IPv4 address is opened, not just the pinned one. They all belong to the configured proxy host, so the posture is unchanged, and a client that resolves the name by some other means still reaches the proxy. Deny-precedence (row 16) is untouched: proxy mode emits no allow or deny host rules at all, and the non-proxy path is unchanged. --- .../lxc/common/src/network_iptables.rs | 345 +++++++++++-- .../common/src/network_iptables_proxy_spec.rs | 481 ++++++++++++++++++ 2 files changed, 795 insertions(+), 31 deletions(-) create mode 100644 src/backends/lxc/common/src/network_iptables_proxy_spec.rs diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index f6b69e4eb..1f16e45da 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -12,7 +12,21 @@ use std::path::Path; use std::process::Command; 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 { @@ -201,6 +215,11 @@ pub struct NetworkIptablesManager { /// 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, } impl NetworkIptablesManager { @@ -219,6 +238,7 @@ impl NetworkIptablesManager { veth_interface: None, veth_scoping_optional: false, created: CreatedResources::default(), + proxy_pin: None, } } @@ -227,6 +247,18 @@ 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 @@ -594,17 +626,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, @@ -1038,6 +1255,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, @@ -1064,7 +1286,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) } @@ -1136,10 +1361,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( @@ -1159,6 +1385,7 @@ impl NetworkIptablesManager { fn install_firewall_rules( &self, policy: &ContainerPolicy, + proxy_endpoints: &[ProxyEndpoint], logger: &mut Logger, created: &mut CreatedResources, ) -> Result<(), String> { @@ -1194,35 +1421,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)?; - } - - // 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() - )); + 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. 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"); @@ -1591,6 +1860,12 @@ mod forward_hook_spec; #[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; @@ -2973,12 +3248,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" ); 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..4a7535221 --- /dev/null +++ b/src/backends/lxc/common/src/network_iptables_proxy_spec.rs @@ -0,0 +1,481 @@ +// 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", "MXC-proxy-allow-default"); + 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", "MXC-proxy-shape"); + 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", "MXC-proxy-order"); + 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:?}"); + + for rule in appended_rules(&issued, "iptables", "MXC-proxy-nodns") { + 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:?}"); + + for rule in appended_rules(&issued, "iptables", "MXC-proxy-nobase") { + 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:?}"); + + for rule in appended_rules(&issued, "iptables", "MXC-proxy-nolists") { + 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", "MXC-proxy-v6"); + 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", "MXC-proxy-control"); + 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()); +} From a46ad0a19b6a8590d45dabca931b95db9f5c99d4 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 12:41:51 -0700 Subject: [PATCH 22/55] [LXC] Pin the proxy host inside the container before running the script A proxied chain opens no port 53, so a container handed HTTP_PROXY=http://proxy.example.com:8080 has no resolver to find its proxy with. Even with one it could pick an address the chain does not allow, because the firewall authorized the addresses a single lookup on the host returned. Write the pin the firewall recorded into the container's /etc/hosts before the script runs, so the name in the URL resolves to an address the chain allows. The URL itself is left alone: rewriting its host to an IP literal breaks SNI and certificate validation for an https:// proxy, which is why review rejected that approach. Fail closed if the write does not succeed. Without the pin the proxy is unreachable, so running the script would only produce a confusing failure inside a container that can reach nothing. The command is idempotent, because a container reused with destroyOnExit=false would otherwise accumulate entries and the first match in a hosts file wins -- a stale line would shadow the current pin. It rewrites the file with cat rather than mv, since LXC may bind-mount /etc/hosts and replacing the inode would leave the container reading the old one. Only grep, printf, cat, and rm are used, so it runs under BusyBox. Single-quoting the line is safe by construction: ProxyHostPin can only be built from a validated hostname and a parsed IpAddr, so it cannot contain a quote, a space, or a newline. Also wait for the container network when a proxy is configured. Without this the proxy connection could be attempted before the veth is up. --- src/backends/lxc/common/src/lxc_runner.rs | 140 +++++++++++++++++++++- 1 file changed, 138 insertions(+), 2 deletions(-) diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index c403a75c3..b9c069251 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, @@ -193,12 +198,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 +240,43 @@ 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 + )); + } + } + // 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 { @@ -284,6 +327,36 @@ 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 with `cat >` rather than `mv`, because LXC may + /// bind-mount `/etc/hosts`; replacing the inode would leave the container + /// still reading the old file. Only `grep`, `printf`, `cat`, and `rm` are + /// used, so this runs under BusyBox as well as coreutils. + /// + /// 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 { + // The group's exit status is printf's, so a grep that matches nothing + // and exits 1 does not abort the chain. + format!( + "{{ grep -v '{marker}' /etc/hosts 2>/dev/null; \ + printf '%s {marker}\\n' '{hosts_line}'; }} > /tmp/.mxc-hosts \ + && cat /tmp/.mxc-hosts > /etc/hosts && rm -f /tmp/.mxc-hosts", + marker = HOSTS_PIN_MARKER, + hosts_line = hosts_line + ) + } } impl ScriptRunner for LxcScriptRunner { @@ -335,4 +408,67 @@ 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}" + ); + } + + // 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}" + ); + } + } } From 8dd810dda8a9853ba7d1e45b255c777c2eb20133 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 12:46:02 -0700 Subject: [PATCH 23/55] [LXC] Add the deny-all-except-proxy integration test Ported from PR 632. Proves the model from the outside: with a proxy configured under defaultPolicy=block/enforcementMode=firewall, the container reaches the world only through the proxy, and the direct IPv4, direct IPv6, and DNS paths are all dropped. The proxy is locally controlled -- a small forward proxy the script starts on the lxcbr0 gateway address -- so the positive path needs no external internet and the negative paths target fixed public IPs that never resolve in-container. The fixture drift guard runs unconditionally, so the file is never wholly conditional: it fails loudly if the fixture stops saying what the assertions assume. The live half exits 77 when a prerequisite is missing, which run_lxc_all_tests.sh already classifies as a skip and reports separately, so a skip is never tallied as a pass. Unproven: this needs Linux, root, LXC, and python3, and no CI job invokes the LXC suite. --- tests/configs/lxc_network_proxy.json | 20 +++ tests/scripts/run_lxc_all_tests.sh | 1 + tests/scripts/run_lxc_network_proxy_test.sh | 189 ++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 tests/configs/lxc_network_proxy.json create mode 100644 tests/scripts/run_lxc_network_proxy_test.sh diff --git a/tests/configs/lxc_network_proxy.json b/tests/configs/lxc_network_proxy.json new file mode 100644 index 000000000..04ab3e9f4 --- /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 >/dev/null 2>&1; then echo DNS_LEAK; else echo 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/scripts/run_lxc_all_tests.sh b/tests/scripts/run_lxc_all_tests.sh index e72108311..be6611495 100644 --- a/tests/scripts/run_lxc_all_tests.sh +++ b/tests/scripts/run_lxc_all_tests.sh @@ -68,6 +68,7 @@ run_test "LXC Network Dual-Stack Hostname" "$SCRIPT_DIR/run_lxc_network_dualstac 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 Timeout" "$SCRIPT_DIR/run_lxc_timeout_test.sh" run_test "LXC Env+Cwd" "$SCRIPT_DIR/run_lxc_env_cwd_test.sh" 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..abd7d7e42 --- /dev/null +++ b/tests/scripts/run_lxc_network_proxy_test.sh @@ -0,0 +1,189 @@ +#!/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) +# DNS_BLOCKED name resolution was dropped (port 53 closed) +# The *_LEAK counterparts mean the isolation failed. +# +# 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 +# and no CI job invokes the LXC suite, so treat it as unproven until executed +# on a Linux host. +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" + +require_sentinel "DNS_BLOCKED" +reject_sentinel "DNS_LEAK" + +# 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, direct IPv4/IPv6/DNS blocked." +echo "LXC network proxy test complete." From 63974b744a4ef8b0f086bc8c0688ea1f2107f4f2 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 12:46:11 -0700 Subject: [PATCH 24/55] [LXC] Document the cooperative-proxy posture schema.md said WSLC was the only own-netns backend supporting the proxy, which is no longer true, and said nothing about the proxy being enforced rather than advisory on LXC. lxc-backend.md gains a section for the posture itself: the four ways a proxied chain differs from the ordinary one and why each difference is load-bearing, why only the url form is accepted, what the IPv6 chain carries, and why the hosts-file pin exists at all now that DNS is closed. --- docs/lxc-support/lxc-backend.md | 39 +++++++++++++++++++++++++++++++++ docs/schema.md | 10 ++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index 1f332ebab..4ad7942c1 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -207,6 +207,45 @@ 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 reaches +nothing rather than reaching the internet directly. + +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. + +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 87cd49c61..c5d01f6bf 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -55,9 +55,13 @@ 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: egress is restricted + // to the proxy endpoint and nothing else, so the + // allow/block host lists and DNS are not opened. }, "ui": { From f39702029bd2accd972445899132d19dd965b2c7 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 12:55:18 -0700 Subject: [PATCH 25/55] [LXC] Scope the integration test's DNS claims to what FORWARD can see The fixture asserted DNS_BLOCKED, which the chain cannot honestly promise. The chain is hooked into FORWARD, so it governs traffic the host *routes* for the container. DNS aimed at 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 the single DNS assertion is split. DNS to an off-bridge resolver is forwarded traffic, the chain governs it, and it is still asserted as FORWARDED_DNS_BLOCKED. DNS to the gateway's own resolver is reported as GATEWAY_DNS_* rather than asserted, so the gap stays visible in the output instead of becoming either a false pass or a failure of something this work item does not cover. Closing it needs an INPUT hook, tracked separately. The same measurement bounds PROXY_OK. The proxy here runs on the host bridge IP, so its packets also take INPUT (6 on the INPUT probe, 0 in FORWARD) and the proxy ACCEPT rule is not what admits them. PROXY_OK proves the env-var injection and the host pin are right and that deny-all did not break the proxy path; it does not exercise the ACCEPT rule. That rule is covered by the unit specs in network_iptables_proxy_spec.rs, and in production by an off-host proxy. The header now says so rather than implying the test proves more than it does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- tests/configs/lxc_network_proxy.json | 2 +- tests/scripts/run_lxc_network_proxy_test.sh | 46 +++++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/tests/configs/lxc_network_proxy.json b/tests/configs/lxc_network_proxy.json index 04ab3e9f4..2760693dc 100644 --- a/tests/configs/lxc_network_proxy.json +++ b/tests/configs/lxc_network_proxy.json @@ -3,7 +3,7 @@ "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 >/dev/null 2>&1; then echo DNS_LEAK; else echo DNS_BLOCKED; fi" + "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 diff --git a/tests/scripts/run_lxc_network_proxy_test.sh b/tests/scripts/run_lxc_network_proxy_test.sh index abd7d7e42..180179695 100644 --- a/tests/scripts/run_lxc_network_proxy_test.sh +++ b/tests/scripts/run_lxc_network_proxy_test.sh @@ -10,13 +10,32 @@ # 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 +# 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) -# DNS_BLOCKED name resolution was dropped (port 53 closed) +# 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 and the hosts pin are 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. +# # 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. @@ -173,8 +192,21 @@ reject_sentinel "PROXY_FAIL" require_sentinel "DIRECT_IPV4_BLOCKED" reject_sentinel "DIRECT_IPV4_LEAK" -require_sentinel "DNS_BLOCKED" -reject_sentinel "DNS_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. @@ -185,5 +217,5 @@ 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, direct IPv4/IPv6/DNS blocked." +echo "PASS: LXC deny-all-except-proxy — proxy reachable, forwarded IPv4/IPv6/DNS blocked." echo "LXC network proxy test complete." From daac8abb21b1f42e7c49ea1a00ce6a419f02a625 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 13:13:47 -0700 Subject: [PATCH 26/55] [LXC] Require a firewall enforcement mode for network.proxy `network.enforcementMode` defaults to `capabilities`, and under that mode `apply_firewall_rules` returns early without installing a single rule. The runner, meanwhile, injects HTTP(S)_PROXY unconditionally. A config that named a proxy but omitted `enforcementMode` therefore produced the worst of both halves: the environment variables said "everything goes through the proxy", while egress stayed completely unrestricted. Any client that ignores those variables -- a raw socket, a statically linked binary, anything hostile -- went straight out. The config read as deny-all-except-proxy and enforced neither part of it. Reject that combination at parse time instead of auto-promoting to `firewall`. Auto-promotion would silently install rules the caller never asked for, which is the same class of surprise the neighboring Bubblewrap and Seatbelt guards exist to prevent; they reject rather than reinterpret, and this follows them. Rejecting is also the honest failure. A caller who wanted an enforced proxy gets a message naming the setting to add, and a caller who genuinely wanted cooperative-only proxying learns that LXC does not offer it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/config_parser.rs | 73 +++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 1380ad7c6..9fb1f8a08 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -1200,6 +1200,30 @@ 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())); + } + // 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 @@ -3470,7 +3494,9 @@ mod tests { 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. - let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"url":"http://proxy.example.com:8080"}}}"#; + // 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(); @@ -3481,6 +3507,51 @@ mod tests { 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 + ); + } + #[test] fn proxy_localhost_rejected_with_lxc() { // network.proxy.localhost maps to 127.0.0.1, unreachable from inside From cfd062c23752bbd6179b596be26989ac82035536 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 13:19:41 -0700 Subject: [PATCH 27/55] [LXC] Fail closed when an allow rule can outrank an unresolvable deny Under a denying default an unresolvable blocked host was downgraded to a warning, reasoning that the chain's closing DROP already covers whatever the missing rule would have covered. That is true only while the closing DROP is what the traffic actually reaches. Allow rules are evaluated first, and `resolve_host` passes CIDRs through unchanged, so a single entry such as `0.0.0.0/0` legally emits an ACCEPT for the entire address space ahead of it. The destination the operator explicitly named as blocked is then accepted by a rule written for an unrelated purpose, and the only trace is a warning line. The previous note in the doc comment claimed this gap could not be closed without knowing the address the deny failed to resolve to. That framing is what hid the fix: the unknown address is the reason to fail, not an obstacle to deciding. Precisely because the address is unknown, no allow rule can be shown not to cover it, so deny precedence cannot be established at all. A deny that cannot be shown to win does not win. Fail closed on that combination and name both halves in the error, so the operator is told which blocked host is unresolvable and why the allow list is implicated. The no-allow case keeps its warning, since with nothing able to ACCEPT first the original reasoning still holds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../lxc/common/src/network_iptables.rs | 111 +++++++++++++++++- 1 file changed, 106 insertions(+), 5 deletions(-) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 1f16e45da..bb7a9195c 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -923,11 +923,15 @@ impl NetworkIptablesManager { /// case being a blocklist naming a host that does not exist at all — and a /// warning is the proportionate response. /// - /// Residual gap, deliberately not closed here: under [`NetworkPolicy::Block`] - /// a sufficiently broad allow entry can still cover a destination whose deny - /// rule went unwritten. Detecting that needs the address the entry failed to - /// resolve to, so no predicate over the policy text can be complete, and a - /// partial check would imply a guarantee this code cannot make. + /// 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. So under [`NetworkPolicy::Block`] the + /// combination of an unresolvable deny and any programmed allow fails + /// closed as well. Deciding it does not require the address the deny failed + /// to resolve to: precisely because that address is unknown, no allow can + /// be shown to miss it, and a deny that cannot be shown to win does not + /// win. /// /// An unresolvable allow entry is always a warning: it withholds traffic /// that was meant to be permitted, which costs availability and cannot @@ -939,6 +943,8 @@ impl NetworkIptablesManager { ) -> 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 programmed_an_allow = false; let entries = policy .blocked_hosts .iter() @@ -961,7 +967,12 @@ impl NetworkIptablesManager { 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) { + programmed_an_allow = true; } let rule_args = Self::build_resolved_destination_rule_args(chain_name, &destinations, &action); @@ -979,6 +990,30 @@ impl NetworkIptablesManager { } args.extend(rule_args); } + // Under a denying default an unresolvable deny was tolerated on the + // grounds that the chain's closing DROP covers whatever the missing + // rule would have covered. That holds only while nothing can ACCEPT + // first. A programmed allow -- and `resolve_host` passes CIDRs through + // untouched, so `0.0.0.0/0` is a legal one -- emits its ACCEPT ahead of + // that DROP. Because the deny never resolved, its addresses are + // unknown, so no allow can be shown to miss them; the very destination + // the operator named is then accepted by a rule they wrote for an + // unrelated purpose. Deny-wins cannot be established, so fail closed. + if !unresolved_denies.is_empty() && programmed_an_allow { + return Err(format!( + "blocked host(s) {} resolved to no address, so no rule can be programmed \ + to deny them, and this policy also programs an allow rule that is \ + evaluated before the chain's closing DROP; because the blocked host has \ + no known address, that allow cannot be shown not to cover it, and deny \ + precedence cannot be guaranteed. Fix or remove the unresolvable blocked \ + host, or remove the allowed hosts", + unresolved_denies + .iter() + .map(|h| format!("'{}'", h)) + .collect::>() + .join(", ") + )); + } Ok(args) } @@ -3045,6 +3080,72 @@ 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_when_an_allow_is_programmed() { + // The allow is evaluated before the chain's closing DROP, and the deny + // has no known address, so nothing establishes deny precedence. + 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 broad 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 a_narrow_allow_is_equally_fatal_because_the_deny_address_is_unknown() { + // The check cannot depend on how broad the allow looks: the deny never + // resolved, so a narrow allow cannot be shown to miss it either. + 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_err("a narrow allow cannot be proven disjoint from an unresolved 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 allow_and_deny_actions_map_to_exact_iptables_jump_targets() { assert_eq!( From aafb0de20db2180bbf80177043a2d18089fe5359 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 13:29:05 -0700 Subject: [PATCH 28/55] [LXC] Claim FORWARD hooks before installing them, not after Ownership was published after each `iptables -I` returned, which still leaves a window the signal handler cannot see through: the kernel has accepted the rule, this process has not yet recorded it, and a fatal signal landing in between finds a snapshot that does not mention the hook. Cleanup then skips it, and the surviving rule holds a reference that keeps the chain undeletable, so the leak outlives the process that created it. The physdev hook is the easiest one to lose this way, but the plain interface hook has the same shape. Claim each hook before the command runs. That converts the failure mode from an under-claim to an over-claim, and an over-claimed hook costs nothing: removal is by full rule specification, which names this attempt's own chain, so a `-D` matching nothing is a no-op and cannot touch another container. Chains deliberately keep the old order. Unlike `-I`, which always inserts, `-N` fails when the name is already taken, and the chain that already exists in that case belongs to someone else. Claiming a chain up front would let the rollback of a failed create delete a live chain this attempt never installed, trading a leak for the removal of another container's enforcement. That is a worse trade, so it is not made, and the reasoning is recorded next to the code rather than left to be rediscovered. The test drives the observable that separates the two orderings: a hook whose insert did not complete must still be removed by the rollback. It fails against the previous ordering, which issued the insert and then went straight to flushing the chain without ever attempting the delete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../lxc/common/src/network_iptables.rs | 90 +++++++++++++++++-- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index bb7a9195c..4b9f091f9 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -1568,6 +1568,8 @@ impl NetworkIptablesManager { )); } + created.v4_hook = true; + Self::publish_created(created); Self::run_iptables_rule_args( &[Self::build_forward_hook_iface_rule_args( "-I", @@ -1576,9 +1578,9 @@ impl NetworkIptablesManager { )], logger, )?; - created.v4_hook = true; - Self::publish_created(created); + created.v4_physdev_hook = true; + Self::publish_created(created); created.v4_physdev_hook = Self::install_physdev_hook( Self::run_iptables_rule_args, iface, @@ -1603,6 +1605,8 @@ impl NetworkIptablesManager { )); } + created.v6_hook = true; + Self::publish_created(created); Self::run_ip6tables_rule_args( &[Self::build_forward_hook_iface_rule_args( "-I", @@ -1611,9 +1615,9 @@ impl NetworkIptablesManager { )], logger, )?; - created.v6_hook = true; - Self::publish_created(created); + created.v6_physdev_hook = true; + Self::publish_created(created); created.v6_physdev_hook = Self::install_physdev_hook( Self::run_ip6tables_rule_args, iface, @@ -1676,6 +1680,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); } @@ -1912,6 +1933,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! { @@ -1934,6 +1958,7 @@ mod test_firewall { issued: Vec::new(), scripted: VecDeque::new(), fallback: Ok(()), + fail_matching: None, }); }); FakeFirewall @@ -1952,6 +1977,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()) @@ -1984,7 +2020,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 @@ -3146,6 +3187,45 @@ mod tests { .expect("an allow that programs no rule cannot accept the unresolved deny"); } + #[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!( From 19ac10751e8cac057d30ad679f4938497531db18 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 13:33:00 -0700 Subject: [PATCH 29/55] [LXC] Clear a stale proxy host pin when a run pins nothing Pinning is self-cleaning: the command filters its own marker out of /etc/hosts before appending the new mapping, so a run that pins always replaces whatever the last one left. A run that pins *nothing* never reaches that path, and with destroyOnExit=false the container outlives the run that wrote the pin. The next execution then starts against an /etc/hosts still mapping a hostname to an address only the previous policy authorized. That is a policy bypass rather than untidiness. The firewall rules for the new run are built from the addresses this run resolves, so a deny can be programmed against the address a hostname resolves to now while the container continues to reach the address pinned earlier. The DROP is real, correct, and aimed at somewhere the traffic no longer goes. Clear the pin whenever this run has none and did not create the container -- a container this run created cannot be carrying one. Treat a failed clear the way a failed pin is treated, because a pin that cannot be removed cannot be reasoned about: the policy is unenforceable, so the script does not run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/backends/lxc/common/src/lxc_runner.rs | 84 +++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index b9c069251..6df911c17 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -275,6 +275,35 @@ impl LxcScriptRunner { 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). @@ -357,6 +386,26 @@ impl LxcScriptRunner { hosts_line = hosts_line ) } + + /// 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 four utilities so it runs under BusyBox. + fn build_hosts_unpin_command() -> String { + format!( + "{{ grep -v '{marker}' /etc/hosts 2>/dev/null; }} > /tmp/.mxc-hosts \ + && cat /tmp/.mxc-hosts > /etc/hosts && rm -f /tmp/.mxc-hosts", + marker = HOSTS_PIN_MARKER + ) + } } impl ScriptRunner for LxcScriptRunner { @@ -442,6 +491,41 @@ mod tests { ); } + #[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}" + ); + assert!( + !command.contains("printf"), + "clearing must not append a replacement mapping; got: {command}" + ); + 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] From c3d133fb869ea8aae2d5be138be9c706c1e841ca Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 14:16:43 -0700 Subject: [PATCH 30/55] [LXC] Derive the chain name in the specs instead of hard-coding it PR #780 landed on main and gives every chain a hashed `MXC--` name, so the literals these specs matched on ("MXC-acme-web", "MXC-proxy-order", and seven more) no longer name any chain the manager creates. Two of them failed outright. The other five were worse: they filtered the issued commands by the stale name, got an empty list back, and looped zero times, so they passed while asserting nothing. Three of those are the "proxy mode omits X" tests, whose whole job is to notice an unwanted rule. Both are fixed by asking the manager for its own chain name rather than restating it, so the specs follow any future renaming. The three loop-based tests also assert the filtered list is non-empty, which is what would have caught this as a failure instead of a silent pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../common/src/network_iptables_proxy_spec.rs | 52 +++++++++++++------ .../common/src/network_iptables_veth_spec.rs | 4 +- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/backends/lxc/common/src/network_iptables_proxy_spec.rs b/src/backends/lxc/common/src/network_iptables_proxy_spec.rs index 4a7535221..d989bbbb5 100644 --- a/src/backends/lxc/common/src/network_iptables_proxy_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_proxy_spec.rs @@ -134,10 +134,10 @@ 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); + 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", "MXC-proxy-allow-default"); + 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), @@ -157,10 +157,10 @@ fn an_applied_proxy_chain_ends_in_drop_under_an_allow_default() { 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); + 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", "MXC-proxy-shape"); + let rules = appended_rules(&issued, "iptables", manager.chain_name()); let accepts: Vec<&&Vec> = rules .iter() .filter(|rule| action_of(rule) == Some("ACCEPT")) @@ -192,10 +192,10 @@ fn the_proxy_accept_names_the_proxy_address_port_and_protocol() { 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); + 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", "MXC-proxy-order"); + let rules = appended_rules(&issued, "iptables", manager.chain_name()); let actions: Vec> = rules.iter().map(|rule| action_of(rule)).collect(); assert_eq!( @@ -238,10 +238,18 @@ fn every_resolved_proxy_address_is_opened() { 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); + let (manager, issued, result) = apply_and_collect("proxy-nodns", &policy); assert!(result.is_ok(), "apply must succeed, got {result:?}"); - for rule in appended_rules(&issued, "iptables", "MXC-proxy-nodns") { + 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:?}" @@ -256,10 +264,16 @@ fn proxy_mode_opens_no_dns_port() { 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); + let (manager, issued, result) = apply_and_collect("proxy-nobase", &policy); assert!(result.is_ok(), "apply must succeed, got {result:?}"); - for rule in appended_rules(&issued, "iptables", "MXC-proxy-nobase") { + 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:?}" @@ -280,10 +294,16 @@ fn proxy_mode_programs_neither_the_allow_list_nor_the_block_list() { 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); + let (manager, issued, result) = apply_and_collect("proxy-nolists", &policy); assert!(result.is_ok(), "apply must succeed, got {result:?}"); - for rule in appended_rules(&issued, "iptables", "MXC-proxy-nolists") { + 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:?}" @@ -298,10 +318,10 @@ fn proxy_mode_programs_neither_the_allow_list_nor_the_block_list() { 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); + 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", "MXC-proxy-v6"); + let rules = appended_rules(&issued, "ip6tables", manager.chain_name()); let actions: Vec> = rules.iter().map(|rule| action_of(rule)).collect(); assert_eq!( @@ -322,10 +342,10 @@ fn without_a_proxy_the_base_exemptions_and_host_lists_are_still_programmed() { ..Default::default() }; - let (_manager, issued, result) = apply_and_collect("proxy-control", &policy); + 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", "MXC-proxy-control"); + 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:?}" diff --git a/src/backends/lxc/common/src/network_iptables_veth_spec.rs b/src/backends/lxc/common/src/network_iptables_veth_spec.rs index 1b565cff6..2b14eac83 100644 --- a/src/backends/lxc/common/src/network_iptables_veth_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_veth_spec.rs @@ -76,7 +76,7 @@ fn refusal_error_names_the_unenforced_chain() { .apply_firewall_rules(&policy, &mut logger) .expect_err("Firewall mode with no veth interface set must fail closed"); - let chain = "MXC-acme-web"; + let chain = manager.chain_name(); assert!( err.contains(chain), "error must name the chain left unenforced ({chain}), got: {err}" @@ -135,7 +135,7 @@ fn apply_tears_down_the_chain_it_created_when_it_fails_closed() { ); let issued = fake.issued(); - let chain = "MXC-ctrl-teardown"; + let chain = manager.chain_name(); let creation_index = issued .iter() .position(|cmd| cmd.iter().any(|a| a == "-N") && cmd.iter().any(|a| a == chain)) From c7c61425d8d3e90c4120c0bb8274237a48124675 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 14:17:02 -0700 Subject: [PATCH 31/55] [LXC] Narrow the deny-precedence failure to a catch-all allow cfd062c failed the apply whenever an unresolvable blocked host sat beside any programmed allow. That was too broad, and the repository's own lxc_network_test.json is the counterexample: it allows api.github.com and blocks evil.example.com, which does not exist. The allow resolves to a handful of GitHub addresses, the chain's closing DROP still covers everything else, and nothing there shows the blocked host is one of those addresses -- yet the apply was rejected. CI caught it; my local run passed only because the name resolved here. The check now fires when an allow entry has a prefix length of zero. That is the case where the deny is *provably* defeated: 0.0.0.0/0 or ::/0 accepts whatever the blocked host turns out to resolve to for the container, so no further evidence could rescue it. Narrower allows go back to a warning. I had argued the opposite -- that an unknown deny address means no allow can be shown to miss it -- and the reasoning was symmetric but the consequence was not. Rejecting an allowlist beside a blocked host that no longer exists makes an ordinary policy a hard error, and the cheapest way to clear that error is to delete the blocklist entry. Trading a recorded warning for a silently shortened blocklist leaves the deployment less protected than the residual risk being removed. The tests cover both directions: a /0 allow in either family is fatal, a literal and a /24 are not. Mutating covers_every_address to never fire loses the two fatal tests; mutating it to accept any prefix loses the /24 test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../lxc/common/src/network_iptables.rs | 134 ++++++++++++++---- 1 file changed, 103 insertions(+), 31 deletions(-) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 17f8be317..e5417a41d 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -521,6 +521,21 @@ impl NetworkIptablesManager { } } + /// 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 @@ -1005,12 +1020,17 @@ impl NetworkIptablesManager { /// 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. So under [`NetworkPolicy::Block`] the - /// combination of an unresolvable deny and any programmed allow fails - /// closed as well. Deciding it does not require the address the deny failed - /// to resolve to: precisely because that address is unknown, no allow can - /// be shown to miss it, and a deny that cannot be shown to win does not - /// win. + /// 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 @@ -1023,7 +1043,7 @@ impl NetworkIptablesManager { 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 programmed_an_allow = false; + let mut catch_all_allows: Vec<&str> = Vec::new(); let entries = policy .blocked_hosts .iter() @@ -1050,8 +1070,14 @@ impl NetworkIptablesManager { unresolved_denies.push(host); } logger.log_line(&format!("Warning: could not resolve host '{}'", host)); - } else if matches!(action, RuleAction::Allow) { - programmed_an_allow = true; + } 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); @@ -1069,24 +1095,38 @@ impl NetworkIptablesManager { } args.extend(rule_args); } - // Under a denying default an unresolvable deny was tolerated on the + // 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 nothing can ACCEPT - // first. A programmed allow -- and `resolve_host` passes CIDRs through - // untouched, so `0.0.0.0/0` is a legal one -- emits its ACCEPT ahead of - // that DROP. Because the deny never resolved, its addresses are - // unknown, so no allow can be shown to miss them; the very destination - // the operator named is then accepted by a rule they wrote for an - // unrelated purpose. Deny-wins cannot be established, so fail closed. - if !unresolved_denies.is_empty() && programmed_an_allow { + // 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, and this policy also programs an allow rule that is \ - evaluated before the chain's closing DROP; because the blocked host has \ - no known address, that allow cannot be shown not to cover it, and deny \ - precedence cannot be guaranteed. Fix or remove the unresolvable blocked \ - host, or remove the allowed hosts", + 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::>() @@ -3198,9 +3238,10 @@ mod tests { const UNRESOLVABLE_HOST: &str = "blocked.invalid"; #[test] - fn an_unresolvable_deny_under_a_blocking_default_is_fatal_when_an_allow_is_programmed() { - // The allow is evaluated before the chain's closing DROP, and the deny - // has no known address, so nothing establishes deny precedence. + 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]) @@ -3208,7 +3249,7 @@ mod tests { 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 broad allow must not be able to accept an unresolvable deny"); + .expect_err("a catch-all allow must not be able to accept an unresolvable deny"); assert!( err.contains(UNRESOLVABLE_HOST) && err.contains("deny precedence"), @@ -3217,9 +3258,25 @@ mod tests { } #[test] - fn a_narrow_allow_is_equally_fatal_because_the_deny_address_is_unknown() { - // The check cannot depend on how broad the allow looks: the deny never - // resolved, so a narrow allow cannot be shown to miss it either. + 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]) @@ -3227,7 +3284,22 @@ mod tests { 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 narrow allow cannot be proven disjoint from an unresolved deny"); + .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] From 130b1c9d2982e1225e288e2d3ebbd30abf1aa42f Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 14:25:27 -0700 Subject: [PATCH 32/55] [LXC] Reject proxy URLs that carry credentials lxc-attach receives the proxy environment as --set-var command-line arguments (lxc_bindings.rs:117) and is spawned as a real process (lxc_bindings.rs:183), so any userinfo in the proxy URL lands in /proc//cmdline, which is world-readable at the default hidepid=0. ProxyAddress::to_url returns original_url verbatim -- the repository's own proxy_address_spec.rs:158 asserts credentials survive that round trip -- so nothing between the config file and argv strips them. The codebase already treats userinfo as secret when it logs: redact_proxy_url exists in proxy_env.rs for exactly that, and config_parser has a test asserting a scheme error does not echo a password. Redaction covered the logs and missed argv. Reject the config at parse time instead of trying to redact at the boundary. Detection and the error message both go through redact_proxy_url, so the two cannot drift apart and the rejection itself cannot become the leak it guards against. Scoped to the LXC backend. bwrap_command.rs:296 has the same shape, but that line is already on main and this branch does not touch that file, so fixing it here would widen the diff past what this PR is for. Mutating the guard to never fire loses two of the new tests; mutating it to fire on any URL loses four, two of which predate this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/config_parser.rs | 99 ++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 7cffbb8c3..a53865b09 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -1240,6 +1240,45 @@ fn convert_wire_config( 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::redact_proxy_url(&url) != 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 @@ -3609,6 +3648,66 @@ mod tests { ); } + #[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 From 3de0f5b8a370b7967f0fec54b2b7765f9b4105c3 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 14:35:10 -0700 Subject: [PATCH 33/55] [LXC] Stop staging /etc/hosts through a predictable /tmp file The proxy pin filtered /etc/hosts into /tmp/.mxc-hosts and copied it back. /tmp belongs to the container, the name is fixed, and `>` follows symlinks -- so on a container reused across runs (destroy_on_exit = false, which is what makes the pin need to be idempotent in the first place) a previous workload could pre-create that name as a symlink and aim the redirect somewhere else. The command runs privileged through lxc-attach, so the target could be another container file or a host path exposed through a writable bind mount. Measured both forms against a planted symlink. The old one overwrote the link target; the new one left it untouched and wrote /etc/hosts correctly. The kept lines now stage in a shell variable instead. That removes a failure window rather than adding one: the substitution completes before the redirect truncates /etc/hosts, so nothing that could fail runs against the truncated file -- where the old form still had a `cat` to survive. It also drops two external commands, leaving grep and printf. Verified the emitted shell under both bash and BusyBox ash: empty file, existing content, four consecutive pins leaving one marker, unpin restoring the original, unpin with no pin present exiting 0, unpin down to an empty file, and a missing /etc/hosts. The unpin test asserted the command contained no `printf` at all. That worked only while printf was the sole way a line could be written, and re-emitting the kept lines needs one now. Replaced it with the invariant it was standing in for, which the test already asserted alongside it: the marker's single appearance is inside the filter, so no marked line can be written. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/backends/lxc/common/src/lxc_runner.rs | 109 +++++++++++++++++++--- 1 file changed, 94 insertions(+), 15 deletions(-) diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index 6df911c17..15519ac80 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -366,22 +366,39 @@ impl LxcScriptRunner { /// previously written entry is stripped by its marker before the new one /// is appended. /// - /// The file is rewritten with `cat >` rather than `mv`, because LXC may + /// 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`, `printf`, `cat`, and `rm` are - /// used, so this runs under BusyBox as well as coreutils. + /// 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 { - // The group's exit status is printf's, so a grep that matches nothing - // and exits 1 does not abort the chain. + // `$(...)` 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!( - "{{ grep -v '{marker}' /etc/hosts 2>/dev/null; \ - printf '%s {marker}\\n' '{hosts_line}'; }} > /tmp/.mxc-hosts \ - && cat /tmp/.mxc-hosts > /etc/hosts && rm -f /tmp/.mxc-hosts", + "kept=$(grep -v '{marker}' /etc/hosts 2>/dev/null); \ + {{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; \ + printf '%s {marker}\\n' '{hosts_line}'; }} > /etc/hosts", marker = HOSTS_PIN_MARKER, hosts_line = hosts_line ) @@ -398,11 +415,12 @@ impl LxcScriptRunner { /// evaded at another. /// /// Uses the same rewrite-in-place form as the pin, for the same - /// bind-mount reason, and the same four utilities so it runs under BusyBox. + /// bind-mount reason, and the same variable staging for the same + /// symlink reason. fn build_hosts_unpin_command() -> String { format!( - "{{ grep -v '{marker}' /etc/hosts 2>/dev/null; }} > /tmp/.mxc-hosts \ - && cat /tmp/.mxc-hosts > /etc/hosts && rm -f /tmp/.mxc-hosts", + "kept=$(grep -v '{marker}' /etc/hosts 2>/dev/null); \ + {{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; }} > /etc/hosts", marker = HOSTS_PIN_MARKER ) } @@ -499,10 +517,12 @@ mod tests { command.contains(&format!("grep -v '{}'", HOSTS_PIN_MARKER)), "the command must filter out every marked line; got: {command}" ); - assert!( - !command.contains("printf"), - "clearing must not append a replacement mapping; 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, @@ -555,4 +575,63 @@ mod tests { ); } } + + // `/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}" + ); + } + } } From c00f94199579d31cb0dd899e4f0332dad970ec31 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 14:35:24 -0700 Subject: [PATCH 34/55] [LXC] Fail closed on a proxy the enforcement mode will not enforce apply_firewall_rules returned Ok(true) whenever the enforcement mode was not firewall or both, which reports success for an enforcement that did not happen. With a proxy in the same policy that is the dangerous outcome rather than the safe one: the runner injects HTTP(S)_PROXY from that policy either way, so the container advertises a proxy and restricts nothing, and any client ignoring the environment reaches the network directly. The JSON parser already 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 (models.rs:302-309) -- so a policy constructed in code lands in the unenforced mode without anyone choosing it. The guard belongs in the layer that can observe whether rules were installed, because that is the layer every caller passes through. The refusal is narrow: capabilities without a proxy is still an ordinary supported no-op. builtinTestServer is covered too, since it enables the proxy without an address and takes the same injection path, so the gate cannot key on the address alone. Mutating the guard to never fire loses the two refusal tests; mutating it to always fire loses three, two of which predate this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../lxc/common/src/network_iptables.rs | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index e5417a41d..e62750458 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -1421,6 +1421,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); } @@ -2185,7 +2211,7 @@ mod tests { use super::*; use std::io::{Error, ErrorKind}; use wxc_common::logger::{Logger, Mode}; - use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode}; + use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode, ProxyAddress, ProxyConfig}; /// Build a policy requesting the given enforcement mode, leaving every /// other field at its default. @@ -3691,6 +3717,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 { From 8e1cf62beed8a8c13c34b9b0c00de2cec81533ba Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 15:13:55 -0700 Subject: [PATCH 35/55] [LXC] Carry the reply path for allowed outbound connections Both FORWARD hooks are ingress-only: one matches `-i `, the other `--physdev-in `. A reply arrives in the opposite direction, matches neither, and falls through to the host's FORWARD policy. Under Docker's DROP default that reply is dropped, so an explicitly allowed destination -- and the proxy itself, when it is off-host -- is unreachable. The chain is fully populated and enforcing; the connection simply never completes. The reviewer raised this on the E2E workflow, where `-P FORWARD ACCEPT` hides it. I first assumed Ubuntu's lxc-net would carry the reply with its usual `-o lxcbr0 -j ACCEPT`, which would have made this a test-only artifact. The FORWARD chain captured from a real CI run refutes that: it holds only DOCKER-USER and DOCKER-FORWARD, with no lxcbr0 rule at any point in the run. The defect is in the product, not the workflow. Install a return-path ACCEPT per family, in both attachment forms, scoped to the container's own port: -I FORWARD -o -m state --state ESTABLISHED,RELATED -j ACCEPT -I FORWARD -m physdev --physdev-out -m state \ --state ESTABLISHED,RELATED -j ACCEPT Jumping the reply direction into the MXC chain would have been shorter, since the chain already opens with an ESTABLISHED,RELATED accept. It is wrong: the chain's remaining rules are `-d ` egress shapes, so inbound NEW packets would be tested against them and, under `defaultPolicy: allow`, reach the chain's closing ACCEPT. That is an inbound enforcement surface acquired by accident, with the wrong semantics. The state match keeps these rules unable to admit anything conntrack does not already know about. Install failure is a warning, not an error. The ingress hook is what confines traffic to the chain, so losing it fails open and must be fatal; a rule that only ever ACCEPTs can at worst leave the container less connected. Refusing to start a container whose policy is fully installed would be the wrong trade. Ownership is claimed before insertion, matching the hooks: a signal between the kernel accepting the rule and the process recording it would otherwise leak an ACCEPT naming a veth the kernel is free to hand to a different container. Teardown deletes from the same builders, and the return rules do not gate the chain delete because they never reference the chain. Thirteen tests, all mutation-tested in both directions. The first pass of the lifecycle tests was itself defective: the builders are family-agnostic, so an IPv4 and an IPv6 rule differ only by which binary issued them, and assertions that ignored the binary were satisfied by whichever family still worked. Two mutations survived because of it. The assertions now pin the tool. Still outstanding, and named in the PR description: dropping `-P FORWARD ACCEPT` from the E2E workflow and asserting the hook packet counters, so the deny cases cannot pass vacuously. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../lxc/common/src/network_iptables.rs | 404 +++++++++++++++++- .../src/network_iptables_forward_hook_spec.rs | 175 ++++++++ 2 files changed, 577 insertions(+), 2 deletions(-) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index e62750458..14c0f91a1 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -97,6 +97,10 @@ pub(crate) struct CreatedResources { 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. @@ -141,6 +145,10 @@ impl CreatedResources { && !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 @@ -156,6 +164,10 @@ impl CreatedResources { v6_hook, v4_physdev_hook: false, v6_physdev_hook: false, + v4_return: false, + v6_return: false, + v4_physdev_return: false, + v6_physdev_return: false, } } } @@ -521,6 +533,97 @@ impl NetworkIptablesManager { } } + /// 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. A packet only matches + /// `ESTABLISHED,RELATED` if conntrack already has an entry for the flow, + /// and the flow can only have an entry because its outbound direction was + /// accepted by the chain. Inbound *new* connections match nothing here and + /// are left to the host 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 @@ -1692,8 +1795,13 @@ impl NetworkIptablesManager { // the ruleset filtering nothing. // // The two are mutually exclusive for any given packet, so no packet is - // counted twice. `-o` would instead match traffic flowing toward the - // container. + // 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 { let bridged = Self::veth_is_bridge_enslaved(iface); let chain_name = self.chain_name.clone(); @@ -1735,6 +1843,30 @@ impl NetworkIptablesManager { 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, chain_name @@ -1773,6 +1905,27 @@ impl NetworkIptablesManager { )?; 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, chain_name @@ -1919,6 +2072,48 @@ impl NetworkIptablesManager { { 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 @@ -4161,4 +4356,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_forward_hook_spec.rs b/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs index c87356eea..72119122f 100644 --- a/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs @@ -456,3 +456,178 @@ fn a_bridge_netfilter_toggle_with_an_unrecognized_value_is_not_active() { "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" + ); + } + } +} From 739d23f1a7b9da6df7dab72e0e5dba9e2c33f753 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 15:22:37 -0700 Subject: [PATCH 36/55] [LXC] Run the E2E suite under the forward policy production has The suite forced `-P FORWARD ACCEPT`. That was load-bearing for a real reason: the FORWARD hooks were ingress-only, so the reply to an allowed request matched no MXC rule and was dropped by the policy, and an explicitly allowed destination looked unreachable. Forcing ACCEPT made the suite pass -- and hid the defect from every run. It is fixed in 8e1cf62, and leaving ACCEPT in place would now hide whether that fix works. The other reason for forcing ACCEPT was vacuity: under DROP a container with no working hook is equally unreachable, so a deny-only assertion would report success against a firewall filtering nothing. That is answered by pairing, not by policy, and the pairing already exists. Every script here that asserts a block also asserts a reachability in the same run -- the allow cases in run_lxc_network_enforcement_test.sh and run_lxc_network_deny_precedence_test.sh, and proxy reachability in run_lxc_network_proxy_test.sh. A broken hook or a missing return rule fails those loudly under either policy. The remaining network scripts assert programmed rule shapes and log lines, which do not depend on the forward policy at all. Set DROP explicitly rather than inheriting whatever Docker left, so a future runner image that happens to default to ACCEPT cannot silently weaken the suite. This also makes the run the measurement I could not make locally: whether `--physdev-out` matches bridged return traffic. The ingress direction was measured at 11 packets against 0 for the interface form; the reverse was not. If it does not match, the allow cases fail here rather than in a customer's DROP-policy host. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .github/workflows/lxc-e2e.yml | 58 +++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/.github/workflows/lxc-e2e.yml b/.github/workflows/lxc-e2e.yml index 0d6f81172..a24d3d59f 100644 --- a/.github/workflows/lxc-e2e.yml +++ b/.github/workflows/lxc-e2e.yml @@ -46,35 +46,41 @@ jobs: 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. + # Run under the forward policy production actually has. # - # 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)`. + # GitHub-hosted runners ship Docker, which sets the IPv4 FORWARD policy to + # DROP -- the same policy any host running Docker has. This suite used to + # force it to ACCEPT, for a reason that was real at the time: MXC hooked + # its chain only on traffic leaving the container (`-i ` / + # `--physdev-in `), so the reply to an allowed request matched no + # MXC rule, fell through to the policy, and was dropped. 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. + # That was a product defect, not a harness quirk, and forcing ACCEPT hid + # it from every run. The container now installs a conntrack-scoped return + # rule for its own port, so replies are carried by MXC's own rules rather + # than by the host's default. Forcing ACCEPT would now hide whether that + # fix works. # - # 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 would fix the reply path - # but leave the DROP policy, and with it the vacuous pass. - - name: Let the host forward, so only MXC rules can block + # The second, subtler reason for forcing ACCEPT was vacuity: under DROP a + # container with no working hook at all is equally unreachable, so a + # deny-only assertion would report success against a firewall filtering + # nothing. That is answered by pairing rather than by policy. Every script + # here that asserts a block also asserts a reachability in the same run -- + # the allow cases in the enforcement and deny-precedence scripts, and + # proxy reachability in the proxy script. A broken hook or a missing + # return rule fails those loudly, under either policy. The remaining + # scripts assert programmed rule shapes and log lines, which do not depend + # on the forward policy at all. + # + # Set explicitly rather than inherited, so a future runner image that + # happens to default to ACCEPT cannot silently weaken the suite. + - name: Force the production forward policy, so the return path is tested run: | - sudo iptables -P FORWARD ACCEPT - sudo ip6tables -P FORWARD ACCEPT + sudo iptables -P FORWARD DROP + sudo ip6tables -P FORWARD DROP sudo iptables -S FORWARD | head -5 - name: Report the environment these tests depend on @@ -86,7 +92,7 @@ jobs: 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) ---" + echo "--- forward policy (must be DROP: production condition, and what exercises the return rules) ---" sudo iptables -S FORWARD | head -1 sudo ip6tables -S FORWARD | head -1 echo "--- bridge netfilter ---" From d983827e95781a389708ccd389baf0996afe8241 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 15:37:55 -0700 Subject: [PATCH 37/55] Revert "[LXC] Run the E2E suite under the forward policy production has" This reverts 739d23f. The flip did its job: it failed, and the failure is a real finding rather than a harness problem. Under `-P FORWARD DROP` the enforcement suite's allow case failed -- `MXC_NET_BLOCKED` for a destination the policy explicitly allowed -- with the return-path rules from 8e1cf62 installed and no warning logged. So the rules install and still do not carry the reply. The cause is documented in iptables-extensions(8): `--physdev-out` names "a bridge port via which a packet is going to be sent (for bridged packets entering the FORWARD and POSTROUTING chains)". A reply from the internet arrives on the host's uplink and is *routed* toward lxcbr0; the bridge port has not been selected when FORWARD runs, so the physdev form cannot match. The interface form cannot match either, because the routing output device is lxcbr0, not the veth. The direction is asymmetric on purpose: `--physdev-in` works because the packet demonstrably arrived on the veth, and the ingress hooks measured 11 packets against 0. Restoring `-P FORWARD ACCEPT` keeps this suite green while the return path is scoped correctly. That is not a fix and is not being presented as one; the gap is recorded in the PR description and on the review thread. The scoping that can work is the container's own address rather than its port -- the address is already discovered in lxc_runner.rs `wait_for_network`, which today logs it and throws it away. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .github/workflows/lxc-e2e.yml | 58 ++++++++++++++++------------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/.github/workflows/lxc-e2e.yml b/.github/workflows/lxc-e2e.yml index a24d3d59f..0d6f81172 100644 --- a/.github/workflows/lxc-e2e.yml +++ b/.github/workflows/lxc-e2e.yml @@ -46,41 +46,35 @@ jobs: sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=1 - # Run under the forward policy production actually has. + # GitHub-hosted runners ship Docker, and Docker sets the IPv4 FORWARD + # policy to DROP. That breaks these tests twice over. # - # GitHub-hosted runners ship Docker, which sets the IPv4 FORWARD policy to - # DROP -- the same policy any host running Docker has. This suite used to - # force it to ACCEPT, for a reason that was real at the time: MXC hooked - # its chain only on traffic leaving the container (`-i ` / - # `--physdev-in `), so the reply to an allowed request matched no - # MXC rule, fell through to the policy, and was dropped. 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)`. + # 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)`. # - # That was a product defect, not a harness quirk, and forcing ACCEPT hid - # it from every run. The container now installs a conntrack-scoped return - # rule for its own port, so replies are carried by MXC's own rules rather - # than by the host's default. Forcing ACCEPT would now hide whether that - # fix works. + # 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. # - # The second, subtler reason for forcing ACCEPT was vacuity: under DROP a - # container with no working hook at all is equally unreachable, so a - # deny-only assertion would report success against a firewall filtering - # nothing. That is answered by pairing rather than by policy. Every script - # here that asserts a block also asserts a reachability in the same run -- - # the allow cases in the enforcement and deny-precedence scripts, and - # proxy reachability in the proxy script. A broken hook or a missing - # return rule fails those loudly, under either policy. The remaining - # scripts assert programmed rule shapes and log lines, which do not depend - # on the forward policy at all. - # - # Set explicitly rather than inherited, so a future runner image that - # happens to default to ACCEPT cannot silently weaken the suite. - - name: Force the production forward policy, so the return path is tested + # 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 would fix the reply path + # but leave the DROP policy, and with it the vacuous pass. + - name: Let the host forward, so only MXC rules can block run: | - sudo iptables -P FORWARD DROP - sudo ip6tables -P FORWARD DROP + sudo iptables -P FORWARD ACCEPT + sudo ip6tables -P FORWARD ACCEPT sudo iptables -S FORWARD | head -5 - name: Report the environment these tests depend on @@ -92,7 +86,7 @@ jobs: echo "--- iptables ---" sudo iptables --version || echo "MISSING iptables" sudo ip6tables --version || echo "MISSING ip6tables" - echo "--- forward policy (must be DROP: production condition, and what exercises the return rules) ---" + 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 ---" From d93bf12dd3369d014a73b41c264eba11262c4501 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 16:00:35 -0700 Subject: [PATCH 38/55] [LXC] Enforce the no-credential proxy invariant at the runner boundary 130b1c9 rejected credential-bearing proxy URLs, but only in config_parser. ExecutionRequest and ProxyAddress::from_url are both public, so a caller can build a request the parser never saw and hand it straight to LxcScriptRunner. From there 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:117). The password lands in /proc//cmdline, world-readable at the default hidepid=0 -- exactly the exposure the parser guard was added to prevent. Guard the boundary that actually spawns the process. The check sits ahead of container creation and firewall programming, so a rejected request leaves no state to clean up, and the message is built from the redacted URL so the rejection cannot become the leak it is rejecting. Both call sites now share one predicate, proxy_url_has_credentials, rather than each open-coding the test -- the parser previously asked whether redaction changed the string, which reports a URL whose userinfo is already "***" as clean. proxy_env_spec.rs pins that case so the weaker form cannot come back. Tests: 6 spec tests for the predicate, 4 for the runner guard, including the anti-vacuity case (a credential-free URL must clear the guard) and an ordering case (the refusal must precede any container work). Six mutations -- guard removed, message rebuilt from the raw URL, predicate always true, naive contains('@'), the redaction-comparison implementation, and the guard moved after the container announcement -- were all caught by assertion failures rather than by compile errors. 2319 passed, 1 failed (the pre-existing BitLocker D:\secrets test). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/backends/lxc/common/src/lxc_runner.rs | 149 ++++++++++++++++++++ src/core/wxc_common/src/config_parser.rs | 2 +- src/core/wxc_common/src/proxy_env.rs | 20 +++ src/core/wxc_common/tests/proxy_env_spec.rs | 76 +++++++++- 4 files changed, 244 insertions(+), 3 deletions(-) diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index 15519ac80..31b918887 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -131,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 @@ -634,4 +667,120 @@ mod tests { ); } } + + // 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}" + ); + } } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index a53865b09..86e3a8242 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -1257,7 +1257,7 @@ fn convert_wire_config( .address .as_ref() .map(|address| address.to_url()) - .is_some_and(|url| crate::proxy_env::redact_proxy_url(&url) != 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. diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index 22ca02cce..94a00cb32 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -101,6 +101,26 @@ pub fn redact_proxy_url(url: &str) -> String { } } +/// 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 Some((_scheme, rest)) = url.split_once("://") else { + return false; + }; + // Stop at the first path, query, or fragment delimiter: an `@` after that + // point belongs to the path, not to userinfo. + let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + rest[..auth_end].contains('@') +} + /// Build the effective environment for a sandbox whose egress is routed /// through a cooperative proxy at `proxy_url`. /// diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index 57404d01e..9504c8084 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -24,8 +24,8 @@ use wxc_common::models::{ProxyAddress, ProxyConfig}; use wxc_common::proxy_env::{ - apply_cooperative_proxy_env, apply_proxy_env, is_managed_proxy_key, redact_proxy_url, - PROXY_ENV_KEYS, PROXY_NEUTRALIZE_KEYS, PROXY_SET_KEYS, + 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"; @@ -431,3 +431,75 @@ fn redact_proxy_url_ignores_at_sign_in_path() { 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`" + ); +} + +// A string with no scheme separator has no authority to parse, so there is no +// userinfo to find and the guard must not refuse it on a spurious match. +#[test] +fn a_url_without_a_scheme_carries_no_credentials() { + assert!(!proxy_url_has_credentials("proxy.example.com:8080")); + assert!(!proxy_url_has_credentials("not-a-url@at-all")); +} From 221f8d106104c324b3d0c742522e7dc025015a07 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 16:18:56 -0700 Subject: [PATCH 39/55] [LXC] Redact the proxy URL in the diagnostics that run before the guard Raised in review. The LXC credential guard runs after convert_wire_proxy succeeds, so a credential-bearing URL that fails an earlier check never reaches it. The host and port diagnostics interpolated the raw url_str, so "http://alice:hunter2@proxy.example.com" -- no port -- put the password in the error and the log, which is precisely what the guard downstream exists to prevent. Redact once at the top of the block and use that in every diagnostic, rather than redacting per site. Per-site redaction is what produced this miss: the scheme error was redacted and the two beside it were not. redact_proxy_url also gave up when the string had no "://" and returned it verbatim. url::Url::parse accepts "alice:hunter2@example.com" as scheme "alice", so such a URL reached the scheme diagnostic with the password intact. It now redacts the scheme:opaque form too. Tests: a parser test for a portless credential-bearing URL, plus two spec tests for the opaque form and its complement. Mutations: the port diagnostic rebuilt from the raw URL, and the opaque redaction turned into a no-op, were both caught by assertion failures. A third mutation -- the host diagnostic rebuilt from the raw URL -- survived, and I am recording that rather than leaving it implied. It survived because the branch is unreachable, not because it is untested: for http/https, url::Url::parse rejects every empty-host input ("empty host") before host_str() is consulted, and that error path formats the ParseError, not the URL. I probed it directly with http://alice:hunter2@, http://@, https://alice@, http://alice:hunter2@/x, http://:8080, and https://user:pw@?q=1 -- all rejected at parse. The redacted form is kept there anyway since it costs nothing and the branch would otherwise be a trap for a future scheme. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/config_parser.rs | 42 ++++++++++++++++++--- src/core/wxc_common/src/proxy_env.rs | 23 ++++++++++- src/core/wxc_common/tests/proxy_env_spec.rs | 27 +++++++++++++ 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 86e3a8242..c27f835b7 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -510,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}")))?; @@ -518,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}" ))); @@ -531,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}" )) })?; @@ -3648,6 +3652,32 @@ mod tests { ); } + // 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 diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index 94a00cb32..ba3f3535f 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -89,9 +89,14 @@ 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. pub fn redact_proxy_url(url: &str) -> String { let Some((scheme, rest)) = url.split_once("://") else { - return url.to_string(); + return redact_opaque_userinfo(url); }; let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); let (authority, tail) = rest.split_at(auth_end); @@ -101,6 +106,22 @@ pub fn redact_proxy_url(url: &str) -> String { } } +/// Redact userinfo from a `scheme:rest` URL that has no `://` authority. +/// +/// `url::Url::parse("alice:hunter2@example.com")` succeeds with scheme `alice`, +/// so such a string reaches the scheme diagnostic with the password intact. +fn redact_opaque_userinfo(url: &str) -> String { + let Some((scheme, rest)) = url.split_once(':') else { + return url.to_string(); + }; + let auth_end = rest.find(['/', '?', '#']).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(), + } +} + /// Whether a proxy URL carries `user:pass@` userinfo. /// /// This is the single definition of "carries credentials", so a backend that diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index 9504c8084..882c03013 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -503,3 +503,30 @@ fn a_url_without_a_scheme_carries_no_credentials() { assert!(!proxy_url_has_credentials("proxy.example.com:8080")); assert!(!proxy_url_has_credentials("not-a-url@at-all")); } + +// 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); +} From 628b0b4a6e5f3a3f4d29eb60e0a3bcf7dbc3ea1e Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 16:46:48 -0700 Subject: [PATCH 40/55] Treat an unreadable sysfs as bridged instead of as directly routed `veth_is_bridge_enslaved_in` decided bridging with `root.join(iface).join("master").exists()`. `Path::exists()` folds every metadata error into `false`, so a masked, unmounted, or permission-denied `/sys/class/net` was read as a positive "this veth is directly routed" finding. That boolean gates two things: whether `br_netfilter` is required at all, and whether a failed physdev FORWARD hook is fatal or a warning. So the failure was silent and it failed open -- setup reported success while neither hook could match. Absence of the sysfs entry is not evidence about the topology. `discover_veth_interface` parses `lxc-info` output, not sysfs, so the veth can be known to exist while its sysfs entry is unreadable. The two sources are independent. The probe now returns `VethTopology::{Bridged, DirectlyRouted, Unknown}` and only a positive `DirectlyRouted` finding earns the relaxed treatment. `Unknown` is handled as bridged, which keeps a failed physdev hook fatal, and is logged -- without the log line the fail-closed choice is undiagnosable in the field. The probe uses `symlink_metadata` rather than `exists`, because `master` is a symlink and `exists()` follows it, reporting a dangling `master` as absent. One existing assertion is reversed by this. `a_missing_interface_directory_is_not_bridge_enslaved` asserted that a missing interface directory means "not enslaved"; it now asserts `Unknown`. That is a contract change driven by the finding above, not a convenience, and the reasoning is recorded in the test comment. Sixteen existing tests were silently depending on the build host having no `/sys/class/net`, which is why they went red. Rather than pin sixteen fixtures, a `#[cfg(test)]` topology override declares the topology a test means, defaulting to `DirectlyRouted`. Mutation results, four compiling mutations: making `Unknown` routed again (the original bug), returning `DirectlyRouted` for a missing interface directory, and dropping the warning were all caught. Swapping `symlink_metadata` for `metadata` SURVIVED -- only a dangling symlink separates them, and that is not creatable portably on a Windows dev host. The stronger call is kept regardless; the gap is in the tests, not the code. Addresses review thread 3754110088. --- .../lxc/common/src/network_iptables.rs | 130 ++++++++++++++++-- .../src/network_iptables_forward_hook_spec.rs | 68 +++++++-- 2 files changed, 175 insertions(+), 23 deletions(-) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 14c0f91a1..ea2945e38 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -226,6 +226,14 @@ pub struct NetworkIptablesManager { /// 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, @@ -315,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 { @@ -323,6 +352,8 @@ impl NetworkIptablesManager { rules_applied: false, veth_interface: None, veth_scoping_optional: false, + #[cfg(test)] + topology_override: Some(VethTopology::DirectlyRouted), created: CreatedResources::default(), proxy_pin: None, } @@ -395,6 +426,12 @@ 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. /// @@ -462,13 +499,26 @@ impl NetworkIptablesManager { ] } - /// Whether `iface` is enslaved to a bridge, looked up under an injectable - /// sysfs root so this is testable without a live interface. + /// Determine whether `iface` is enslaved to a bridge, looked up under an + /// injectable sysfs root so this is testable without a live interface. /// - /// The kernel exposes `master` only for an enslaved interface, so its mere - /// presence is the answer. - fn veth_is_bridge_enslaved_in(sysfs_net_root: &Path, iface: &str) -> bool { - sysfs_net_root.join(iface).join("master").exists() + /// `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 => { + match std::fs::symlink_metadata(&iface_dir) { + Ok(_) => VethTopology::DirectlyRouted, + Err(_) => VethTopology::Unknown, + } + } + Err(_) => VethTopology::Unknown, + } } /// Whether bridged traffic is delivered to iptables at all, read from an @@ -483,9 +533,23 @@ impl NetworkIptablesManager { .unwrap_or(false) } - /// Production wrapper over [`Self::veth_is_bridge_enslaved_in`]. - fn veth_is_bridge_enslaved(iface: &str) -> bool { - Self::veth_is_bridge_enslaved_in(Path::new(SYSFS_NET_ROOT), iface) + /// 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`]. @@ -1803,7 +1867,20 @@ impl NetworkIptablesManager { // 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 { - let bridged = Self::veth_is_bridge_enslaved(iface); + 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 @@ -3552,6 +3629,39 @@ mod tests { .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 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 index 72119122f..7b6540d5c 100644 --- a/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs @@ -287,11 +287,12 @@ fn an_interface_with_a_master_entry_is_reported_as_bridge_enslaved() { 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_is_bridge_enslaved_in(&root, "veth-a1b2"); + let result = NetworkIptablesManager::veth_topology_in(&root, "veth-a1b2"); fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); - assert!( + assert_eq!( result, + VethTopology::Bridged, "an interface with a master entry must be reported as bridge-enslaved" ); } @@ -304,29 +305,70 @@ fn an_interface_without_a_master_entry_is_not_bridge_enslaved() { 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_is_bridge_enslaved_in(&root, "veth-d4e5"); + let result = NetworkIptablesManager::veth_topology_in(&root, "veth-d4e5"); fs::remove_dir_all(&root).expect("failed to clean up the fake sysfs root"); - assert!( - !result, - "an interface directory with no master entry must not be reported as bridge-enslaved" + assert_eq!( + result, + VethTopology::DirectlyRouted, + "an interface directory with no master entry is a positive directly-routed finding" ); } -// If the interface itself has no sysfs directory at all -- for example a -// name that does not exist on the host -- there is nothing to be enslaved, -// and the function must say so rather than erroring. +// 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_not_bridge_enslaved() { +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_is_bridge_enslaved_in(&root, "veth-ghost"); + 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 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!( - !result, - "an interface with no sysfs directory at all must not be reported as bridge-enslaved" + 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" ); } From 12eb8e996d21dc11453b046a43110247e8c357f2 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 16:51:27 -0700 Subject: [PATCH 41/55] Do not read a dangling interface symlink as directly routed An independent review of the previous commit found a fail-open path I had left in the fix for fail-open behavior. `symlink_metadata` does not follow the final component. The confirmation step used it on the interface directory itself, and in real sysfs `/sys/class/net/` *is* a symlink into `/sys/devices` -- so a dangling one succeeded and earned a positive `DirectlyRouted` finding, which is the branch that downgrades a failed physdev hook to a warning. That is the same defect the three-state probe exists to remove, one level down. The two probes now differ deliberately. `master` is still read with `symlink_metadata`, because a dangling `master` still means enslaved and following it would report the veth as routed. The interface directory is read with `metadata`, because only a target that actually resolves proves the absent `master` was observed rather than merely unreachable. Reported by evidence-reviewer against pre-registered ground truth; this item was not on the key, which is the point of not grading your own work. --- src/backends/lxc/common/src/network_iptables.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index ea2945e38..5bfad0e2e 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -512,7 +512,14 @@ impl NetworkIptablesManager { match std::fs::symlink_metadata(iface_dir.join("master")) { Ok(_) => VethTopology::Bridged, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - match std::fs::symlink_metadata(&iface_dir) { + // `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, } From 97ff4b1a1002e815598bd56e2c0d8c54e7271be5 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 16:59:17 -0700 Subject: [PATCH 42/55] Pin the metadata asymmetry with dangling-symlink tests The mutation that swapped `symlink_metadata` for `metadata` on the `master` probe survived the first battery, and I recorded that as a gap I could not close portably. That reasoning was wrong: this package already has a Unix-gated dangling-symlink test, `resolve_denied_host_path_fails_closed_on_dangling_symlink`, so the pattern was established here and the excuse was mine, not the platform's. An independent reviewer pointed at it. Two tests now pin both halves of the asymmetry. A dangling `master` must still read as bridged, because following it would report a bridged veth as directly routed -- the relaxed branch. A dangling interface symlink must read as unknown, because `/sys/class/net/` is itself a symlink into `/sys/devices` and a link that does not resolve establishes nothing. Both are `#[cfg(unix)]`, since a dangling symlink is not creatable without privilege on Windows. Verified to compile for Linux with `cargo check -p lxc_common --tests --target x86_64-unknown-linux-gnu`; they execute in CI, not on the dev host. --- .../src/network_iptables_forward_hook_spec.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 index 7b6540d5c..d6b3b82ea 100644 --- a/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs +++ b/src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs @@ -353,6 +353,61 @@ fn an_unreadable_sysfs_root_is_an_unknown_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. From b657fc7f4e24fe4c511de264c9f5c8cad9365ffe Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 20:48:21 -0700 Subject: [PATCH 43/55] Find proxy credentials in an opaque URL, not just an authority one `proxy_url_has_credentials` split on `://` and returned false when that failed. `url::Url::parse` accepts the opaque form -- scheme, colon, no slashes -- so `http:alice:secret@proxy.example.com` is a real proxy URL that the guard called clean. It then reached `lxc-attach --set-var`, where `/proc//cmdline` exposes it to every local user. The gap was structural rather than a missing case. `redact_proxy_url` already handled the opaque form while the predicate did not: two parsers, one input class, and the drift the predicate's own doc comment claimed to prevent. Both now share `split_proxy_authority`, so neither can learn a shape the other has not. A value with no scheme at all now fails closed. The old test asserted the opposite, on the premise that a string with no scheme has no authority to parse. Parseability is not safety here -- nothing downstream re-parses the value, it reaches argv as written -- and a bearer token used as sole userinfo, `token@proxy.example.com`, carries no colon and no scheme. Every password-bearing form contains a colon and was caught by the opaque branch, which is exactly why the gap looked safe. A port colon is still not userinfo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/proxy_env.rs | 76 +++++++---- src/core/wxc_common/tests/proxy_env_spec.rs | 134 +++++++++++++++++++- 2 files changed, 182 insertions(+), 28 deletions(-) diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index ba3f3535f..53183fdcc 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -95,31 +95,57 @@ pub fn is_managed_proxy_key(key: &str) -> bool { /// URL. `scheme:opaque` is redacted too, since `url::Url::parse` accepts it and /// the resulting error message would otherwise carry the password. pub fn redact_proxy_url(url: &str) -> String { - let Some((scheme, rest)) = url.split_once("://") else { - return redact_opaque_userinfo(url); + let Some(parts) = split_proxy_authority(url) else { + return url.to_string(); }; - let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); - let (authority, tail) = rest.split_at(auth_end); - match authority.rsplit_once('@') { - Some((_userinfo, host)) => format!("{scheme}://***@{host}{tail}"), + match parts.authority.rsplit_once('@') { + Some((_userinfo, host)) => format!( + "{}{}***@{}{}", + parts.scheme, parts.separator, host, parts.tail + ), None => url.to_string(), } } -/// Redact userinfo from a `scheme:rest` URL that has no `://` authority. +/// The pieces of a proxy URL that userinfo handling needs. +struct ProxyAuthority<'a> { + scheme: &'a str, + /// Whichever separator followed the scheme, `://` or `:`, 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. /// -/// `url::Url::parse("alice:hunter2@example.com")` succeeds with scheme `alice`, -/// so such a string reaches the scheme diagnostic with the password intact. -fn redact_opaque_userinfo(url: &str) -> String { - let Some((scheme, rest)) = url.split_once(':') else { - return url.to_string(); +/// Both forms are recognized deliberately. `url::Url::parse` accepts the +/// opaque `scheme:rest` form -- `alice:hunter2@example.com` parses with scheme +/// `alice` -- and [`ProxyAddress::from_url`] stores whatever string it is +/// given, so the opaque form reaches the same places the absolute form does. +/// +/// 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. +fn split_proxy_authority(url: &str) -> Option> { + let (scheme, separator, rest) = match url.split_once("://") { + Some((scheme, rest)) => (scheme, "://", rest), + None => { + let (scheme, rest) = url.split_once(':')?; + (scheme, ":", rest) + } }; let auth_end = rest.find(['/', '?', '#']).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(), - } + Some(ProxyAuthority { + scheme, + separator, + authority, + tail, + }) } /// Whether a proxy URL carries `user:pass@` userinfo. @@ -133,13 +159,17 @@ fn redact_opaque_userinfo(url: &str) -> String { /// 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 Some((_scheme, rest)) = url.split_once("://") else { - return false; - }; - // Stop at the first path, query, or fragment delimiter: an `@` after that - // point belongs to the path, not to userinfo. - let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); - rest[..auth_end].contains('@') + match split_proxy_authority(url) { + Some(parts) => parts.authority.contains('@'), + // No scheme at all, so nothing downstream should accept this as a + // proxy URL. The guard still fails closed rather than reasoning about + // what a malformed value will do once it is somewhere else: any `@` + // ahead of the path is userinfo. + None => { + let auth_end = url.find(['/', '?', '#']).unwrap_or(url.len()); + url[..auth_end].contains('@') + } + } } /// Build the effective environment for a sandbox whose egress is routed diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index 882c03013..d46934c78 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -496,12 +496,34 @@ fn userinfo_that_looks_like_the_redaction_marker_still_carries_credentials() { ); } -// A string with no scheme separator has no authority to parse, so there is no -// userinfo to find and the guard must not refuse it on a spurious match. +// 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_url_without_a_scheme_carries_no_credentials() { - assert!(!proxy_url_has_credentials("proxy.example.com:8080")); - assert!(!proxy_url_has_credentials("not-a-url@at-all")); +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 @@ -530,3 +552,105 @@ fn redact_proxy_url_leaves_a_scheme_opaque_url_without_userinfo_alone() { 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}" + ); + } +} From dad5aebbfa8df3bde9fa004973149e3926cfd331 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 20:48:36 -0700 Subject: [PATCH 44/55] Stop rewriting /etc/hosts when the read that feeds it failed The pin and unpin commands discarded grep's exit status. With grep missing, /etc/hosts unreadable, or the read 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. The unpin form emptied it outright. `> /etc/hosts` truncates the moment it is opened, so every reason the read could fail has to be settled before then. Both commands now share a prologue that reads into `kept` and exits ahead of the redirect when the status exceeds 1. Status 1 stays an outcome rather than a failure: it is an empty file, or a re-pin where every line carried the marker. A missing file is separated out first, because grep cannot tell absent from unreadable -- both are status 2 -- and an image shipping no /etc/hosts has no content to protect. Only grep and printf are still used, so this runs under BusyBox. The caller already treats any non-zero exit as fatal and destroys the container, so the abort surfaces rather than being swallowed. Every existing hosts test asserts on the command string, and no string assertion separates a command that preserves the file from one that empties it -- both contain `> /etc/hosts`, and all six passed throughout the defect. The new tests execute the generated command under a real /bin/sh against a scratch file, with a PATH-shimmed grep to fail the read on demand. Removing the guard fails three of them, and each of the three narrower mutations -- comparing against 2 instead of 1, comparing against 0, and defeating the existence check -- fails exactly the test written for it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/backends/lxc/common/src/lxc_runner.rs | 330 +++++++++++++++++++++- 1 file changed, 325 insertions(+), 5 deletions(-) diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index 31b918887..c9609d281 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -429,14 +429,48 @@ impl LxcScriptRunner { // printf's, so a grep that matches nothing and exits 1 does not fail // the command. format!( - "kept=$(grep -v '{marker}' /etc/hosts 2>/dev/null); \ - {{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; \ + "{}{{ 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. + fn hosts_read_prologue() -> String { + format!( + "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 @@ -452,9 +486,8 @@ impl LxcScriptRunner { /// symlink reason. fn build_hosts_unpin_command() -> String { format!( - "kept=$(grep -v '{marker}' /etc/hosts 2>/dev/null); \ - {{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; }} > /etc/hosts", - marker = HOSTS_PIN_MARKER + "{}{{ if [ -n \"$kept\" ]; then printf '%s\\n' \"$kept\"; fi; }} > /etc/hosts", + Self::hosts_read_prologue() ) } } @@ -784,3 +817,290 @@ mod tests { ); } } + +/// 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}" + ); + } + } +} From 9595b58e549d8623267d7345831278b6fd08d97c Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 20:48:50 -0700 Subject: [PATCH 45/55] Cover the credentialed-proxy rejection from outside the process The no-credential rule had unit coverage on both sides of the boundary but nothing that ran the product. This adds the fixture and the harness: an otherwise valid LXC request whose proxy URL embeds `alice:hunter2`. The rule is enforced while the configuration is parsed, before any container exists, so unlike every other LXC network test this one needs no root, no bridge, no LXC, and no network -- only the built binary. It asserts the run is refused, refused for carrying credentials rather than by coincidence, that neither the username nor the password appears anywhere in the output, that the redacted host survives so the operator has something to act on, and that the container's command never ran. The redaction is asserted as its own observable because a rejection that echoed the URL back would leak the same secret it just refused. The harness prefers target/release and falls back to target/debug, matching the other LXC scripts. A release binary built before this rule existed is therefore picked ahead of a fresh debug one, and it fails here in exactly the way a regression would, because a binary without the rule really does accept the URL and run the command. The failure now names the artifact and its build time so that is one line to read rather than an hour to find. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- ...xc_network_proxy_credentials_rejected.json | 20 +++ tests/scripts/run_lxc_all_tests.sh | 1 + .../run_lxc_network_proxy_credentials_test.sh | 135 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 tests/configs/lxc_network_proxy_credentials_rejected.json create mode 100644 tests/scripts/run_lxc_network_proxy_credentials_test.sh 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 be6611495..de8c5b864 100644 --- a/tests/scripts/run_lxc_all_tests.sh +++ b/tests/scripts/run_lxc_all_tests.sh @@ -69,6 +69,7 @@ run_test "LXC Network CIDR Boundary" "$SCRIPT_DIR/run_lxc_network_cidr_boundary_ 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" 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..66cceceab --- /dev/null +++ b/tests/scripts/run_lxc_network_proxy_credentials_test.sh @@ -0,0 +1,135 @@ +#!/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. +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)" +[ "$actual_url" = "$EXPECTED_PROXY_URL" ] \ + || fail "fixture proxy.url is '$actual_url', test expects '$EXPECTED_PROXY_URL'" +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=$? + +echo "--- lxc-exec output ---" +echo "$OUT" +echo "-----------------------" + +# 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; output above" + +# The rejection must not become the leak it is rejecting. +if echo "$OUT" | grep -q "$EXPECTED_PASSWORD"; then + fail "the proxy password appeared in lxc-exec output" +fi +if echo "$OUT" | grep -q "$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 -q "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 -q "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." From bfcbc82cc7bb471bea48b5b246189dfeca70a98d Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 21:00:08 -0700 Subject: [PATCH 46/55] Close three holes an independent review found in the credential guard A one-slash URL bypassed the guard entirely. 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@proxy.example.com` is a credentialed URL. Anchoring on `://` saw a path beginning with `/`, read an empty authority, and reported it clean. Any run of leading slashes now belongs to the separator, and redaction reassembles whichever run it was given. A schemeless value was refused but not redacted. `token@proxy.example.com` has no colon, so the shared parser gave up and the predicate answered from a fallback of its own -- the same split-brain the shared parser was introduced to end. The guard said credentials, redaction returned the string verbatim, and the rejection message printed the secret it was refusing. The parser is now total: no scheme means the whole value is an authority, and the fallback is gone. Empty userinfo was a false positive. `http://@proxy.example.com` and `http://:@proxy.example.com` name neither a user nor a password, so refusing them rejected a configuration that leaks nothing. Userinfo of nothing but colons is no longer a credential -- while a single component still is, since `:hunter2@` is a password with the username omitted and `token@` is how a bearer token is passed. Both public functions now reach the same judgment through the same two helpers, so the disagreement is structural rather than tested-for. Reverting any one of the three fixes fails exactly the tests written for it. Also records the two gaps that survive the /etc/hosts guard and cannot be closed while the command is restricted to grep and printf: a shell variable cannot carry NUL bytes, and the existence test, read, and redirect are three separate path resolutions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/backends/lxc/common/src/lxc_runner.rs | 17 +++ src/core/wxc_common/src/proxy_env.rs | 89 +++++++++----- src/core/wxc_common/tests/proxy_env_spec.rs | 123 ++++++++++++++++++++ 3 files changed, 198 insertions(+), 31 deletions(-) diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index c9609d281..f3da3161b 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -455,6 +455,23 @@ impl LxcScriptRunner { /// 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. + /// + /// * The existence test, the read, and the redirect are three separate + /// path resolutions, so a `/etc/hosts` symlink swapped between them + /// sends the preserved content somewhere else, and a *dangling* symlink + /// fails `-e` and is then followed by the redirect without any read + /// having happened. Closing that needs an open-once-and-rewrite + /// primitive, which is a Rust-side change rather than a shell one. fn hosts_read_prologue() -> String { format!( "kept=''; \ diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index 53183fdcc..ac5922cb2 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -95,10 +95,8 @@ pub fn is_managed_proxy_key(key: &str) -> bool { /// URL. `scheme:opaque` is redacted too, since `url::Url::parse` accepts it and /// the resulting error message would otherwise carry the password. pub fn redact_proxy_url(url: &str) -> String { - let Some(parts) = split_proxy_authority(url) else { - return url.to_string(); - }; - match parts.authority.rsplit_once('@') { + let parts = split_proxy_authority(url); + match credential_userinfo(parts.authority) { Some((_userinfo, host)) => format!( "{}{}***@{}{}", parts.scheme, parts.separator, host, parts.tail @@ -110,8 +108,9 @@ pub fn redact_proxy_url(url: &str) -> String { /// The pieces of a proxy URL that userinfo handling needs. struct ProxyAuthority<'a> { scheme: &'a str, - /// Whichever separator followed the scheme, `://` or `:`, so a redaction - /// can be reassembled in the same form it arrived in. + /// 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. @@ -121,31 +120,69 @@ struct ProxyAuthority<'a> { /// Split `url` into scheme, separator, authority, and tail. /// -/// Both forms are recognized deliberately. `url::Url::parse` accepts the -/// opaque `scheme:rest` form -- `alice:hunter2@example.com` parses with scheme -/// `alice` -- and [`ProxyAddress::from_url`] stores whatever string it is -/// given, so the opaque form reaches the same places the absolute form does. +/// 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. /// /// 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. -fn split_proxy_authority(url: &str) -> Option> { - let (scheme, separator, rest) = match url.split_once("://") { - Some((scheme, rest)) => (scheme, "://", rest), - None => { - let (scheme, rest) = url.split_once(':')?; - (scheme, ":", rest) - } +/// 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) => url.split_at(colon), + None => (&url[..0], url), }; + let slashes = after_scheme + .strip_prefix(':') + .map(|rest| 1 + (rest.len() - rest.trim_start_matches('/').len())) + .unwrap_or(0); + let (separator, rest) = after_scheme.split_at(slashes); let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); let (authority, tail) = rest.split_at(auth_end); - Some(ProxyAuthority { + ProxyAuthority { scheme, separator, authority, tail, - }) + } +} + +/// 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 -- so anything other than colons counts. +/// +/// 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.chars().all(|c| c == ':') { + return None; + } + Some((userinfo, host)) } /// Whether a proxy URL carries `user:pass@` userinfo. @@ -159,17 +196,7 @@ fn split_proxy_authority(url: &str) -> Option> { /// 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 { - match split_proxy_authority(url) { - Some(parts) => parts.authority.contains('@'), - // No scheme at all, so nothing downstream should accept this as a - // proxy URL. The guard still fails closed rather than reasoning about - // what a malformed value will do once it is somewhere else: any `@` - // ahead of the path is userinfo. - None => { - let auth_end = url.find(['/', '?', '#']).unwrap_or(url.len()); - url[..auth_end].contains('@') - } - } + credential_userinfo(split_proxy_authority(url).authority).is_some() } /// Build the effective environment for a sandbox whose egress is routed diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index d46934c78..04c6cf0eb 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -654,3 +654,126 @@ fn redaction_and_the_credential_guard_agree_on_every_shape() { ); } } + +// 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. +#[test] +fn empty_userinfo_is_not_a_credential() { + for url in [ + "http://@proxy.example.com:3128", + "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 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", + "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}" + ); + } +} From 7a9948bdc37bd0f1a240ae05d8e84950000a22c7 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 21:21:36 -0700 Subject: [PATCH 47/55] Stop reading a port separator as a URL scheme `alice@proxy.example.com:3128` reached `lxc-attach` argv with the username intact. Every character before the first colon was taken for a scheme on sight, so the scheme parsed as `alice@proxy.example.com` and the authority as the bare port `3128`. An authority of `3128` carries no `@`, so the guard reported no credentials and redaction returned the string untouched -- the third shape in this family to get through, after the opaque form and the one-slash form. The prefix now has to satisfy the RFC 3986 scheme grammar, `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`, or the colon is a port separator and the whole value is the authority. `@` is not in that grammar and a prefix carrying userinfo always contains one, so this closes the class rather than the instance. The invariant that could have broken is that a bare `host:port` still parses as a scheme -- `proxy.example.com` is ALPHA and `.` -- and that stays harmless, because it leaves the authority as the port, which names no credential either way. A test pins it, and reverting the grammar check fails exactly the test written for it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/proxy_env.rs | 36 ++++++++++++- src/core/wxc_common/tests/proxy_env_spec.rs | 58 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index ac5922cb2..907aaa94f 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -139,6 +139,10 @@ struct ProxyAuthority<'a> { /// `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 @@ -147,8 +151,8 @@ struct ProxyAuthority<'a> { /// they can differ on. fn split_proxy_authority(url: &str) -> ProxyAuthority<'_> { let (scheme, after_scheme) = match url.find(':') { - Some(colon) => url.split_at(colon), - None => (&url[..0], url), + Some(colon) if is_uri_scheme(&url[..colon]) => url.split_at(colon), + _ => (&url[..0], url), }; let slashes = after_scheme .strip_prefix(':') @@ -165,6 +169,34 @@ fn split_proxy_authority(url: &str) -> ProxyAuthority<'_> { } } +/// 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. /// diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index 04c6cf0eb..efb3f42c6 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -760,6 +760,8 @@ fn the_guard_and_the_redaction_never_disagree_on_the_shapes_that_broke_them() { "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", @@ -777,3 +779,59 @@ fn the_guard_and_the_redaction_never_disagree_on_the_shapes_that_broke_them() { ); } } + +// 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" + ); + } +} From b67f7ecdf6e79b50042fb9e3e90c993d950e69e1 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 21:21:56 -0700 Subject: [PATCH 48/55] Refuse to rewrite /etc/hosts through a symbolic link The dangling case needed no race at all. `-e` is false on a broken link, so the read was skipped, and then `> /etc/hosts` *created* the target -- a write to a path the workload named, which on a writable host bind mount lands outside the container. A workload reused across runs can leave that link behind, so this was reachable without an adversary racing anything. The prologue now tests `-h` first and exits 4 before anything opens the file for writing, for both the pin and the unpin. Failing closed is right here: the caller treats any non-zero status as fatal and destroys the container, so a refused pin cannot be mistaken for a successful one. A distribution that ships `/etc/hosts` as a symlink now fails loudly with a message naming the reason, which is diagnosable, where writing through it silently was not. What this does not close is a link swapped in between the `-h` test and the redirect. That is a genuine race, it needs `openat` with `O_NOFOLLOW` inside the container's mount namespace, and it is a Rust-side change rather than a shell one. The doc comment says so rather than implying the gap is gone. Four executing tests cover it, because no string assertion can: a command that refuses a symlink and one that writes through it both contain `> /etc/hosts`. They run the generated command under a real `/bin/sh` against a scratch symlink and assert the target is neither created nor rewritten. Dropping the guard fails all four. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/backends/lxc/common/src/lxc_runner.rs | 106 ++++++++++++++++++++-- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/src/backends/lxc/common/src/lxc_runner.rs b/src/backends/lxc/common/src/lxc_runner.rs index f3da3161b..381c1e326 100644 --- a/src/backends/lxc/common/src/lxc_runner.rs +++ b/src/backends/lxc/common/src/lxc_runner.rs @@ -466,15 +466,24 @@ impl LxcScriptRunner { /// alternative -- a scratch file, `sed`, `awk` -- reintroduces either the /// symlink target this design removed or a dependency BusyBox may lack. /// - /// * The existence test, the read, and the redirect are three separate - /// path resolutions, so a `/etc/hosts` symlink swapped between them - /// sends the preserved content somewhere else, and a *dangling* symlink - /// fails `-e` and is then followed by the redirect without any read - /// having happened. Closing that needs an open-once-and-rewrite - /// primitive, which is a Rust-side change rather than a shell one. + /// * 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!( - "kept=''; \ + "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=$?; \ @@ -1120,4 +1129,87 @@ mod hosts_command_execution { ); } } + // 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}" + ); + } } From d26d826dc2f789a13bf24953fb9f0e5a27066e2a Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 21:21:56 -0700 Subject: [PATCH 49/55] Stop the credential test from publishing the secret it guards The script echoed the captured output before asserting that the output contains neither the username nor the password. On the one regression this test exists to catch, the capture *is* the secret, so the password would be published to the CI log first and the assertion would then fail a run that had already leaked it. The capture is now withheld whenever either credential appears in it. Every other failure still prints it, because that is what makes those diagnosable, and the assertions name what went wrong either way. Also switches the literal-string assertions to `grep -F`, so a password or a dotted address is matched as text rather than as a pattern. Verified by forcing the capture to contain the password: the withhold branch runs, the test still fails, and the password never reaches stdout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- .../run_lxc_network_proxy_credentials_test.sh | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/scripts/run_lxc_network_proxy_credentials_test.sh b/tests/scripts/run_lxc_network_proxy_credentials_test.sh index 66cceceab..09c9d97da 100644 --- a/tests/scripts/run_lxc_network_proxy_credentials_test.sh +++ b/tests/scripts/run_lxc_network_proxy_credentials_test.sh @@ -98,9 +98,18 @@ echo "Using $LXC_EXEC (built $(date -r "$LXC_EXEC" '+%Y-%m-%d %H:%M:%S' 2>/dev/n OUT="$("$LXC_EXEC" "$CONFIG" 2>&1)" STATUS=$? -echo "--- lxc-exec output ---" -echo "$OUT" -echo "-----------------------" +# 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. @@ -112,23 +121,23 @@ echo "-----------------------" # 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; output above" + || fail "rejected, but not for carrying credentials" # The rejection must not become the leak it is rejecting. -if echo "$OUT" | grep -q "$EXPECTED_PASSWORD"; then +if echo "$OUT" | grep -qF "$EXPECTED_PASSWORD"; then fail "the proxy password appeared in lxc-exec output" fi -if echo "$OUT" | grep -q "$EXPECTED_USERNAME"; then +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 -q "10.0.3.1:3128" \ +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 -q "THIS_MUST_NEVER_RUN"; then +if echo "$OUT" | grep -qF "THIS_MUST_NEVER_RUN"; then fail "the container command ran despite the rejected proxy URL" fi From 6392ea03e610f97716457a784a7a41830670ffc8 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 22:01:55 -0700 Subject: [PATCH 50/55] [LXC] Stop leading whitespace from hiding a proxy credential The URL parser that eventually receives this string follows WHATWG, which ignores C0 controls and spaces at either end and strips tab, newline, and carriage return from anywhere inside. The guard read the string as written. So ' http://alice:hunter2@10.0.3.1:3128' had no recognizable scheme once the leading space counted, the authority stopped at the first '/' of '//', the '@' was never seen, and the guard returned false. redact_proxy_url agreed and returned the value verbatim, so a rejection message would have printed the password even if something else had caught it. This is the fourth bypass of the same guard, and it refutes the claim made when the third was fixed -- that a scheme-grammar check closed the whole class. It did not, because the mismatch is not in the grammar but in which string is being judged. Both public functions now normalize the way the parser does before judging, so they judge the string lxc-attach will actually receive. Verified by mutation: making the normalization a no-op fails whitespace_around_a_credentialed_url_does_not_hide_it and nothing else. Verified end to end: lxc-exec run against a config whose proxy.url carries a leading space now exits 1, prints 'http://***@10.0.3.1:3128', and never runs the container command. --- src/core/wxc_common/src/proxy_env.rs | 38 ++++++++++++- src/core/wxc_common/tests/proxy_env_spec.rs | 59 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index 907aaa94f..242e38aa9 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -30,6 +30,7 @@ //! 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. @@ -94,8 +95,14 @@ pub fn is_managed_proxy_key(key: &str) -> bool { /// 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 parts = split_proxy_authority(url); + let normalized = normalize_as_the_url_parser_does(url); + let parts = split_proxy_authority(&normalized); match credential_userinfo(parts.authority) { Some((_userinfo, host)) => format!( "{}{}***@{}{}", @@ -105,6 +112,32 @@ pub fn redact_proxy_url(url: &str) -> 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, @@ -228,7 +261,8 @@ fn credential_userinfo(authority: &str) -> Option<(&str, &str)> { /// 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 { - credential_userinfo(split_proxy_authority(url).authority).is_some() + 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 diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index efb3f42c6..c6e8649c7 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -835,3 +835,62 @@ fn a_prefix_that_is_not_a_scheme_does_not_invent_a_credential() { ); } } + +// `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" + ); + } +} From 5c057c65be83cefee6fb6d94ab686e7f444a53c7 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 22:02:10 -0700 Subject: [PATCH 51/55] [LXC] Stop the drift guard from printing the credentials it guards The drift guard exists so that a fixture edited to drop its inline credentials cannot make every assertion below it vacuous. It compared the fixture's proxy.url against the expected one and, on mismatch, passed both to fail(), which echoes its arguments. Both carry a password. So the one code path that fires precisely when the fixture can no longer be trusted was also the path that published the secret -- into a public CI log. The guard now describes the mismatch instead of quoting it: whether the URL still carries userinfo (so the pair changed) or carries none at all (so every assertion below would be vacuous). Both branches say enough to diagnose the drift and name neither URL. Verified by forcing both drift shapes against a temporarily edited fixture: each fails with the right explanation, and neither the old password, the new one, nor either username appears anywhere in the output. Trace mode is a separate exposure -- set -x prints every expansion, including these values -- so the script now says not to enable it. The workflow does not. --- .../run_lxc_network_proxy_credentials_test.sh | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/scripts/run_lxc_network_proxy_credentials_test.sh b/tests/scripts/run_lxc_network_proxy_credentials_test.sh index 09c9d97da..0267123d5 100644 --- a/tests/scripts/run_lxc_network_proxy_credentials_test.sh +++ b/tests/scripts/run_lxc_network_proxy_credentials_test.sh @@ -32,6 +32,10 @@ CONFIG="$REPO_DIR/tests/configs/lxc_network_proxy_credentials_rejected.json" # 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" @@ -69,8 +73,17 @@ PY } actual_url="$(read_json_field network.proxy.url)" -[ "$actual_url" = "$EXPECTED_PROXY_URL" ] \ - || fail "fixture proxy.url is '$actual_url', test expects '$EXPECTED_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)." # --------------------------------------------------------------------------- From aef66b57568beda83d7f9660af5b7a2ef5131ea5 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 22:15:30 -0700 Subject: [PATCH 52/55] [LXC] Stop a backslash from hiding a proxy credential, and pin the guard to the parser The fifth bypass of this guard, and the first one found by a test rather than by a reviewer or a guess. WHATWG treats a backslash as a slash for the special schemes -- http, https, ws, wss, ftp, file -- both where an authority begins and where it ends. The splitter counted only forward slashes, so 'http:\/alice:hunter2@10.0.3.1:3128' left an authority of the single character '\'. That carries no '@', so the guard reported no credentials and redact_proxy_url returned the password verbatim. Verified end to end: the pre-fix binary accepted that config; the fixed binary exits 1, prints 'http:\/***@10.0.3.1:3128', and never runs the container command. The more important change is the test that found it. Four of the five bypasses were hand-found, one at a time, each after a claim that the class was closed. Every one was the same failure: the guard judged one string and lxc-attach received another. So the guard is now pinned to an oracle instead of to examples -- for a corpus of thirty-two shapes, if url::Url::parse finds userinfo, proxy_url_has_credentials must find it too, and if the parser finds none, the guard must not invent one. url is the crate ProxyAddress already parses with, so it is the authority on this question, not a second opinion. That test failed on the backslash form the first time it ran. The equivalence deliberately stops at the special schemes, because the parser stops there. Measured: 'socks5:\/alice:hunter2@h' parses as an opaque path with cannot_be_a_base = true, an empty username, and no host, so it names no credential and could never become a ProxyAddress. Applying the equivalence everywhere would make the guard reject a config that leaks nothing, and rejection is fatal -- the caller destroys the container. Both directions are mutation-tested: forcing is_special_scheme false fails three tests including the differential one, forcing it true fails the non-special test. The second mutation survived until that test was added, which is why it exists. --- src/core/wxc_common/src/proxy_env.rs | 30 +++- src/core/wxc_common/tests/proxy_env_spec.rs | 180 ++++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index 242e38aa9..1071c82de 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -187,12 +187,24 @@ fn split_proxy_authority(url: &str) -> ProxyAuthority<'_> { Some(colon) if is_uri_scheme(&url[..colon]) => url.split_at(colon), _ => (&url[..0], url), }; + // For the special schemes, WHATWG reads a backslash exactly as a slash -- + // both where an authority begins and where it ends. Reading only `/` + // truncated `http:\/alice:hunter2@host` to an authority of `\`, which + // carries no `@`, so the guard reported no credentials while the password + // still reached argv. The equivalence is deliberately not applied to other + // schemes, because the parser does not apply it there either, and a guard + // that over-reports rejects proxies that leak nothing. + let backslash_is_a_slash = is_special_scheme(scheme); + let introduces_authority = |c: char| c == '/' || (backslash_is_a_slash && c == '\\'); + let ends_authority = + |c: char| matches!(c, '/' | '?' | '#') || (backslash_is_a_slash && c == '\\'); + let slashes = after_scheme .strip_prefix(':') - .map(|rest| 1 + (rest.len() - rest.trim_start_matches('/').len())) + .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(['/', '?', '#']).unwrap_or(rest.len()); + let auth_end = rest.find(ends_authority).unwrap_or(rest.len()); let (authority, tail) = rest.split_at(auth_end); ProxyAuthority { scheme, @@ -202,6 +214,20 @@ fn split_proxy_authority(url: &str) -> ProxyAuthority<'_> { } } +/// Whether `scheme` is one of the WHATWG "special" schemes, for which a +/// backslash is equivalent to a slash. +/// +/// The list is fixed by the URL Standard rather than open-ended, so naming it +/// here matches the parser instead of guessing at it. Comparison ignores case +/// because the parser lowercases the scheme before deciding, and +/// `HTTP:\/alice:hunter2@host` is the same URL as its lowercase form. +fn is_special_scheme(scheme: &str) -> bool { + let scheme = scheme.trim_start_matches(':'); + ["http", "https", "ws", "wss", "ftp", "file"] + .iter() + .any(|special| scheme.eq_ignore_ascii_case(special)) +} + /// Whether `candidate` satisfies the RFC 3986 scheme grammar, /// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`. /// diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index c6e8649c7..47bb806d0 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -22,6 +22,7 @@ //! (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, @@ -894,3 +895,182 @@ fn whitespace_around_a_clean_url_stays_clean() { ); } } + +// 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 strongest available +// assertion is not another hand-picked example but a differential one: for any +// input, if `url::Url::parse` finds userinfo, the guard must say so too. +// `url` is the crate `ProxyAddress` itself parses with, so it is the oracle, +// not a second opinion. +#[test] +fn the_guard_agrees_with_the_parser_that_will_actually_read_the_url() { + let corpus = [ + "http://alice:hunter2@10.0.3.1:3128", + "http:alice:hunter2@10.0.3.1:3128", + "http:/alice:hunter2@10.0.3.1:3128", + "alice@proxy.example.com:3128", + " http://alice:hunter2@10.0.3.1:3128", + "http://alice:hunter2@10.0.3.1:3128 ", + "\thttp://alice:hunter2@10.0.3.1:3128", + "http://ali\tce:hunter2@10.0.3.1:3128", + "http://alice:hun\nter2@10.0.3.1:3128", + "http://alice:hunter2@10.0.3.1:3128\r", + "http:\\\\alice:hunter2@10.0.3.1:3128", + "http:/\\alice:hunter2@10.0.3.1:3128", + "http:\\/alice:hunter2@10.0.3.1:3128", + "HTTP://alice:hunter2@10.0.3.1:3128", + "HtTp://alice:hunter2@10.0.3.1:3128", + "http://alice%40x:hunter2@10.0.3.1:3128", + "http://alice:hunter2@@10.0.3.1:3128", + "http://@10.0.3.1:3128", + "https://alice:hunter2@10.0.3.1:3128", + "socks5://alice:hunter2@10.0.3.1:1080", + "http://alice@10.0.3.1:3128", + "http://alice:@10.0.3.1:3128", + "http://:hunter2@10.0.3.1:3128", + "http://10.0.3.1:3128", + "http://10.0.3.1:3128/path@notuserinfo", + "http://10.0.3.1:3128/?q=a@b", + "http://10.0.3.1:3128/#frag@ment", + "proxy.example.com:3128", + "10.0.3.1:3128", + "", + "http://[::1]:3128", + "http://alice:hunter2@[::1]:3128", + ]; + + let mut disagreements = Vec::new(); + for raw in 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; + }; + + let parser_sees_credentials = !parsed.username().is_empty() || parsed.password().is_some(); + let guard_sees_credentials = proxy_url_has_credentials(raw); + + if parser_sees_credentials && !guard_sees_credentials { + disagreements.push(format!( + "BYPASS: parser found userinfo (username={:?}, password={:?}) but the guard did not, for bytes {:?}", + parsed.username(), + parsed.password(), + raw.as_bytes() + )); + } + } + + assert!( + disagreements.is_empty(), + "the guard disagreed with the parser that will read the URL:\n{}", + disagreements.join("\n") + ); +} + +// A guard that simply answered "credentials" to everything would satisfy the +// differential test above while rejecting every legitimate proxy, so the +// agreement has to hold in the other direction too. +#[test] +fn the_guard_does_not_invent_credentials_the_parser_cannot_see() { + let clean = [ + "http://10.0.3.1:3128", + "https://proxy.example.com:8080", + "http://proxy.example.com", + "socks5://10.0.3.1:1080", + "http://[::1]:3128", + "http://10.0.3.1:3128/path", + "http://10.0.3.1:3128/path@notuserinfo", + "http://10.0.3.1:3128/?q=a@b", + ]; + + for raw in clean { + let parsed = Url::parse(raw).expect("corpus entry should parse"); + assert!( + parsed.username().is_empty() && parsed.password().is_none(), + "corpus entry {raw:?} was supposed to be credential-free" + ); + assert!( + !proxy_url_has_credentials(raw), + "the guard claimed {raw:?} carries credentials, but the parser sees none; \ + a guard that over-reports rejects legitimate proxies" + ); + } +} + +// 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 belongs to the special schemes and stops there, +// which is what keeps the guard from rejecting a proxy that leaks nothing. +// 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 at all -- so it names no credential, and a value with no host could +// never become a `ProxyAddress` in the first place. Applying the equivalence +// everywhere would make the guard claim a credential the parser cannot see, +// and rejection here is fatal: the caller destroys the container. +#[test] +fn a_backslash_after_a_non_special_scheme_is_a_path_not_an_authority() { + 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.cannot_be_a_base()); + assert!(parsed.username().is_empty() && parsed.password().is_none()); + assert!(parsed.host_str().is_none()); + + assert!( + !proxy_url_has_credentials(opaque), + "the guard claimed a credential the parser cannot see" + ); + assert_eq!(redact_proxy_url(opaque), opaque); + + // The ordinary `//` form of the same non-special scheme is a real + // authority, and that one does carry a credential. + assert!(proxy_url_has_credentials( + "socks5://alice:hunter2@10.0.3.1:1080" + )); +} From 63097891b11bc2ac42e42c824204d52e35a45039 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 22:39:24 -0700 Subject: [PATCH 53/55] [LXC] Correct the return-path rule's safety claim with a measurement Review argued that conntrack creates state before the filter/FORWARD decision, so a packet the MXC chain drops would still leave a NEW entry, and a cooperating peer could then send a reverse packet classified ESTABLISHED that this rule accepts. That would make the rule a genuine widening. Measured on the directly routed topology the argument names, with a positive control so a negative result means something. Two network namespaces either side of a forwarding host, FORWARD policy DROP, this exact rule installed, and an arrival counter inside the container. outbound ACCEPTed: 2 conntrack entries, this rule matched 3 times, 3 packets reached the container outbound DROPPED: 0 conntrack entries, this rule matched 0 times, 0 packets reached the container The reverse packets fell to the policy DROP. The mechanism does not hold: 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 comment was wrong in a different way, though, and that part is fixed. It said a flow could only have state because its outbound direction was accepted by this chain. State can also exist because the *host* authorized an inbound flow, and this rule accepts that flow's continuation. That is what stateful filtering means rather than a widening -- the first packet still had to pass the host's own policy -- but the comment overstated the invariant, so it now says the accurate thing and cites the measurement. Not established: the same experiment on the bridged topology. Its positive control failed -- no packet reached the container even with the outbound allowed -- so that harness proves nothing in either direction and no claim is made from it. Bridged behavior is covered by the LXC E2E job against real containers, which passes. --- .../lxc/common/src/network_iptables.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 5bfad0e2e..5aab749dd 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -613,11 +613,27 @@ impl NetworkIptablesManager { /// 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. A packet only matches - /// `ESTABLISHED,RELATED` if conntrack already has an entry for the flow, - /// and the flow can only have an entry because its outbound direction was - /// accepted by the chain. Inbound *new* connections match nothing here and - /// are left to the host policy exactly as before. + /// 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 From a4613e3559738112373e888b1952e80e9ae4b295 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Mon, 10 Aug 2026 23:10:09 -0700 Subject: [PATCH 54/55] Judge a proxy URL by what can leak, not only by what a parser sees Two more ways a proxy password reached argv, and a change to how the guard is tested so the next one is found by a machine rather than by a reviewer. `::@host` was read as empty userinfo. It is not: the first colon separates an empty username from a password of `:`. Measured against the parser the backend already uses, `http://::@h` yields `password = Some("%3A")` while the guard said there was nothing there. A backslash after a scheme was only honoured for the WHATWG special schemes. That was modelled on the parser, and it was the wrong model. The parser is authoritative for whether it *sees* userinfo; it is not authoritative for whether a password is sitting in a string that reaches argv and the failure diagnostic. `socks5:\/alice:hunter2@host` parses as an opaque path with no userinfo, and still carries the password in plain text. The two ways of being wrong do not cost the same -- over-reporting rejects a config, under-reporting publishes a secret -- so the equivalence now applies to every scheme. The differential test that found the backslash case listed its inputs by hand, and a hand-written list had by then missed two shapes. It now generates them: 7 schemes x 7 separators x 12 userinfos x 3 hosts x 5 tails x 5 paddings, over 500 of which the parser accepts. A miss is a hard failure; an over-report is only a failure when the string contains no `@` at all, since without one there is nothing to be suspicious of. Both fixes are mutation-tested. Reverting the userinfo check kills two tests, and dropping the backslash equivalence kills four -- the generated corpus catches each on its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d --- src/core/wxc_common/src/proxy_env.rs | 47 ++-- src/core/wxc_common/tests/proxy_env_spec.rs | 258 ++++++++++++-------- 2 files changed, 177 insertions(+), 128 deletions(-) diff --git a/src/core/wxc_common/src/proxy_env.rs b/src/core/wxc_common/src/proxy_env.rs index 1071c82de..5902c92c2 100644 --- a/src/core/wxc_common/src/proxy_env.rs +++ b/src/core/wxc_common/src/proxy_env.rs @@ -187,17 +187,17 @@ fn split_proxy_authority(url: &str) -> ProxyAuthority<'_> { Some(colon) if is_uri_scheme(&url[..colon]) => url.split_at(colon), _ => (&url[..0], url), }; - // For the special schemes, WHATWG reads a backslash exactly as a slash -- - // both where an authority begins and where it ends. Reading only `/` - // truncated `http:\/alice:hunter2@host` to an authority of `\`, which - // carries no `@`, so the guard reported no credentials while the password - // still reached argv. The equivalence is deliberately not applied to other - // schemes, because the parser does not apply it there either, and a guard - // that over-reports rejects proxies that leak nothing. - let backslash_is_a_slash = is_special_scheme(scheme); - let introduces_authority = |c: char| c == '/' || (backslash_is_a_slash && c == '\\'); - let ends_authority = - |c: char| matches!(c, '/' | '?' | '#') || (backslash_is_a_slash && c == '\\'); + + // 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(':') @@ -214,20 +214,6 @@ fn split_proxy_authority(url: &str) -> ProxyAuthority<'_> { } } -/// Whether `scheme` is one of the WHATWG "special" schemes, for which a -/// backslash is equivalent to a slash. -/// -/// The list is fixed by the URL Standard rather than open-ended, so naming it -/// here matches the parser instead of guessing at it. Comparison ignores case -/// because the parser lowercases the scheme before deciding, and -/// `HTTP:\/alice:hunter2@host` is the same URL as its lowercase form. -fn is_special_scheme(scheme: &str) -> bool { - let scheme = scheme.trim_start_matches(':'); - ["http", "https", "ws", "wss", "ftp", "file"] - .iter() - .any(|special| scheme.eq_ignore_ascii_case(special)) -} - /// Whether `candidate` satisfies the RFC 3986 scheme grammar, /// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`. /// @@ -264,13 +250,20 @@ fn is_uri_scheme(candidate: &str) -> bool { /// 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 -- so anything other than colons counts. +/// 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.chars().all(|c| c == ':') { + if userinfo.is_empty() || userinfo == ":" { return None; } Some((userinfo, host)) diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index 47bb806d0..b93fa4726 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -719,12 +719,16 @@ fn a_schemeless_value_with_userinfo_is_redacted_as_well_as_refused() { // 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", - "http://::@proxy.example.com:3128", ] { assert!( !proxy_url_has_credentials(url), @@ -734,6 +738,32 @@ fn empty_userinfo_is_not_a_credential() { } } +// 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. @@ -898,105 +928,121 @@ fn whitespace_around_a_clean_url_stays_clean() { // 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 strongest available -// assertion is not another hand-picked example but a differential one: for any -// input, if `url::Url::parse` finds userinfo, the guard must say so too. -// `url` is the crate `ProxyAddress` itself parses with, so it is the oracle, -// not a second opinion. -#[test] -fn the_guard_agrees_with_the_parser_that_will_actually_read_the_url() { - let corpus = [ - "http://alice:hunter2@10.0.3.1:3128", - "http:alice:hunter2@10.0.3.1:3128", - "http:/alice:hunter2@10.0.3.1:3128", - "alice@proxy.example.com:3128", - " http://alice:hunter2@10.0.3.1:3128", - "http://alice:hunter2@10.0.3.1:3128 ", - "\thttp://alice:hunter2@10.0.3.1:3128", - "http://ali\tce:hunter2@10.0.3.1:3128", - "http://alice:hun\nter2@10.0.3.1:3128", - "http://alice:hunter2@10.0.3.1:3128\r", - "http:\\\\alice:hunter2@10.0.3.1:3128", - "http:/\\alice:hunter2@10.0.3.1:3128", - "http:\\/alice:hunter2@10.0.3.1:3128", - "HTTP://alice:hunter2@10.0.3.1:3128", - "HtTp://alice:hunter2@10.0.3.1:3128", - "http://alice%40x:hunter2@10.0.3.1:3128", - "http://alice:hunter2@@10.0.3.1:3128", - "http://@10.0.3.1:3128", - "https://alice:hunter2@10.0.3.1:3128", - "socks5://alice:hunter2@10.0.3.1:1080", - "http://alice@10.0.3.1:3128", - "http://alice:@10.0.3.1:3128", - "http://:hunter2@10.0.3.1:3128", - "http://10.0.3.1:3128", - "http://10.0.3.1:3128/path@notuserinfo", - "http://10.0.3.1:3128/?q=a@b", - "http://10.0.3.1:3128/#frag@ment", - "proxy.example.com:3128", - "10.0.3.1:3128", +// 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 = [ "", - "http://[::1]:3128", - "http://alice:hunter2@[::1]:3128", + "@", + ":@", + "::@", + ":::@", + "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 +} - let mut disagreements = Vec::new(); - for raw in 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 { +#[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_credentials = !parsed.username().is_empty() || parsed.password().is_some(); - let guard_sees_credentials = proxy_url_has_credentials(raw); + let parser_sees = !parsed.username().is_empty() || parsed.password().is_some(); + let guard_sees = proxy_url_has_credentials(&raw); - if parser_sees_credentials && !guard_sees_credentials { - disagreements.push(format!( - "BYPASS: parser found userinfo (username={:?}, password={:?}) but the guard did not, for bytes {:?}", + 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!( - disagreements.is_empty(), - "the guard disagreed with the parser that will read the URL:\n{}", - disagreements.join("\n") + 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") ); -} - -// A guard that simply answered "credentials" to everything would satisfy the -// differential test above while rejecting every legitimate proxy, so the -// agreement has to hold in the other direction too. -#[test] -fn the_guard_does_not_invent_credentials_the_parser_cannot_see() { - let clean = [ - "http://10.0.3.1:3128", - "https://proxy.example.com:8080", - "http://proxy.example.com", - "socks5://10.0.3.1:1080", - "http://[::1]:3128", - "http://10.0.3.1:3128/path", - "http://10.0.3.1:3128/path@notuserinfo", - "http://10.0.3.1:3128/?q=a@b", - ]; - - for raw in clean { - let parsed = Url::parse(raw).expect("corpus entry should parse"); - assert!( - parsed.username().is_empty() && parsed.password().is_none(), - "corpus entry {raw:?} was supposed to be credential-free" - ); - assert!( - !proxy_url_has_credentials(raw), - "the guard claimed {raw:?} carries credentials, but the parser sees none; \ - a guard that over-reports rejects legitimate proxies" - ); - } } // The fifth bypass, and the second one a differential test caught rather than @@ -1045,31 +1091,41 @@ fn a_backslash_still_ends_an_authority_it_does_not_only_begin_one() { ); } -// The backslash equivalence belongs to the special schemes and stops there, -// which is what keeps the guard from rejecting a proxy that leaks nothing. -// 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 at all -- so it names no credential, and a value with no host could -// never become a `ProxyAddress` in the first place. Applying the equivalence -// everywhere would make the guard claim a credential the parser cannot see, -// and rejection here is fatal: the caller destroys the container. +// 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_after_a_non_special_scheme_is_a_path_not_an_authority() { +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.cannot_be_a_base()); - assert!(parsed.username().is_empty() && parsed.password().is_none()); - assert!(parsed.host_str().is_none()); + 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 guard claimed a credential the parser cannot see" + 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}" ); - assert_eq!(redact_proxy_url(opaque), opaque); - // The ordinary `//` form of the same non-special scheme is a real - // authority, and that one does carry a credential. + // 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" )); From 82e8b0e39e691069b7fced51f561368b7ad054d2 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Tue, 11 Aug 2026 12:51:31 -0700 Subject: [PATCH 55/55] Fix two E2E scripts that asserted on chain names that can never exist run_lxc_network_enforcement_test.sh and run_lxc_network_deny_precedence_test.sh hardcoded chain names such as MXC-CLI-LXC-Net-Deny. Chain names are derived -- chain_name_for produces MXC--, where the hash is 16 base32 characters of the SHA-256 of the container name -- so a literal name never matches a real chain. `iptables -S "$CHAIN"` therefore always failed, and the cleanup check read that failure as "the chain was properly removed." Both scripts passed while inspecting nothing. The four other network scripts already derived the name from the chain-creation log line and took a before/after snapshot of MXC chains. Both broken scripts now follow that pattern: mxc_chains, assert_no_new_mxc_chains, derive_chain_name, a shape check on the derived name, and a snapshot taken before each run. Add chain_name_script_drift_spec.rs to keep this from coming back. It is a drift guard over the scripts, not a unit test -- it reads files, so it lives in its own file and leaves chain_name_spec.rs dependency-free. chain_name_spec.rs already had 20 green naming tests on the day these scripts were broken, so more tests of chain_name_for could not have caught this; the guard has to read the scripts. It fails if any script names a specific chain, if the shape checks copy-pasted across five scripts drift apart, if the pinned shape stops accepting what chain_name_for actually produces, if a script derives a name without validating it, or if a script asserts on a chain name it never derived. Also correct six documentation and comment claims that the implementation contradicts: - schema.md said the LXC proxy allowance covers egress; it covers forwarded egress, and traffic to the bridge gateway arrives on INPUT instead. - lxc-backend.md said a blocked container "reaches nothing," and did not mention the FORWARD/INPUT split or the two parse-time rejections (enforcementMode must be firewall or both; the proxy URL may not carry credentials, since argv is world-readable through /proc//cmdline). - run_lxc_network_proxy_test.sh claimed the fixture pins a hosts entry. The fixture proxy is http://10.0.3.1:3128, an IP literal, and host_pin returns Ok(None) for those, so no pin is written. It also claimed no CI job invokes the LXC suite; lxc-e2e.yml does. - lxc-e2e.yml proposed a conntrack return rule as the fix for the return path. The PR measured both return-rule forms inert. Address-scoping is the fix and is deferred, so the comment now records the measurement rather than a disproven proposal. The return-path defect itself is unchanged and still deferred. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f151c717-2eee-4d1d-8498-081504aef847 --- .github/workflows/lxc-e2e.yml | 10 +- docs/lxc-support/lxc-backend.md | 26 +- docs/schema.md | 9 +- .../tests/chain_name_script_drift_spec.rs | 316 ++++++++++++++++++ .../run_lxc_network_deny_precedence_test.sh | 78 ++++- .../run_lxc_network_enforcement_test.sh | 78 ++++- tests/scripts/run_lxc_network_proxy_test.sh | 19 +- 7 files changed, 503 insertions(+), 33 deletions(-) create mode 100644 src/backends/lxc/common/tests/chain_name_script_drift_spec.rs diff --git a/.github/workflows/lxc-e2e.yml b/.github/workflows/lxc-e2e.yml index 0d6f81172..4b1b89b0a 100644 --- a/.github/workflows/lxc-e2e.yml +++ b/.github/workflows/lxc-e2e.yml @@ -69,8 +69,14 @@ jobs: # 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 would fix the reply path - # but leave the DROP policy, and with it the vacuous pass. + # 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 diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index 4ad7942c1..5a204f2a0 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -212,8 +212,15 @@ Firewall state is torn down automatically with best-effort removal of the `FORWA `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 reaches -nothing rather than reaching the internet directly. +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 @@ -222,6 +229,21 @@ 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: diff --git a/docs/schema.md b/docs/schema.md index e935d5be9..29190ebae 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -62,9 +62,12 @@ production configs and the dev schema when working on experimental features: // only via { "url": "http://proxy.example:8080" } // (own-netns: localhost/builtinTestServer are // unreachable, rejected) - // Under LXC the proxy is enforced: egress is restricted - // to the proxy endpoint and nothing else, so the - // allow/block host lists and DNS are not opened. + // 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/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/tests/scripts/run_lxc_network_deny_precedence_test.sh b/tests/scripts/run_lxc_network_deny_precedence_test.sh index 770ffbfb2..7c813e893 100644 --- a/tests/scripts/run_lxc_network_deny_precedence_test.sh +++ b/tests/scripts/run_lxc_network_deny_precedence_test.sh @@ -42,21 +42,56 @@ command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed OVERLAP_CONFIG="$REPO_DIR/tests/configs/lxc_network_deny_precedence_overlap.json" CONTROL_CONFIG="$REPO_DIR/tests/configs/lxc_network_deny_precedence_control.json" -OVERLAP_CHAIN="MXC-CLI-LXC-Net-DenyWins" -CONTROL_CHAIN="MXC-CLI-LXC-Net-DenyCtl" 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() { - if iptables -S "$1" >/dev/null 2>&1; then - fail "iptables chain '$1' was left behind after lxc-exec completed." + 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 "$1" >/dev/null 2>&1; then - fail "ip6tables chain '$1' was left behind after lxc-exec completed." + 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() { @@ -65,9 +100,28 @@ assert_no_forward_reference() { 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" @@ -75,10 +129,13 @@ 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 -assert_no_forward_reference "$CONTROL_CHAIN" -assert_firewall_chain_cleaned_up "$CONTROL_CHAIN" +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" @@ -89,8 +146,9 @@ 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 -assert_no_forward_reference "$OVERLAP_CHAIN" -assert_firewall_chain_cleaned_up "$OVERLAP_CHAIN" +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 index 9887c87e9..689836c4f 100644 --- a/tests/scripts/run_lxc_network_enforcement_test.sh +++ b/tests/scripts/run_lxc_network_enforcement_test.sh @@ -39,21 +39,56 @@ command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed DENY_CONFIG="$REPO_DIR/tests/configs/lxc_network_enforcement_deny.json" ALLOW_CONFIG="$REPO_DIR/tests/configs/lxc_network_enforcement_allow.json" -DENY_CHAIN="MXC-CLI-LXC-Net-Deny" -ALLOW_CHAIN="MXC-CLI-LXC-Net-Allow" 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() { - if iptables -S "$1" >/dev/null 2>&1; then - fail "iptables chain '$1' was left behind after lxc-exec completed." + 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 "$1" >/dev/null 2>&1; then - fail "ip6tables chain '$1' was left behind after lxc-exec completed." + 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 @@ -65,12 +100,31 @@ assert_no_forward_reference() { 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" @@ -81,10 +135,13 @@ 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 -assert_no_forward_reference "$DENY_CHAIN" -assert_firewall_chain_cleaned_up "$DENY_CHAIN" +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" @@ -95,8 +152,9 @@ 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 -assert_no_forward_reference "$ALLOW_CHAIN" -assert_firewall_chain_cleaned_up "$ALLOW_CHAIN" +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_test.sh b/tests/scripts/run_lxc_network_proxy_test.sh index 180179695..d4f42fbcb 100644 --- a/tests/scripts/run_lxc_network_proxy_test.sh +++ b/tests/scripts/run_lxc_network_proxy_test.sh @@ -31,18 +31,25 @@ # 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 and the hosts pin are 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 +# 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 -# and no CI job invokes the LXC suite, so treat it as unproven until executed -# on a Linux host. +# 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)"