Skip to content

nixos: block namespace creation in the instance units - #48

Open
euoia wants to merge 2 commits into
mainfrom
harden/restrict-namespaces
Open

euoia wants to merge 2 commits into
mainfrom
harden/restrict-namespaces

Conversation

@euoia

@euoia euoia commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes the kernel local-privesc class that the per-tenant isolation model is exposed to, independently of kernel version.

Why

A site's npm/bun dependency tree executes on the host — the checkout one-shot runs bun install (postinstall scripts) and the preview runs astro dev — all as the per-instance Unix user. Those users are what make the 0400 mode on an instance's deploy key, session secret and password hash actually isolate one instance from another, so a privesc out of one of them defeats the whole model.

Unprivileged user + network namespaces are a standing kernel privesc surface for exactly that code, and a recurring CVE class rather than one bug. A host running 6.18.38 was demonstrably exposed: it was missing the fix for CVE-2026-64581 (xfrm double-free, reachable by an unprivileged user through user and network namespaces, fixed in 6.18.39), and an instance user could both create the namespaces and autoload xfrm inside one:

$ sudo -u astroadmin-<instance> unshare -Urn true
RESULT: YES - unprivileged userns+netns available to instance users
$ sudo -u astroadmin-<instance> unshare -Urn sh -c 'ip xfrm state list'
xfrm-reachable

RestrictNamespaces=true blocks that with seccomp regardless of kernel version, so the class is closed rather than each bug waited out. Keeping the kernel current still matters — this is defence in depth, not a substitute.

What changed

  • RestrictNamespaces = true in the shared hardening set → admin + preview units.
  • RestrictNamespaces = true on the checkout unit directly. It deliberately does not take the shared set (that is scoped to the long-running units and is not validated against a package install), but it runs bun install — the least trusted code on the host — and the namespace restriction applies cleanly.
  • Corrects a false security claim in nixos/README.md.

The README claim is worth a look on its own

It said:

No untrusted code runs on the host: the site code is first-party, and the npm dependency tree builds on Netlify (build-on-push), not here.

That contradicts this module's own header comment, which has always said the dependency tree executes on the host. Netlify builds the public site, which is a different thing. I confirmed the module is the accurate one by running bun install and astro build on a host directly.

A reassurance like that is the kind of sentence that stops someone bothering to harden, so it is a bug in its own right rather than cosmetic rot.

Verification — driven on a live host

Not reasoned about; applied with /run/systemd/system drop-ins, exercised, then removed so the host matched its declared config again.

check result
vector without restriction (positive control) entered user ns 4026532448 vs host 4026531837 — genuinely open
RestrictNamespaces=true unshare: unshare failed: Operation not permitted
RestrictNamespaces=~user net also blocked (either form works; true chosen as the broader one)
bun install (checkout unit) Result=success ExecMainStatus=0
astro dev (preview) HTTP 200, served the expected page
astro build (the Publish path) 9 page(s) built, exit 0
seccomp on running procs Seccomp: 2 (filter mode) on admin and preview
HTTPS vhost + preview auth gate unchanged
working tree after astro build still clean, so it does not trip the ff-only pull guard from #42

An earlier version of the positive control "passed" only because true was not on the unit's minimal PATH — the unshare had actually succeeded. That would have been a false negative in the direction that matters, so the control was rewritten to compare namespace inodes instead of relying on an exit code. Similarly, a first attempt to apply the drop-ins under /etc/systemd/system silently failed (read-only on NixOS) and systemctl show reported RestrictNamespaces=no — caught by checking the property rather than trusting the write.

Deploying

Needs a flake pin bump in the ops repo to reach a host. Worth landing alongside a kernel update so both halves arrive together.

Follow-up, not in this PR

The checkout unit gets no other hardening at all, despite running the least trusted code — no ProtectSystem, ProtectHome, PrivateTmp or the rest. Extending the shared set to it looks feasible (its StateDirectory covers the writes it needs) but wants its own testing round against a real bun install.

A site's npm/bun dependency tree executes on the host — the checkout
one-shot runs bun install (postinstall scripts) and the preview runs
astro dev — all as the per-instance Unix user. Those users are what
make the 0400 mode on an instance's deploy key, session secret and
password hash actually isolate one instance from another, so a
privilege escalation out of one of them defeats the model entirely.

Unprivileged user + network namespaces are a standing kernel
local-privesc surface for exactly that code, and a recurring CVE class
rather than a single bug. A host running 6.18.38 was demonstrably
exposed: it was missing the fix for CVE-2026-64581 (xfrm double-free
reachable by an unprivileged user through user and network namespaces,
fixed in 6.18.39), and an instance user could both create the
namespaces and autoload xfrm inside one.

RestrictNamespaces=true blocks that with seccomp, independently of the
kernel version, so the class is closed rather than each bug being
waited out. Set on the admin and preview units via the shared hardening
set, and on the checkout unit directly - it does not take the shared
set (that is scoped to the long-running units and is not validated
against a package install), but it runs the least trusted code on the
host and the namespace restriction applies cleanly.

Also corrects nixos/README.md, which claimed "No untrusted code runs on
the host: the site code is first-party, and the npm dependency tree
builds on Netlify (build-on-push), not here." That contradicts this
module's own header comment and is false - Netlify builds the public
site, which is a different thing. A reassurance like that is the kind
of sentence that stops someone bothering to harden, so it is a bug in
its own right.

Verified by driving it on a live host rather than reasoning about it:

  - vector open without it, blocked with it, with a positive control
    that enters a genuinely different user namespace (an earlier
    control "passed" only because `true` was not on the unit's PATH,
    which would have been a false negative)
  - bun install, astro dev and astro build (the Publish path) all
    succeed under the restriction; the preview serves normally
  - seccomp filter mode 2 confirmed on the running admin and preview
    processes
  - the working tree stays clean after astro build, so it does not
    trip the ff-only pull guard added in #42
@euoia
euoia force-pushed the harden/restrict-namespaces branch from dc24d26 to 58a4dbe Compare August 27, 2026 08:51
nixos/README.md called preview routing the one open item and said the
localhost dev server "isn't reachable as-is", with the safe exposure
"to be finalized against a live instance". That has not been true for
some time: the module implements previewHost, the nginx TLS vhost and
the auth_request gate, its own header comment documents all three, and
it is live and serving on every hosted instance.

Verified rather than assumed: an unauthenticated request to a preview
host returns the "preview needs an active editor session" page, not
content, on all six live instances.

This is the stale-doc trap the house rules warn about, in its worst
form. A note saying something is a KNOWN LIMITATION reads as an
instruction, so the next contributor builds the preview vhost that
already exists rather than correcting the claim. Ordinary rot reads as
out of date and gets checked; "this is the one open item" does not.

Says what it now does, and says the claim went stale, so the correction
is visible rather than silently swapped in.
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.

1 participant