Skip to content

[LXC] Network policy enforcement decided from static config or a failed probe fails open #975

Description

Relevant area(s)

Linux

Brief description of your issue

Two places decide whether a firewall-enforced network policy can be applied
using information that the running container can contradict. Both fail open
rather than closed, and neither logs the downgrade, so a caller that asked for
a default-deny policy can get an unfiltered or partially filtered container and
still receive success.

1. A failed interface probe silently downgrades pre-start enforcement
(src/backends/lxc/common/src/lxc_runner.rs, the Err arm around line 261 and
the unwrap_or(false) around line 294)

configured_net_interfaces() returning Err is collapsed to None. None
then means "not pinnable", which is the same answer the code gives for a
container that genuinely has several interfaces. The pre-start egress install
is skipped, the container starts, and the rules are applied only on the
post-start path after wait_for_network. The comment immediately below the
probe states the requirement this violates: "Install egress before the container
starts, so nothing inside it transmits during an interval MXC already reports as
deny-all." The count > 1 refusal is also skipped entirely when the probe
failed, so an unreadable config bypasses the multi-interface check as well.

2. The veth pin hook takes the first peered interface without verifying it is
the only one
(src/backends/lxc/common/src/lxc_bindings.rs, VETH_PIN_SCRIPT
around line 330)

The script uses head -n1 and only fails when there are zero peered
interfaces. Its own doc comment claims it "finds the container's sole peered
interface", but nothing checks sole-ness. The count > 1 refusal that would
otherwise catch this reads the static lxc.net configuration, so it cannot
see an interface created imperatively at runtime by an lxc.hook.start-host
entry that the adopted container already carried — the MXC hook is appended
after those. The static count passes, MXC pins and filters one peer, and the
other continues unfiltered for the lifetime of the container.

Steps to reproduce

For (1):

  1. Request a policy where requires_firewall() is true against an LXC container.
  2. Make configured_net_interfaces() fail — for example run without the
    privileges lxc-info needs, or point at a container whose config cannot be
    parsed.
  3. Observe that the run proceeds: the container starts before any egress rule is
    installed, the rules land only on the post-start path, and the call returns
    success.

For (2):

  1. Take an existing container whose config already contains an
    lxc.hook.start-host entry that creates an additional veth at start.
  2. Adopt it with a firewall-enforced network policy, so the static lxc.net
    count is 1 and the refusal does not fire.
  3. Observe the MXC hook rename and filter one peer while the interface created
    by the pre-existing hook stays unfiltered.

Expected behavior

Both cases fail closed. A probe that could not be read is a refusal when the
policy requires a firewall, not a silent downgrade to post-start enforcement.
The pin hook refuses unless the live namespace contains exactly one peered
interface, rather than selecting the first of several.

Actual behavior

Both cases fail open. Case (1) starts a container the caller believes is
default-deny with unrestricted egress until the post-start path completes, and
skips the multi-interface refusal as well. Case (2) leaves a second interface
permanently unfiltered while reporting success. Neither path warns.


Raised by Copilot review on PR #849
(threads on lxc_runner.rs and lxc_bindings.rs); both verified by reading the
code at commit 0289a927. Neither was dynamically reproduced.

Part of #896 — the shared root cause is an enforcement decision made from static
or unavailable information that the running container can contradict.

Metadata

Metadata

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions