Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5f5ae13
[LXC] Network model 2: deny-all-except-proxy (AB#62830341)
dhoehna Jul 10, 2026
64c072e
[LXC] Address PR #632 review: reject localhost proxy, gate DNS in pro…
dhoehna Jul 13, 2026
6425119
Add LXC network proxy sample config (tests/configs/lxc_network_proxy.…
dhoehna Jul 15, 2026
775dc87
Merge microsoft/mxc main (47a172c) into lxc-net-model2-deny-all-excep…
dhoehna Jul 15, 2026
9648284
Merge remote-tracking branch 'upstream/main' into user/dahoehna/lxc-n…
dhoehna Jul 20, 2026
3fd32ed
[LXC] Address PR #632 review: rollback, IPv6 parity, pinned proxy, DN…
dhoehna Jul 30, 2026
f22f839
Merge upstream/main into lxc-net-model2-deny-all-except-proxy
dhoehna Jul 30, 2026
80fb608
Merge remote-tracking branch 'upstream/main' into user/dahoehna/lxc-n…
dhoehna Jul 30, 2026
4c71e2e
Address review: close conntrack, INPUT, IPv6 and rollback gaps in LXC…
dhoehna Jul 30, 2026
7389263
Merge remote-tracking branch 'upstream/main' into user/dahoehna/lxc-n…
dhoehna Aug 4, 2026
81362b6
[LXC] Fix Bubblewrap regression from veth-scoped firewall enforcement
dhoehna Aug 4, 2026
8df9dff
[LXC] Set DHCP ownership flags only after the insert succeeds
dhoehna Aug 4, 2026
84ac24c
[LXC] Stop rewrite_url_host from eating a query or fragment character
dhoehna Aug 4, 2026
29cf20e
[LXC] Reject loopback proxy URLs under the deny-all model
dhoehna Aug 4, 2026
ed6ee5b
[LXC] Explicitly reject IPv6 proxy endpoints
dhoehna Aug 4, 2026
a273708
[LXC] Add executable deny-all-except-proxy integration test
dhoehna Aug 4, 2026
7c4e03b
[LXC] Restore fail-closed veth invariant coverage in lxc_runner
dhoehna Aug 4, 2026
c5484b1
[bwrap] Add + register no-veth firewall regression guard
dhoehna Aug 4, 2026
f276fb2
Add spec-derived black-box tests for host_is_loopback and rewrite_url…
dhoehna Aug 4, 2026
815758a
Un-ignore documenting tests; add characterization tests for contract …
dhoehna Aug 4, 2026
fff3c93
Fix IPv6 host bracketing in rewrite_url_host and pinned_to_ip fallback
dhoehna Aug 4, 2026
c917434
fix(proxy_env): always return true from disabled branch
dhoehna Aug 4, 2026
a7a6194
fix(tests): avoid expect_fun_call in IPv6 proxy URL tests
dhoehna Aug 4, 2026
6e7591c
fix(tests): stop counting a skipped bwrap firewall run as a pass
dhoehna Aug 4, 2026
1ed2ae6
Skip with exit 77 in the LXC proxy test so a skip is not tallied as a…
dhoehna Aug 5, 2026
63578db
Address review feedback on deny-all-except-proxy
Aug 6, 2026
d563cbb
Make the start-ownership bit impossible to forget rather than merely …
dhoehna Aug 6, 2026
c3af5f3
Close two fail-open paths the review found
dhoehna Aug 6, 2026
6756a3b
Republish firewall ownership after teardown instead of leaving it stale
dhoehna Aug 6, 2026
6c52d93
Fail closed on an unresolvable blocked host under either default policy
dhoehna Aug 6, 2026
17ea610
[LXC] Tear down only the firewall objects the record names
dhoehna Aug 7, 2026
96042f4
[LXC] Attempt every hook delete, so a failed one cannot hide the next
dhoehna Aug 7, 2026
10f0599
[LXC] Make hook teardown converge, and stop flushing a referenced chain
dhoehna Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ production configs and the dev schema when working on experimental features:
"enforcementMode": "firewall", // "capabilities", "firewall", or "both"
"proxy": { "localhost": 8080 } // Loopback proxy port (processcontainer; bubblewrap; seatbelt)
// (use { "builtinTestServer": true } for the bundled
// testing-only proxy; requires --allow-testing-features)
// testing-only proxy; requires --allow-testing-features.
// lxc can't reach a loopback proxy — use { "url": "..." })
},

"processContainer": { // Process-based container-specific
Expand Down
44 changes: 40 additions & 4 deletions src/backends/lxc/common/src/lxc_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,26 +68,45 @@ 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<String> {
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`).
///
/// Extracted so the env / cwd / command layering is unit-testable without
/// 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<String> {
fn build_attach_args_with_env_control(
env: &[String],
working_directory: &str,
command: &str,
force_clear_env: bool,
) -> Vec<String> {
// Loose upper bound; realloc-avoidance hint only.
let mut args: Vec<String> = Vec::with_capacity(env.len() + 8);

// Replace semantics: any non-empty env opts the caller into a clean
// 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
Expand Down Expand Up @@ -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
Expand All @@ -314,14 +337,20 @@ impl LxcContainer {
command: &str,
working_directory: &str,
env: &[String],
force_clear_env: bool,
timeout: Option<std::time::Duration>,
) -> Result<(i32, String, String), String> {
use mxc_pty::{run_with_pty, PtyOptions, PtyOutcome, Signal};

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,
Expand All @@ -348,6 +377,7 @@ impl LxcContainer {
_command: &str,
_working_directory: &str,
_env: &[String],
_force_clear_env: bool,
_timeout: Option<std::time::Duration>,
) -> Result<(i32, String, String), String> {
Err("LxcContainer::attach_run is only supported on Linux".to_string())
Expand Down Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions src/backends/lxc/common/src/lxc_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,14 @@ impl LxcScriptRunner {
let _ = writeln!(logger, "Container already running.");
}

// Wait for network only when the config uses network features (firewall rules
// or allowed/blocked hosts).
// Wait for network only when the config uses network features
// (firewall rules, allowed/blocked hosts, or proxy enforcement).
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);
Comment on lines +355 to 358

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shall we address this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and it is not fixed in this PR. container.start() is at lxc_runner.rs:330, the optional address wait at :358, veth discovery at :361, and apply_firewall_rules only at :408, so anything already inside the container has unfiltered egress for that whole span. Moving proxy pinning ahead of start removed host DNS work from the window but did not close it.

Closing it properly needs a veth-scoped quarantine chain installed the moment the interface exists and then atomically swapped for the real policy, which is a new enforcement stage with its own rollback and teardown semantics rather than a reordering. I filed #764 for it rather than growing this PR further. The window is established by reading the call ordering, not by observing a packet escape.

Expand All @@ -207,6 +208,27 @@ impl LxcScriptRunner {
// Configure network rules
let mut fw_manager = NetworkIptablesManager::new(&container_name);

// Pin a hostname proxy to the address the host resolved, once, before
// anything consumes it. The firewall ACCEPT and the HTTP(S)_PROXY handed
// to the container must name the same endpoint: if the container
// re-resolved the hostname itself it could pick a different address
// under round-robin or split-horizon DNS and be dropped by its own
// policy. Pinning also means the container never needs a resolver, so
// DNS stays closed under deny-all-except-proxy.
let mut effective_policy = request.policy.clone();
match NetworkIptablesManager::pin_proxy_to_resolved_ip(
&effective_policy.network_proxy,
logger,
) {
Ok(pinned) => effective_policy.network_proxy = pinned,
Err(e) => {
if self.destroy_on_exit || container_created {
let _ = container.destroy();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agree with bot. Shall we fix this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. SetupUndo now distinguishes destroy, stop, and leave, and setup_undo_action returns Stop when this invocation started a pre-existing container that must be preserved. container_started is now the result of the start if expression, and all six setup failure exits go through undo_container_setup. This is source and unit-test coverage only; it has not been exercised against live LXC.

}
return ScriptResponse::error(&format!("Network policy error: {}", e));
}
}

// Try to discover the container's veth interface for scoped rules
if let Some(veth) = NetworkIptablesManager::discover_veth_interface(&container_name) {
let _ = writeln!(logger, "Discovered veth interface: {}", veth);
Expand All @@ -218,7 +240,7 @@ impl LxcScriptRunner {
}
}

match fw_manager.apply_firewall_rules(&request.policy, logger) {
match fw_manager.apply_firewall_rules(&effective_policy, logger) {
Ok(true) => {}
Ok(false) => {
if self.destroy_on_exit || container_created {
Expand All @@ -242,10 +264,14 @@ 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();
let force_clear_env =
wxc_common::proxy_env::apply_proxy_env(&mut exec_env, &effective_policy.network_proxy);
let result = container.attach_run(
&request.script_code,
&request.working_directory,
&request.env,
&exec_env,
force_clear_env,
timeout,
);

Expand Down
Loading
Loading