Repository navigation
linux: with no seccomp helper, have bubblewrap disable user namespaces - #617
Merged
ronleizrowice-ant merged 8 commits intoOct 13, 2026
Conversation
A wrap with no seccomp helper in its chain (`allowAllUnixSockets`, or no helper for the architecture) put no limit on user namespaces, so a sandboxed command could create one and undo the write denies there. Such a wrap now passes `--disable-userns` to bubblewrap, unless `allowNestedUserNamespaces` or `enableWeakerNestedSandbox` is on. The option needs bubblewrap 0.8.0 or later and a binary that is not setuid: the wrap reads `bwrap --help` and the file mode once per path, and does not remember a probe that failed. Where the option cannot be used, one warning per process says so. It is never passed beside the helper, which makes a namespace of its own.
…rns-without-helper
This was referenced Sep 28, 2026
Closed
antdres
reviewed
Oct 9, 2026
antdres
left a comment
Collaborator
There was a problem hiding this comment.
No blockers from me on the diff over #588. With allowAllUnixSockets: true (no seccomp helper in the chain) the write-deny escape still works on #588 and is refused here: unshare -Ur true fails with ENOSPC and the protected .git/config is untouched, as a non-root user and as root. With a setuid bwrap, or one whose --help has no --disable-userns, the wrap still runs and the flag is left out, so those hosts stay as exposed as before; the only signal is a debug-level log line. The new test fails against #588's source.
Generated by Claude Code
antdres
approved these changes
Oct 9, 2026
antdres
left a comment
Collaborator
There was a problem hiding this comment.
Approving at 9711be4. No blocking issues; what I ran is in my comment above.
Generated by Claude Code
…ces' into fix/linux-disable-userns-without-helper
As on the helper's path. A truthy value of another type, given for a single wrap, left --disable-userns out.
…ces' into fix/linux-disable-userns-without-helper
ronleizrowice-ant
merged commit Oct 13, 2026
75b39fe
into
fix/linux-no-nested-user-namespaces
16 checks passed
dylan-conway
pushed a commit
that referenced
this pull request
Oct 14, 2026
* seccomp helper: keep the command from making namespaces of its own Every write deny on Linux is a read-only bind mount, and a bind protects a path only in the mount namespace it was made in. Creating a user namespace needs no capability and gives its creator a full capability set over a private copy of the mount tree, so a command that may create one can take the binds out of its own view. The helper now stops that in two independent ways. A second seccomp filter, stacked on the Unix-socket one, refuses the user-namespace flag to unshare and clone, answers clone3 with ENOSYS so callers fall back to clone, and refuses setns and the calls that change a mount tree. And the helper sets user.max_user_namespaces to zero in the user namespace it made for itself. SRT_ALLOW_NESTED_USERNS=1 in the helper's environment turns both off. SRT_HELPER_FEATURES=1 makes it print what it supports and exit, so a caller can tell this helper from one built before the limit existed. * linux: a sandboxed command may not create user namespaces unless the config allows it Adds allowNestedUserNamespaces (default false). Every wrap clears the helper's two variables from whatever environment the caller has and sets the opt-out only from the configuration, so an environment block cannot switch the limit off. Where no seccomp helper is in the chain (allowAllUnixSockets, or no helper for the architecture) bubblewrap is told to disable user namespaces, which needs bubblewrap 0.8.0 or later that is not installed setuid. checkDependencies() now also returns what it found as data: features.usernsLimit (true, false, or 'unknown') and details[] with a stable code for the two new warnings, helper_lacks_userns_limit and bwrap_lacks_disable_userns. It asks the helper once per process. What stops working under the sandbox by default is whatever sandboxes itself with user namespaces: a browser's namespace sandbox, rootless podman or buildah, a nested bubblewrap, unshare -r. The README says so, and says that an embedder which compiles the helper into its own binary has to rebuild it. * test: the command stays in the namespaces made for it A live arm opens a directory, makes a user and mount namespace, pivots away from the sandbox's mounts and tries to replace a denied file through the directory it kept; it must be refused and the file on the host unchanged. The same script with allowNestedUserNamespaces on does replace the file, which is what shows the refusing arms can fail. Further arms: both means in force and clone3 answering ENOSYS, threads and child processes still starting, the path with no helper, what the wrap emits, and the helper's answer reaching checkDependencies as data. The dependency tests now let the helper be asked what it supports, and still pin that a non-root caller is never asked about capabilities. One PID namespace test runs the helper by hand inside a wrap that has none, so it says it needs namespaces. * linux: the dependency check and the wrap decide the namespace limit in one place checkDependencies() could say the limit was in force where the wrap would not impose it: with no seccomp helper and enableWeakerNestedSandbox on, bubblewrap is not given the option to disable user namespaces, but the check reported it as if it were. Both now take the decision from planUsernsLimit(), and that case is reported as not limited, under its own warning code, no_userns_limit_in_weaker_nested_sandbox. A configuration that allows namespaces is reported as not limited too, without a warning. The container suite runs as uid 0, where the helper's filter is the barrier that counts. With the helper an unmount is now refused EPERM by the filter before the kernel looks at the mount, so that expectation changes, and a new case allows namespaces to show the older barrier, the locked mounts and their EINVAL, still standing on its own. Another asks for a user namespace outright and must be refused. * seccomp helper: non-dumpable before its first fork, and the outer half filtered too Where the helper cannot mount a fresh /proc (a masked /proc underneath, the enableWeakerNestedSandbox case) the command still sees the helper's outer half. That process shares the command's user namespace, was under no filter and, except for an execute-only binary, was dumpable. A command started by uid 0 holds a full capability set in that namespace, so it could open /proc/<pid>/mem of the outer half, have it run other code with no seccomp filter at all, and from there change the mount tree after all. The helper now clears the dumpable flag before its first fork, whatever it was on entry, so both halves refuse ptrace and every /proc file that asks the same question; the save and restore around the uid and gid map writes goes. The outer half also sets no_new_privs and takes the namespaces filter, which refuses nothing it calls. A non-root caller's command was never able to do this: it holds no capabilities. The filter also refuses open_tree_attr where the libseccomp it is built with can name that call. The container suite gains a case that, as uid 0 with the masked /proc that job has, tries to open the memory and the environment of every helper process it can see. The execute-only test that required a readable copy to stay dumpable now requires the opposite. * linux: ask the helper safely, pin its variables on its own command line, and test the filter itself Asking a helper what it supports ran, for one built before the question existed, whatever its argument named, on the host and outside any sandbox, and the argument was a bare name, so PATH decided. It is now an absolute path in the root of procfs, where nothing can be created. A helper that is part of the caller's own binary is no longer run from the host at all: its path only has to mean something inside the sandbox. The answer for it is 'unknown', and the caller that built it in can ask its own binary. The wrap reaches the helper through a shell, and a shell reads start-up files the caller's environment can name, which could put the opt-out variable back after bubblewrap had cleared it. The helper's command line now starts with an assignment of both of its variables from the configuration, which holds whatever ran before it. The tests could pass with a namespaces filter that held only its clone3 rule. They now make calls that only the filter answers EPERM (a raw clone and an unshare asking for a user namespace, setns, open_tree, mount and umount2 with null arguments, mount_setattr), with a control showing the kernel's own answers where nothing refuses them, and the with-helper arm requires EPERM rather than accepting the limit's ENOSPC in its place. Also: the summary line says when it could not find out whether the helper has the limit; the README says what a root caller's command loses, that the option is one setting for the whole configuration, which callers of clone3 have no fallback, and no longer says that locked mount copies are what hold a root caller's command. * seccomp helper: build with a libseccomp that cannot name the two newest calls The namespaces filter resolved every call by name and failed the build on one the library did not know, and mount_setattr is only named from libseccomp 2.5.2, which is newer than what some long-lived build images carry. For that call and for open_tree_attr the generator now falls back to the number, which is the same on every architecture for calls this recent. That works for the builder's own architecture only: libseccomp carries a call from one architecture to another by name and refuses a bare number for another one. So for the other architecture the call is left out, which costs nothing, since a build compiles in its own architecture's filter and no other. An unknown name among the older calls still fails the build. The live test asks for open_tree_attr by number and must be refused, which on a builder whose libseccomp cannot name it is this path. * seccomp helper: never emit a filter that quietly lacks a call The generator put the two newest calls into the namespaces filter by number where libseccomp could not name them, which works only for the builder's own architecture, and for another architecture left them out. A filter that is weaker on one architecture with nothing said is the one outcome worth a failed build, so emitting for another architecture with a library that cannot name a call is now an error that names the call, the architecture and the libseccomp version. This package's own build no longer emits for the other architecture at all: the helper it compiles takes the arrays of its own architecture and no other, so the second set was never used. A build that does emit for another architecture needs a libseccomp that names every call; 2.6.1 does. * seccomp generator: fail on a call it cannot name, and test the generator Every lookup treated only "unknown name" as failure. A name libseccomp knows but reports absent on the architecture comes back as a negative pseudo-number, which seccomp_rule_add() accepts, returning 0 and emitting nothing: the one way left to a filter that quietly lacks a call. Any negative number now takes the same path as an unknown name (by number where that is possible, a refusal otherwise), and the rules are added with seccomp_rule_add_exact(), which errors where the other drops. The filters emitted for x86_64 and aarch64 are byte for byte what they were, and so is the helper. The repository had no test that runs the generator. One now compiles it where a compiler and libseccomp are present, decodes the namespaces filter for the builder's own architecture and looks for all fifteen call numbers in it, and checks that a request for the other architecture it cannot complete exits non-zero naming the call. Comments in both C files that later commits of this branch had made untrue are corrected; apply-seccomp.c changes in comments only. * linux: ask the seccomp helper what it supports inside bubblewrap To learn whether a helper file can limit user namespaces the library ran it, with spawnSync, in its own process context: from the dependency check and from every wrap. Until this branch that file was only ever named inside the wrapped command line, so it only ever ran inside bubblewrap. Where the sandbox policy lets a wrapped command write the helper (an operator-named seccomp.applyPath inside a write root, the home-directory fallbacks), replacing it meant code running unsandboxed in the next process that checked dependencies or wrapped a command. The question is now put inside bubblewrap: user, mount, pid, network, ipc and uts namespaces of its own, the root bound read-only, no capabilities, a new session, PATH as the only environment. The user namespace is required, as it is for every wrap, so where none can be made the helper is not run at all. "Could not be asked" (no bubblewrap, bubblewrap failing under its own name, a silent failure, a time-out) is 'unknown' and never a reason to run the helper directly; only an answer is remembered, except a time-out, which is not paid for twice. Also: seccomp.applyPath must be absolute unless argv0 is set; an empty argv0 counts as not set in the one place both the check and the wrap use; bubblewrap is looked up only on the path that needs the answer (one lookup per wrap under Node where there were two); an empty bwrapPath no longer makes the wrap throw. The README names every call the filter refuses, says what holds without the seccomp layer, and describes what a refusal looks like. * test: pin what nothing pinned in the limit on user namespaces Each of these failed to fail when the code it is about was taken out. - Six of the filter's rules (pivot_root, move_mount, fsopen, fsconfig, fsmount, fspick): probed as uid 0 in the container suite, EPERM each by default and the kernel's own answers where namespaces are allowed; fsconfig also on the host leg, where the kernel's answer differs. - The route that needs no unmount and, for uid 0, no namespace: private mount tree, tmpfs, pivot_root, lazy detach, then the denied file moved aside and written afresh. Refused step by step by default; going through, and replacing the file on the host, where namespaces are allowed, which is what the option gives up and shows the probe can tell. The case whose title promised more than it checked is renamed. - The outer half of the helper takes the namespaces filter, and leaves it out under the opt-out: read from /proc/<pid>/status. - The no-helper cases are gated on bubblewrap's own answer, not on the function under test; bwrapCanDisableUserns and its warning code are tested with stand-in bubblewraps, one of them setuid. - Opting out keeps the Unix-socket filter, on both legs. - SandboxManager.checkDependencies() passes on features and details and forwards the three options; the wrap carries the assignment. - The probe never runs a helper file outside bubblewrap: a stand-in reports its namespaces, session and environment, from the dependency check and from a wrap; a failing bubblewrap is 'unknown' and asked again; a time-out is not; none of it as uid 0 needs a fresh /proc. - The probe caches are reset between test files that mock them. * Shorten the comments this change adds Comments only: the JavaScript emitted with comments removed is byte-identical for every file, and the test names are unchanged. Kept: every invariant and what could be done without it, the documentation of exported names, and the reasons for what is not done the obvious way. Cut: how the code came to be as it is, narration of the lines that follow, and explanations repeated at several sites. * Cut this change down to its core Stays: the namespaces seccomp filter, the generator failing on a call it cannot name, the helper installing the filter and setting user.max_user_namespaces to zero, the helper non-dumpable before its first fork with its outer half filtered too, and the allowNestedUserNamespaces option, taken from the configuration alone. Leaves, to come back as changes of their own: asking the helper what it supports and reporting it through checkDependencies(), the --disable-userns path for a wrap with no helper, and the rule that seccomp.applyPath be absolute. With no helper in the chain, or a compiled-in helper older than this, nothing limits namespaces: the README says so. The emitted filters are byte-identical to before. * test: keep the cases that pin the core of the namespace limit * linux: nor a mount namespace of its own A command started by uid 0 holds the capability to make a mount namespace with no new user namespace. From Linux 7.1 unshare(2) can make one that holds the root mount and none of those beneath it. unshare is refused with CLONE_NEWNS and with that flag as well, clone with CLONE_NEWNS. The rule table takes a set of flags for a call, one rule a flag. To clone the flag's bit is CLONE_PARENT_SETTID, which threads are made with, so it is left alone there. * allowNestedUserNamespaces can be given for a single wrap Read from the configuration passed to wrapWithSandbox before the session's, as allowPty is. One command that has to sandbox itself can then be let, and keeps every other limit, while the commands beside it keep this one too. * README: the filter refuses a mount namespace too * linux: with no seccomp helper, have bubblewrap disable user namespaces (#617) Squash merge of 8 commits
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Stacked on #588 (base: its branch). #588 keeps a sandboxed command in the namespaces made for it through the seccomp helper. A wrap with no helper in its chain (
allowAllUnixSockets: true, or no helper for the architecture) gets no limit there. So an option that is about sockets also decides whether the limit applies.Four files, +207.
What changes
--disable-userns, unlessallowNestedUserNamespacesorenableWeakerNestedSandboxis on. (Bubblewrap does it by writing a sysctl, and dies on the read-only/proc/sysof an unprivileged container.)bwrap --helpand the file mode, once per binary path. A failed probe is not remembered.Who sees a difference
With no helper, by default:
unshare -runshare: unshare failed: No space left on devicebwrapbwrap: Creating new namespace failed: nesting depth or /proc/sys/user/max_*_namespaces exceeded (ENOSPC)With the helper they fail with
EPERM, as in #588.allowNestedUserNamespaceslifts both, for the session or for a single wrap.Testing
allowAllUnixSockets: true, against linux: a sandboxed command stays in the namespaces made for it #588 alone: the table above; a write-denied file stays as it was; threads, worker threads, forks and pipelines work.--helplacks the option: the wrap runs, the flag is left out, one warning.Merge order
After #588. #594 merges with no conflict in the source, but the two must be joined by hand: this PR's probe looks
bwrapup onPATHand runs--helpon the host, which #594 forbids for a copy the sandboxed command can write. Whichever lands second makes the probe ask the binary the wrap resolved.Not here
Asking the helper what it supports, anything on
checkDependencies(), new exported types.