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):
- Request a policy where
requires_firewall() is true against an LXC container.
- Make
configured_net_interfaces() fail — for example run without the
privileges lxc-info needs, or point at a container whose config cannot be
parsed.
- 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):
- Take an existing container whose config already contains an
lxc.hook.start-host entry that creates an additional veth at start.
- Adopt it with a firewall-enforced network policy, so the static
lxc.net
count is 1 and the refusal does not fire.
- 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.
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, theErrarm around line 261 andthe
unwrap_or(false)around line 294)configured_net_interfaces()returningErris collapsed toNone.Nonethen 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 theprobe 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 > 1refusal is also skipped entirely when the probefailed, 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_SCRIPTaround line 330)
The script uses
head -n1and only fails when there are zero peeredinterfaces. Its own doc comment claims it "finds the container's sole peered
interface", but nothing checks sole-ness. The
count > 1refusal that wouldotherwise catch this reads the static
lxc.netconfiguration, so it cannotsee an interface created imperatively at runtime by an
lxc.hook.start-hostentry 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):
requires_firewall()is true against an LXC container.configured_net_interfaces()fail — for example run without theprivileges
lxc-infoneeds, or point at a container whose config cannot beparsed.
installed, the rules land only on the post-start path, and the call returns
success.
For (2):
lxc.hook.start-hostentry that creates an additional veth at start.lxc.netcount is 1 and the refusal does not fire.
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.rsandlxc_bindings.rs); both verified by reading thecode 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.