Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
junction is compared by where the junction leads, and a path nvx cannot
resolve counts as outside the home.

* **On Linux a contained install can unpack a prebuilt binary that another
user owns in its archive.** The contained process ran as root in a user
namespace that holds only your own user id. A tool that unpacks an archive
as root gives each file the owner the archive records, and an owner the
namespace does not hold failed with `EINVAL`. Measured 2026-10-07 in a
Debian 13 container, a contained `npm install sqlite3@6.0.1` stopped at
`prebuild-install warn install EINVAL: invalid argument, lchown`, because the
archive of its prebuilt binary is owned by uid 1001, and its fallback build
from source failed too. The contained process now runs as you, with your own
user and group ids, and starts with no capabilities. The same install exits
0, and a contained `node` loads the module. A contained server can still
listen on a port below 1024 in its own network namespace. If you run nvx as
root, a contained process still runs as root, so the archive still fails to
unpack.

* **nvx says when a browser that a contained install downloaded is deleted
with the sandbox.** puppeteer's postinstall and `playwright install` keep
their browsers under the home directory, and a contained command's home is
the sandbox's own, which nvx deletes when the command ends. The install
exited 0 and nothing said the browser was gone. Measured 2026-10-07 in a
Debian 13 container with `storage.googleapis.com` allowed, the puppeteer
cache in the sandbox's home reached 856 MB during a contained `npm install
puppeteer`, and no browser was left afterwards. nvx now warns when this
happens and prints the command that installs the browser where the tool
looks for it, such as `nvx --no-sandbox npx puppeteer browsers install
chrome`. It also names `PUPPETEER_CACHE_DIR` and `PLAYWRIGHT_BROWSERS_PATH`
when it removes them from a contained command's environment.

### Security

* **A contained process can no longer type into your terminal.** nvx gives the
Expand Down
7 changes: 4 additions & 3 deletions docs/enforcement-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -988,9 +988,10 @@ provider mounts the project as it is.
file with mode 0000 over each one in its private mount namespace, before
Landlock applies. A read or a write fails with `EACCES`, a `chmod` with
`EROFS`, a rename with `EBUSY` and a hard link with `EXDEV`. The target runs
as root in its user namespace, so the supervisor drops `CAP_DAC_OVERRIDE` and
`CAP_DAC_READ_SEARCH` from it, which would read past the mode, and
`CAP_SYS_ADMIN`, which would unmount the mask. The target could otherwise make
as you in its user namespace and starts with no capabilities, so it holds
neither `CAP_DAC_OVERRIDE` nor `CAP_DAC_READ_SEARCH`, which would read past the
mode, nor `CAP_SYS_ADMIN`, which would unmount the mask. The supervisor also
drops all three from its bounding set. The target could otherwise make
a user namespace of its own, where it holds `CAP_SYS_ADMIN` again and can build
a mount namespace the run-time masks never reach. The supervisor stops that by
writing 0 to `/proc/sys/user/max_user_namespaces` before Landlock keeps `/proc`
Expand Down
111 changes: 111 additions & 0 deletions internal/nvx/sandbox_browser_downloads.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package nvx

import (
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
)

// A browser a contained install downloads goes with the guest home.
//
// puppeteer's postinstall and `playwright install` keep their browsers in a
// cache under the home directory, and inside the sandbox that is the guest
// home, which nvx deletes when the command ends. The install still exits 0, so
// the first sign was puppeteer or Playwright failing to find the browser later.
// Measured 2026-10-07 in a Debian 13 container with storage.googleapis.com
// allowed, the cache in the guest home reached 856 MB during a contained `npm
// install puppeteer`. npm exited 0, nvx said nothing, and no browser was left
// afterwards.

// browserCache is a folder a browser installer writes under the home, and the
// command that installs the browser where the tool looks for it.
type browserCache struct {
tool string
// dir is relative to the home and slash-separated. One element may be *,
// for every folder at that level.
dir string
install string
}

// browserCaches are the folders for goos. puppeteer uses ~/.cache/puppeteer
// everywhere. Playwright uses the platform's cache folder, which the sandbox
// points into the guest home through XDG_CACHE_HOME on Linux. Windows points an
// AppContainer's LOCALAPPDATA at the package's own folder. Measured 2026-10-07,
// a contained node saw it as AppData\Local\Packages\nvx.sandbox.<id>\AC under
// the guest home.
func browserCaches(goos string) []browserCache {
playwright := ".cache/ms-playwright"
switch goos {
case "windows":
playwright = "AppData/Local/Packages/*/AC/ms-playwright"
case "darwin":
playwright = "Library/Caches/ms-playwright"
}
return []browserCache{
{tool: "puppeteer", dir: ".cache/puppeteer", install: "npx puppeteer browsers install chrome"},
{tool: "Playwright", dir: playwright, install: "npx playwright install"},
}
}

