Skip to content

[Bubblewrap][Seatbelt] Reach the host's loopback services outbound, refuse blocked connections at once, and log them - #1708

Draft
Carmine Paolino (crmne) wants to merge 1 commit into
microsoft:mainfrom
crmne:egress-host-loopback
Draft

Carmine Paolino (crmne) wants to merge 1 commit into
microsoft:mainfrom
crmne:egress-host-loopback

Conversation

@crmne

@crmne Carmine Paolino (crmne) commented Nov 8, 2026 •

Copy link
Copy Markdown

📖 Description

This adds three optional network.egress fields to the 1.1.0-alpha contract. Each one sits behind its own NetworkPolicySupport bit, and every backend that doesn't declare that bit refuses the field.

  • hostLoopbackPorts: [u16]: TCP ports of the host's own loopback the sandbox may connect to, outbound only. Ingress is unchanged.
    • Bubblewrap: each port gets an ACCEPT at slirp's 10.0.2.2 ahead of the host-loopback drop. The sandbox's own 127.0.0.1:<port> reaches the host's through a nat DNAT in the sandbox namespace, with route_localnet and a masquerade on tap0. Only the listed ports are redirected, so a workload's own servers on other ports stay its own.
    • Seatbelt: each port gets (allow network-outbound (remote tcp "localhost:<port>")), after the host-loopback deny.
  • blocked: "drop" | "reject": on Bubblewrap, "reject" turns every blocking verdict into REJECT, so clients fail at once instead of timing out. Seatbelt already fails at once, so it accepts "reject" as is.
  • blockedLog: "<absolute path>" (Bubblewrap only):
    • An NFLOG copy precedes each blocking verdict.
    • MXC reads those copies inside the sandbox's network namespace, through a netlink socket that a short-lived forked child creates there (it joins the owning user namespace, then the network namespace) and hands back over a socket pair.
    • The reader starts after the rules are installed and before the workload is released.
    • MXC appends one JSON line per distinct blocked destination (protocol, host, port, and the question's name for a DNS query), at most 256 lines.

Any of the three fields selects the firewall-enforced private namespace, even with a deny default and no rules. None of them can be combined with a runtime proxy. BaseContainer now declares the features it enforces explicitly instead of ALL.

Limitations:

  • IPv4 only, as slirp is.
  • The nat table needs nf_nat, which an unprivileged sandbox can't load. Without it, the sandbox fails to start and says why.
  • Logging is best effort: if the socket can't be made, the run continues with its rules and a warning.

🔗 References

Resolves #1707. Opened as a draft to agree on the approach first, as CONTRIBUTING asks for schema changes. I'm happy to reshape the fields.

🔍 Validation

  • Unit tests: cargo test -p mxc-sdk --lib -- bubblewrap validator network contract schema seatbelt passes on Linux (1136 tests). The new tests cover:
    • the rendered chains (the ports ahead of the gateway drop, REJECT, the NFLOG lines)
    • the nat payload
    • a relative blockedLog being refused
    • the support bits, with probes
    • the NFLOG and DNS packet parsing
    • the Seatbelt profile ordering
  • Linting: cargo clippy -p mxc-sdk --all-targets -- -D warnings and cargo fmt --all -- --check are clean on the pinned 1.93 toolchain.
  • Schema: schemas/dev/mxc-config.schema.1.1.0-alpha.json was regenerated with mxc_schema_gen.
  • Linux, for real, on Bubblewrap with slirp4netns (through an embedding app):
    • A PostgreSQL container on docker0 and one published on 127.0.0.1:55432 are both reached, the latter as the sandbox's own localhost:55432.
    • A workload's own server on its loopback still works.
    • 1.1.1.1:443 is refused in about a second and logged.
    • A blocked getent hosts rubygems.org is logged with its name.
  • macOS 27, for real, on Seatbelt: a listed loopback port is reached, and the internet is refused at once.
  • Not run: the Windows build. BaseContainer's change is declarative only.

✅ Checklist

📋 Issue Type

  • Bug fix
  • Feature
  • Task
Microsoft Reviewers: Open in CodeFlow

…efuse blocked connections at once, and log them

Three optional fields in network.egress (1.1.0-alpha):

- hostLoopbackPorts: TCP ports of the host's own loopback the sandbox may
  connect to, outbound only; ingress stays as it was. On Bubblewrap they are
  accepted at slirp's gateway ahead of its drop, and the sandbox's own
  127.0.0.1:<port> reaches them through a DNAT in its namespace
  (route_localnet, masquerade on tap0), so a server the sandbox starts on
  any other port of its loopback stays its own. On Seatbelt they are
  (remote tcp "localhost:<port>") rules after the host-loopback deny.
- blocked: "reject" refuses a blocked connection at once instead of
  dropping it, so clients fail fast. Seatbelt already does.
- blockedLog (Bubblewrap): an absolute path to append one JSON line to per
  blocked outbound connection. Each blocking verdict is preceded by an
  NFLOG copy, read inside the sandbox's network namespace by a netlink
  socket a short-lived child makes there and hands back, started before
  the workload is released. A blocked DNS query also carries the name it
  asked for.

New NetworkPolicySupport bits gate each field; Bubblewrap declares all
three, Seatbelt the first two, and every other backend refuses them.
Copilot AI balanced review requested due to automatic review settings November 8, 2026 01:08

Copilot AI left a comment

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.

🟡 Changes recommended

Unresolved security and correctness issues permit proxy-confinement and explicit-deny bypasses and can hang sandbox startup.

9 open issues
What changed in this PR

Adds development-contract network controls to MXC for outbound access to selected host services, immediate rejection of blocked connections, and Bubblewrap blocked-traffic logging, addressing #1707.

Changes:

  • Adds hostLoopbackPorts, blocked, and blockedLog to the 1.1.0-alpha contract, adapters, schema, and capability validation.
  • Implements Bubblewrap firewall, loopback redirection, and logging support, plus Seatbelt host-port grants.
  • Adds unit coverage and documentation while preserving defaults for older contracts.
File Description
src/​mxc-sdk/​src/​core/​mxc_contract/​dev/​network.rs Defines the new egress controls.
src/​mxc-sdk/​src/​core/​mxc_contract/​dev/​mod.rs Exports the blocked-action enum.
src/​mxc-sdk/​src/​core/​mxc_common/​wire.rs Adds internal egress fields and defaults.
src/​mxc-sdk/​src/​core/​mxc_common/​validator.rs Adds capability bits and unsupported-policy checks.
src/​mxc-sdk/​src/​core/​mxc_common/​sdk_input.rs Defaults new fields during SDK conversion.
src/​mxc-sdk/​src/​core/​mxc_common/​network_parser.rs Converts and validates new egress values.
src/​mxc-sdk/​src/​core/​mxc_common/​network_parser_ingress_default_tests.rs Updates test construction for new defaults.
src/​mxc-sdk/​src/​core/​mxc_common/​models.rs Extends the runtime egress policy.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​v1_0/​state_aware.rs Preserves lifecycle conversion defaults.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​v1_0/​common.rs Preserves v1.0 network defaults.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​v0_9/​state_aware.rs Preserves older lifecycle conversion defaults.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​v0_9/​common.rs Preserves v0.9 network defaults.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​dev/​state_aware.rs Updates lifecycle network construction.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​dev/​common.rs Maps development-contract egress controls.
src/​mxc-sdk/​src/​backends/​seatbelt/​common/​seatbelt_runner.rs Declares supported network capabilities.
src/​mxc-sdk/​src/​backends/​seatbelt/​common/​profile_builder.rs Emits and tests outbound host-port grants.
src/​mxc-sdk/​src/​backends/​process_container/​common/​base_container_runner.rs Replaces blanket support with explicit capabilities.
src/​mxc-sdk/​src/​backends/​nanvix/​runner/​mod.rs Updates test policy defaults.
src/​mxc-sdk/​src/​backends/​lxc/​common/​network_iptables_ga_egress_spec.rs Updates test policy construction.
src/​mxc-sdk/​src/​backends/​lxc/​common/​lxc_runner.rs Updates network test defaults.
src/​mxc-sdk/​src/​backends/​isolation_session/​common/​policy.rs Updates a policy test fixture.
src/​mxc-sdk/​src/​backends/​bubblewrap/​common/​proxy_network.rs Installs NAT rules and manages logging lifecycle.
src/​mxc-sdk/​src/​backends/​bubblewrap/​common/​network_rules.rs Renders host-port, REJECT, NFLOG, and NAT rules.
src/​mxc-sdk/​src/​backends/​bubblewrap/​common/​mod.rs Registers the Linux blocked-log module.
src/​mxc-sdk/​src/​backends/​bubblewrap/​common/​bwrap_runner.rs Declares and unit-tests new capabilities.
src/​mxc-sdk/​src/​backends/​bubblewrap/​common/​bwrap_command.rs Updates network-mode and proxy-conflict predicates.
src/​mxc-sdk/​src/​backends/​bubblewrap/​common/​blocked_log.rs Adds namespace logging and packet parsing.
schemas/​dev/​mxc-config.schema.1.1.0-alpha.json Adds generated schema definitions.
docs/​schema.md Illustrates the new egress fields.
docs/​backends/​bwrap/​bubblewrap-backend.md Documents behavior, logging, and prerequisites.

Review effort: Balanced

Comment on lines +79 to +83
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.map_err(|error| {
format!("Bubblewrap: failed to open the blocked-connection log: {error}")
})?;
Comment on lines +817 to +819
for &port in &egress.host_loopback_ports {
rules.push(EgressRule {
verdict: RuleVerdict::Accept,
}

write_host_loopback_rules(out, policy, allow_outbound);
write_host_loopback_port_rules(out, policy);
Comment on lines +104 to +108
#[serde(default)]
pub host_loopback_ports: OptionalField<Vec<NonZeroU16>>,
/// Optional answer to a blocked outbound connection. Defaults to `drop`.
#[serde(default)]
pub blocked: OptionalField<NetworkBlockedAction>,
Comment on lines +561 to +562
&& egress.host_loopback_ports.is_empty()
&& egress.blocked_log.is_none()
Comment on lines +646 to +647
|| !egress.host_loopback_ports.is_empty()
|| egress.blocked_log.is_some()
Comment on lines +94 to +96
| NetworkPolicySupport::EGRESS_HOST_LOOPBACK_PORTS
| NetworkPolicySupport::EGRESS_BLOCKED_REJECT
| NetworkPolicySupport::EGRESS_BLOCKED_LOG
Comment on lines +875 to +879
let mut payload = String::from("*nat\n");
for port in &self.host_loopback_ports {
payload.push_str(&format!(
"-A OUTPUT -d 127.0.0.1/32 -p tcp --dport {port} -j DNAT --to-destination \
{gateway}:{port}\n"
Comment thread docs/schema.md
Comment on lines +230 to +232
"hostLoopbackPorts": [5432], // 1.1.0-alpha, Bubblewrap and Seatbelt: TCP ports
// of the host's loopback the sandbox may connect to,
// outbound only (ingress is unchanged)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: outbound-only host-loopback ports, and refusing and logging blocked egress

2 participants