-
Notifications
You must be signed in to change notification settings - Fork 62
[LXC] Enforce the deny-all-except-proxy network policy (model 2) #798
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Darren Hoehna (dhoehna)
wants to merge
55
commits into
main
Choose a base branch
from
user/dahoehna/lxc-network-model2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+8,302
−172
Open
Changes from 17 commits
Commits
Show all changes
55 commits
Select commit
Hold shift + click to select a range
d0705a0
[LXC] Scrub and apply proxy env vars through the shared helper
dhoehna b74f5bf
Correct the LXC client note: the integration is planned, not wired
dhoehna 40514a6
[LXC] Pin the proxy hostname instead of rewriting the URL host
dhoehna 764900b
Drop a dead bracket guard and document why unbracketing is load-bearing
dhoehna bf37b12
[LXC] Make an unpinnable proxy address unrepresentable
dhoehna 445ea2e
[LXC] Update proxy address spec for the unpinnable-address fix
dhoehna 91b3760
[LXC] Fail closed when firewall rules cannot be scoped to the container
dhoehna a3d82c0
[LXC] Spec the fail-closed contract for unscopeable firewall rules
dhoehna 47e00a9
[LXC] Hook the firewall chain onto the bridge port so it actually fil…
dhoehna 3a05f6a
[LXC] Spec the FORWARD hook contract and assert enforcement end to end
dhoehna 792d1ae
[LXC] Make deny rules win over allow rules and fail closed on an unre…
dhoehna 988b609
[LXC] Spec deny precedence and assert an overlapping allow cannot def…
dhoehna 8595d5d
[LXC] Correct the network policy docs and make the E2E suite gate in CI
dhoehna 4ed9d4d
[LXC] Set FORWARD to ACCEPT in CI so only MXC rules can block
dhoehna 76eb242
Keep Bubblewrap startable when no veth exists to scope the chain to
dhoehna 3dd4196
Cover the Bubblewrap veth declaration so deleting it fails a test
dhoehna c098239
Pin the negative case of the missing-veth accessor
dhoehna 76c4c51
[LXC] Admit network.proxy for LXC and reject the forms it cannot reach
dhoehna d1da5d3
[LXC] Let a caller force lxc-attach to clear the inherited environment
dhoehna de6c96a
[LXC] Wire the proxy-env scrub into the production execution path
dhoehna 8c6fa72
[LXC] Restrict egress to the proxy endpoint when one is configured
dhoehna a46ad0a
[LXC] Pin the proxy host inside the container before running the script
dhoehna 8dd810d
[LXC] Add the deny-all-except-proxy integration test
dhoehna 63974b7
[LXC] Document the cooperative-proxy posture
dhoehna f397020
[LXC] Scope the integration test's DNS claims to what FORWARD can see
dhoehna daac8ab
[LXC] Require a firewall enforcement mode for network.proxy
dhoehna cfd062c
[LXC] Fail closed when an allow rule can outrank an unresolvable deny
dhoehna aafb0de
[LXC] Claim FORWARD hooks before installing them, not after
dhoehna 19ac107
[LXC] Clear a stale proxy host pin when a run pins nothing
dhoehna edb9d38
Merge remote-tracking branch 'origin/main' into user/dahoehna/lxc-net…
dhoehna c3d133f
[LXC] Derive the chain name in the specs instead of hard-coding it
dhoehna c7c6142
[LXC] Narrow the deny-precedence failure to a catch-all allow
dhoehna 130b1c9
[LXC] Reject proxy URLs that carry credentials
dhoehna 3de0f5b
[LXC] Stop staging /etc/hosts through a predictable /tmp file
dhoehna c00f941
[LXC] Fail closed on a proxy the enforcement mode will not enforce
dhoehna 8e1cf62
[LXC] Carry the reply path for allowed outbound connections
dhoehna 739d23f
[LXC] Run the E2E suite under the forward policy production has
dhoehna d983827
Revert "[LXC] Run the E2E suite under the forward policy production has"
dhoehna d93bf12
[LXC] Enforce the no-credential proxy invariant at the runner boundary
dhoehna 221f8d1
[LXC] Redact the proxy URL in the diagnostics that run before the guard
dhoehna 628b0b4
Treat an unreadable sysfs as bridged instead of as directly routed
dhoehna 12eb8e9
Do not read a dangling interface symlink as directly routed
dhoehna 97ff4b1
Pin the metadata asymmetry with dangling-symlink tests
dhoehna b657fc7
Find proxy credentials in an opaque URL, not just an authority one
dhoehna dad5aeb
Stop rewriting /etc/hosts when the read that feeds it failed
dhoehna 9595b58
Cover the credentialed-proxy rejection from outside the process
dhoehna bfcbc82
Close three holes an independent review found in the credential guard
dhoehna 7a9948b
Stop reading a port separator as a URL scheme
dhoehna b67f7ec
Refuse to rewrite /etc/hosts through a symbolic link
dhoehna d26d826
Stop the credential test from publishing the secret it guards
dhoehna 6392ea0
[LXC] Stop leading whitespace from hiding a proxy credential
dhoehna 5c057c6
[LXC] Stop the drift guard from printing the credentials it guards
dhoehna aef66b5
[LXC] Stop a backslash from hiding a proxy credential, and pin the gu…
dhoehna 6309789
[LXC] Correct the return-path rule's safety claim with a measurement
dhoehna a4613e3
Judge a proxy URL by what can leak, not only by what a parser sees
dhoehna File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| 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 | ||
|
|
||
| # 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 <veth>` / `--physdev-in <veth>`), 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 ---" | ||
| 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 "--- 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" | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed, and I want to correct something I wrote in that comment rather than defend it.
The mechanism is as you describe. Both hooks are ingress-only --
-i <veth>(network_iptables.rs:410) and-m physdev --physdev-in <veth>(:436). A reply arrives with the external interface as input and the container's port as output, so it matches neither, falls through to the policy, and under Docker'sDROPis dropped.I checked the one thing that could have made this a test-harness artifact rather than a product defect: Ubuntu's
lxc-netnormally installs its own-i lxcbr0 -j ACCEPT/-o lxcbr0 -j ACCEPTinto FORWARD, which would carry the reply path regardless of the policy. It does not happen here. The full FORWARD chain on the runner (LXC E2E run 31433788808) is:No
lxcbr0rules at any point in the run. So nothing but the policy itself is carrying replies, and on a production host withFORWARD DROPthere is nothing at all --allowedHostsand an off-host proxy are unusable, exactly as you say.Where I was wrong. The comment at
lxc-e2e.yml:72-73dismisses the conntrack fix -- "would fix the reply path but leave the DROP policy, and with it the vacuous pass." That treats vacuity as an argument against the rule, when it is really a separate problem with its own solution, and you named it: assert the MXC hook actually matched. The hook's packet counter distinguishes "the deny was enforced by MXC" from "the deny was enforced by the policy," which is the control the ACCEPT policy was standing in for. With that check the conntrack rule and a non-vacuous deny case are not in tension, and my reason for rejecting it does not hold.One design point I want recorded, because the obvious implementation is wrong. The MXC chain already contains
-m state --state ESTABLISHED,RELATED -j ACCEPTas a base rule (:707), so hooking the return direction into the same chain looks like a one-line fix. It should not be done that way. The chain's remaining rules are destination-shaped for egress, so inbound packets that are not established would be evaluated against them -- and underdefaultPolicy: allowthe chain closes with ACCEPT. That would quietly create an inbound enforcement surface with the wrong semantics, which is the surface tracked separately by AB#62864412 and discussed in the FORWARD-vs-INPUT thread on this PR. The return path needs to be a standalone conntrack-scoped ACCEPT matching the container's port as output, mirroring the existing ingress pair (-o <veth>and--physdev-out <veth>), so it restores replies for flows the chain already permitted outbound and cannot widen policy: a flow only reaches ESTABLISHED if the egress direction was accepted.Status: not fixed in this push, and I would rather say so than half-do it. The rule itself is small, but it needs four new ownership flags threaded through install, teardown, and the partial-failure rollback in both families -- that is the most delicate machinery in this PR, and the E2E half (drop the forced
ACCEPT, re-run the deny cases underDROP, add the counter assertion) is the larger part. This is the next thing I pick up on this branch.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 8e1cf62.
Two return-path rules per family, scoped to the container's own port and
confined by conntrack state:
Both attachment forms, because the reply direction has the same
routed-versus-bridged split the ingress hooks do (
network_iptables.rs,build_forward_return_iface_rule_argsandbuild_forward_return_physdev_rule_args).They deliberately do not jump into the MXC chain, even though the chain already
opens with an
ESTABLISHED,RELATEDaccept atbuild_base_chain_rule_argsandthat would have been a one-liner. The chain's other rules are
-d <destination>egress shapes, so inbound NEW packets would be tested against them and, under
defaultPolicy: allow, fall through to the chain's closing ACCEPT. That is aninbound enforcement surface acquired by accident and with the wrong semantics --
the surface AB#62864412 covers.
Install failure warns rather than fails the apply. The ingress hook is what
confines traffic to the chain, so losing it fails open and stays fatal; a rule
that only ever ACCEPTs can at worst leave the container less connected.
Ownership is claimed before insertion, matching the fix in aafb0de: a fatal
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 reassign.
Thirteen tests, mutation-tested in both directions. Worth flagging that the
first pass of them was defective: the rule builders are family-agnostic, so an
IPv4 rule and an IPv6 rule differ only by which binary issued them, and my
assertions ignored the binary -- two mutations survived because whichever family
still worked satisfied the assertion. They now pin the tool.
The harness half of your comment is not done and I have said so in the PR
description rather than closing it here:
-P FORWARD ACCEPTstill needs to comeout of the workflow, with the hook packet-counter assertion you named, so the
deny cases cannot pass vacuously. Also unmeasured: whether
--physdev-outactually matches bridged return traffic on a live container. I measured
--physdev-inat 11 packets against 0 for the interface form; I have notmeasured the reverse.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correcting my previous reply on this thread. I said the return path was fixed
in 8e1cf62. I then ran the experiment and it is not, on the topology that
matters.
I pushed 739d23f to drop
-P FORWARD ACCEPTand run this suite under thepolicy production actually has. The enforcement allow case failed:
MXC_NET_BLOCKEDfor a destination the policy explicitly allows, with bothreturn rules installed and no warning logged. So they install and still do not
carry the reply.
iptables-extensions(8)explains it.--physdev-outis "Name of a bridge portvia 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 beenselected when FORWARD runs, so the physdev form cannot match. And
-o <veth>cannot match either, because the routing output device is
lxcbr0, not theveth. The asymmetry with the ingress side is real:
--physdev-inmatchesbecause the packet demonstrably arrived on the veth, which is why the ingress
hooks measured 11 packets against 0.
I have reverted the workflow change (d983827) rather than leave this suite red
for a reason I already understand. The rules from 8e1cf62 stay: they are
correct for a veth the host routes directly, and inert on a bridge. I have
rewritten the PR description to say exactly that -- it no longer claims this is
fixed.
The scoping that can work is the container's address rather than its port,
since the reply is identified by where it is going, not by a bridge port that
does not exist yet at FORWARD time. The address is already discovered in
lxc_runner.rs::wait_for_network, which today logs it and discards the value.Plumbing it through to the manager, plus a live bridged measurement, is the
next change, and I would rather do it in its own PR than add a third untested
rule to this one.
One more thing worth recording, since it bears on your original point about
vacuity: I audited the scripts, and the suite is not vacuous under either
policy. Every script that asserts a block also asserts a reachability in the
same run -- the allow cases in
run_lxc_network_enforcement_test.shandrun_lxc_network_deny_precedence_test.sh, and proxy reachability inrun_lxc_network_proxy_test.sh. That pairing is what caught this. Theremaining network scripts assert programmed rule shapes and log lines, which do
not depend on the forward policy at all.