// dirsUnder returns the folders under home that c.dir names. Not
// filepath.Glob, which would read a * or [ in home itself as a pattern.
func (c browserCache) dirsUnder(home string) []string {
parent, rest, wild := strings.Cut(c.dir, "/*/")
if !wild {
return []string{filepath.Join(home, filepath.FromSlash(c.dir))}
}
parentDir := filepath.Join(home, filepath.FromSlash(parent))
entries, _ := os.ReadDir(parentDir)
var dirs []string
for _, e := range entries {
dirs = append(dirs, filepath.Join(parentDir, e.Name(), filepath.FromSlash(rest)))
}
return dirs
}

// browserDownloadFloor is how much a cache folder must hold to count as a
// download. A blocked download leaves folders and small records behind, such as
// Playwright's note of the project in .links. A browser is hundreds of MB.
const browserDownloadFloor = 1 << 20

// warnLostBrowserDownloads says when a browser landed in a guest home that nvx
// is about to delete, and how to install it where the tool looks for it.
func warnLostBrowserDownloads(guestHome string) {
for _, c := range browserCaches(runtime.GOOS) {
for _, dir := range c.dirsUnder(guestHome) {
if !holdsAtLeast(dir, browserDownloadFloor) {
continue
}
LogWarn("%s downloaded a browser into the sandbox's home, which nvx deletes when the command ends.", c.tool)
LogInfo("To install it where %s looks for it, run: %s --no-sandbox %s", c.tool, installedNvxHint(), c.install)
break
}
}
}

// errEnough stops the walk in holdsAtLeast.
var errEnough = errors.New("enough")

// holdsAtLeast reports whether the regular files under dir add up to at least
// n bytes. It stops as soon as they do, and does not follow links.
func holdsAtLeast(dir string, n int64) bool {
var total int64
err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
if err != nil || !d.Type().IsRegular() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
total += info.Size()
if total >= n {
return errEnough
}
return nil
})
return errors.Is(err, errEnough)
}
63 changes: 63 additions & 0 deletions internal/nvx/sandbox_browser_downloads_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package nvx

import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

// A browser in a guest home that nvx is about to delete is named, with the
// command that installs it where the tool looks for it. A blocked download
// leaves only small records behind, and those say nothing.
func TestBrowserDownloadInTheGuestHomeIsNamed(t *testing.T) {
var playwright string
for _, c := range browserCaches(runtime.GOOS) {
if c.tool == "Playwright" {
// On Windows the folder is under the AppContainer package's own.
playwright = filepath.FromSlash(strings.Replace(c.dir, "*", "nvx.sandbox.0123456789abcdef", 1))
}
}
writeSized := func(path string, size int64) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Truncate(path, size); err != nil {
t.Fatal(err)
}
}

// A puppeteer download, and what Playwright leaves after a blocked one.
home := tempDir(t)
writeSized(filepath.Join(home, ".cache", "puppeteer", "chrome", "linux-154.0.8037.57", "chrome"), 2<<20)
writeSized(filepath.Join(home, playwright, ".links", "0123abcd"), 64)
out := captureStderrHere(t, func() { warnLostBrowserDownloads(home) })
for _, want := range []string{
"puppeteer downloaded a browser into the sandbox's home, which nvx deletes when the command ends.",
"--no-sandbox npx puppeteer browsers install chrome",
} {
if !strings.Contains(out, want) {
t.Errorf("the warning does not say %q:\n%s", want, out)
}
}
if strings.Contains(out, "Playwright") {
t.Errorf("a blocked Playwright download was reported as a browser:\n%s", out)
}

// A Playwright download, where this platform's sandbox puts it.
home = tempDir(t)
writeSized(filepath.Join(home, playwright, "chromium-1243", "chrome"), 2<<20)
out = captureStderrHere(t, func() { warnLostBrowserDownloads(home) })
if !strings.Contains(out, "Playwright downloaded a browser") || !strings.Contains(out, "--no-sandbox npx playwright install") {
t.Errorf("a Playwright download was not named:\n%s", out)
}

if out := captureStderrHere(t, func() { warnLostBrowserDownloads(tempDir(t)) }); out != "" {
t.Errorf("a guest home with no browser in it printed:\n%s", out)
}
}
14 changes: 8 additions & 6 deletions internal/nvx/sandbox_dotenv_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import (
// EXDEV, so the real file cannot be moved to a name nothing covers.
//
// Mode 0000 holds only against a process without CAP_DAC_OVERRIDE and
// CAP_DAC_READ_SEARCH. The target runs as root in the supervisor's user
// namespace, where it holds both over every file the user owns, the mask
// included. So they leave the bounding set with CAP_SYS_ADMIN, which would let
// the target unmount the mask. Without them the target reads and writes the
// user's files as the user does outside the sandbox. They go even when the
// launch finds no dotenv file, because watchDotenvFiles may cover one later.
// CAP_DAC_READ_SEARCH. Root in the supervisor's user namespace holds both over
// every file the user owns, the mask included, and the target ran as that root
// until 2026-10-07. So they leave the bounding set with CAP_SYS_ADMIN, which
// would let the target unmount the mask. The target runs as the user now and
// dropTargetCapabilities empties the bounding set, so these drops are a second
// layer. Without the capabilities the target reads and writes the user's files
// as the user does outside the sandbox. They go even when the launch finds no
// dotenv file, because watchDotenvFiles may cover one later.
//
// This covers the files present at launch. watchDotenvFiles covers those
// created or replaced during the run. The walk stops after dotenvScanLimit
Expand Down
4 changes: 4 additions & 0 deletions internal/nvx/sandbox_env_scrub.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ var notableEnvKeys = map[string]bool{
"DEBUG": true,
"FORCE_COLOR": true,
"NO_COLOR": true,
// Where puppeteer and Playwright put a browser. Without them it goes into
// the guest home and is deleted with it (see warnLostBrowserDownloads).
"PUPPETEER_CACHE_DIR": true,
"PLAYWRIGHT_BROWSERS_PATH": true,
}

// notableEnvPrefixes catch families rather than single names.
Expand Down
7 changes: 5 additions & 2 deletions internal/nvx/sandbox_env_scrub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,13 @@ func TestACredentialNamedBySuffixIsRefused(t *testing.T) {
// furniture (COMPUTERNAME, PROCESSOR_LEVEL). Naming them all on every run would
// be noise nobody reads, which is its own way of saying nothing.
func TestOnlyBehaviourChangingVariablesAreWorthPrinting(t *testing.T) {
dropped := []string{"COMPUTERNAME", "PROCESSOR_LEVEL", "CI", "ONEDRIVE", "NODE_ENV", "npm_config_yes"}
dropped := []string{"COMPUTERNAME", "PROCESSOR_LEVEL", "CI", "ONEDRIVE", "NODE_ENV", "npm_config_yes",
"PUPPETEER_CACHE_DIR", "PLAYWRIGHT_BROWSERS_PATH"}
notable := notableDropped(dropped)

for _, want := range []string{"CI", "NODE_ENV"} {
// Without the last two a browser download lands in the guest home and is
// deleted with it.
for _, want := range []string{"CI", "NODE_ENV", "PUPPETEER_CACHE_DIR", "PLAYWRIGHT_BROWSERS_PATH"} {
if !containsString(notable, want) {
t.Errorf("%q changes how tools behave and would not be reported: %v", want, notable)
}
Expand Down
11 changes: 6 additions & 5 deletions internal/nvx/sandbox_git_readonly_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ const (
// dropSysAdminFromBoundingSet removes CAP_SYS_ADMIN from what the target can
// hold after exec.
//
// The supervisor is root in its own user namespace, and the target runs as that
// root, so without this the target holds every capability there, CAP_SYS_ADMIN
// over the mount namespace that carries the read-only .git mount included.
// Landlock refuses mount, umount and remount to a restricted process. This does
// not rely on that covering every interface that can change a mount's
// The target ran as root in the supervisor's user namespace until 2026-10-07,
// and held every capability there, CAP_SYS_ADMIN over the mount namespace that
// carries the read-only .git mount included. It runs as the user now and
// dropTargetCapabilities empties the bounding set, so this drop is a second
// layer. Landlock refuses mount, umount and remount to a restricted process.
// This does not rely on that covering every interface that can change a mount's
// attributes: without CAP_SYS_ADMIN the target cannot change any of them.
//
// The bounding set limits what exec grants and leaves this thread's own
Expand Down
10 changes: 10 additions & 0 deletions internal/nvx/sandbox_landlock_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,9 @@ func runLandlockExecChild(a supervisorExecArgs) int {
return 1
}
LogInfo("Linux loopback-only network namespace active")
if err := allowLowPortsInNamespace(); err != nil {
LogWarn("A contained server cannot listen on a port below 1024 in this run (%v).", err)
}
}

// The egress proxy runs in the parent, outside this namespace, because a
Expand Down Expand Up @@ -584,6 +587,13 @@ func runLandlockExecChild(a supervisorExecArgs) int {
LogError("Could not stop the sandbox typing into the terminal (fail-closed): %v", err)
return 1
}
// Last, because `ip` and `iptables` above got their capabilities from this
// thread. Fail closed, or the target starts with this thread's ambient
// capabilities.
if err := dropTargetCapabilities(); err != nil {
LogError("Could not take the sandbox's capabilities away from the command (fail-closed): %v", err)
return 1
}

cmd := exec.Command(cmdPath, args...)
cmd.Stdin = os.Stdin
Expand Down
2 changes: 2 additions & 0 deletions internal/nvx/sandbox_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ func runNativeSandbox(config SandboxConfig, policy Policy, egress *EgressProxy,
LogInfo("The sandbox's debug logs were copied out before its home was removed: %s", dest)
}
}()
// A browser an installer put in the home goes with it, on success too.
defer warnLostBrowserDownloads(guestHome)
}

// Platforms that cut the sandboxed process off from the parent's loopback
Expand Down
5 changes: 3 additions & 2 deletions internal/nvx/sandbox_native_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,9 @@ func platformLaunchNative(config SandboxConfig, guestHome, workDir, cmdPath stri
// still in the old namespace and one reached the public internet. Supplying the
// flag at clone time puts the whole child process in the new namespace from
// birth, which is deterministic and needs no thread pinning.
// CLONE_NEWUSER, with this user mapped to root inside it, is what makes the
// rest of these flags possible without privileges.
// CLONE_NEWUSER, which grants the capabilities the new namespace needs (see
// supervisorSysProcAttr for how they last past exec), is what makes the rest
// of these flags possible without privileges.
//
// CLONE_NEWPID and CLONE_NEWNET both require CAP_SYS_ADMIN in the current
// user namespace. Without CLONE_NEWUSER an ordinary user has none, so the
Expand Down
15 changes: 15 additions & 0 deletions internal/nvx/sandbox_network_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package nvx

import (
"fmt"
"os"
"os/exec"
"strings"
)
Expand Down Expand Up @@ -34,6 +35,20 @@ func bringUpLoopback() error {
return nil
}

// unprivilegedPortStartPath is the lowest port a process without
// CAP_NET_BIND_SERVICE may bind in the caller's network namespace. It covers
// IPv6 as well, and a new namespace starts at 1024.
const unprivilegedPortStartPath = "/proc/sys/net/ipv4/ip_unprivileged_port_start"

// allowLowPortsInNamespace lets the target listen on any port in its own network
// namespace. As root in its user namespace it could, and running as the user
// it could not listen below 1024. The namespace holds only its own loopback,
// so a low port there reaches nothing a high one does not. Docker sets the same
// value for its containers, 0 as read from one on 2026-10-07.
func allowLowPortsInNamespace() error {
return os.WriteFile(unprivilegedPortStartPath, []byte("0"), 0)
}

// networkModeRequiresNamespace reports whether mode needs a loopback-only
// network namespace.
//
Expand Down
4 changes: 2 additions & 2 deletions internal/nvx/sandbox_proc_private_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ func TestContainedProcessReadsItsOwnProcAndNotTheHosts(t *testing.T) {
// Creating the namespaces and being allowed to USE them are different
// questions, and requireNamespaceSupport above only answers the first.
//
// supervisorSysProcAttr maps this user to root inside a new user namespace, so
// the child should hold CAP_SYS_ADMIN there and unshare(CLONE_NEWNS) should
// supervisorSysProcAttr gives the child every capability in a new user
// namespace, so it should hold CAP_SYS_ADMIN and unshare(CLONE_NEWNS) should
// succeed. Ubuntu 24.04 hardens unprivileged user namespaces through AppArmor
// and strips it: the clone succeeds, the unshare comes back EPERM, and every
// assertion below then reads as a product failure caused by a distribution
Expand Down
Loading
Loading