Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,14 @@ Filesystem restrictions are enforced at the OS level:

**Precedence is intentionally opposite for reads vs writes:** `allowRead` overrides `denyRead`, while `denyWrite` overrides `allowWrite`. This lets you carve out readable regions within denied areas, and carve out protected regions within writable areas.

**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to a file that bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args <file> bwrap … --args 9 …`: still a simple command, which opens the file on fd 9, unlinks it and runs bubblewrap, so it can be run once. The environment and the command stay on the command line; the file holds mount paths only.

- The file sits in a per-process directory, `<os.tmpdir()>/.srt-bwrap-args-*`, that every profile of the process binds read-only over itself, so a command the process sandboxed cannot rewrite a pending profile. It can read one: the mount paths of a command that is wrapped but not yet started are visible to the process's other sandboxes.
- A sandbox with tmpdir writable sees that directory as an entry it cannot delete. `rm -rf "$TMPDIR"/*` skips it (the name starts with a dot); `find "$TMPDIR" -mindepth 1 -delete` does not.
- The directory is created once. If it cannot be created, or is later removed or replaced (an age-based tmp cleaner), profiles that fit are unaffected and an over-long one is refused with an error until the process restarts: a sandbox started earlier would not have a new directory read-only, and the library cannot tell whether it is still running.
- Not covered: a sandbox started by another process of the same user with tmpdir writable never bound this directory, and can swap a pending file so that the command runs with bubblewrap options of its choosing. The same holds when `os.tmpdir()` lies below a directory a sandbox may write (`TMPDIR=/tmp/work` with `/tmp` writable): a sandboxed command can rename that parent and put its own directory at the path. Where profiles can be over-long, keep tmpdir out of `allowWrite`, which closes both, or at least point `TMPDIR` at a directory that is itself an `allowWrite` entry, which closes the second.
- bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error.

### Mandatory Deny Paths (Auto-Protected Files)

Certain sensitive files and directories are **always blocked from writes**, even if they fall within an allowed write path. This provides defense-in-depth against sandbox escapes and configuration tampering.
Expand Down
270 changes: 268 additions & 2 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto'
import * as fs from 'fs'
import { spawn } from 'node:child_process'
import type { ChildProcess } from 'node:child_process'
import { tmpdir } from 'node:os'
import { endianness, tmpdir } from 'node:os'
import path, { join } from 'node:path'
import { ripGrep } from '../utils/ripgrep.js'
import { buildJavaToolOptions } from './java-proxy-agent.js'
Expand Down Expand Up @@ -441,6 +441,246 @@ function capabilityArgs(usesSeccompHelper: boolean): string[] {
return args
}

/**
* Linux's per-argument cap, MAX_ARG_STRLEN: 32 pages, so 128 KiB on most
* kernels and up to 2 MiB with 64 KiB pages. The page size is AT_PAGESZ in
* /proc/self/auxv (pairs of native words); 4 KiB, the smallest, if unreadable.
*/
let linuxMaxArgStrlen: number | undefined
function maxArgStrlen(): number {
if (linuxMaxArgStrlen === undefined) {
const AT_PAGESZ = 6
let pageSize = 4096
try {
const auxv = fs.readFileSync('/proc/self/auxv')
const wordBytes = /64|s390x/.test(process.arch) ? 8 : 4
// Buffer reads at most 6 bytes as a number; no value needed here is wider.
const low = Math.min(wordBytes, 6)
const word = (at: number): number =>
endianness() === 'BE'
? auxv.readUIntBE(at + wordBytes - low, low)
: auxv.readUIntLE(at, low)
for (let at = 0; at + 2 * wordBytes <= auxv.length; at += 2 * wordBytes) {
if (word(at) === AT_PAGESZ) {
pageSize = word(at + wordBytes)
break
}
}
} catch {
// No /proc: the smallest page size only moves a profile to the file
// sooner than it had to.
}
linuxMaxArgStrlen = 32 * pageSize
}
return linuxMaxArgStrlen
}

/**
* Room left below the cap when deciding whether the profile stays on the
* command line: the embedder may put a prefix of its own (`exec`, `cd x &&`,
* an assignment) in the same argument.
*/
const ARG_HEADROOM_BYTES = 4096

/** bwrap's cap on parsed words, the command line and `--args` file together. */
const BWRAP_MAX_ARGS = 9000

/**
* The fd the `--args` file is opened on: a single digit, since dash rejects
* multi-digit redirections, and high, since embedders hand the command low
* fds of their own (an extra stdio pipe, a helper as `/proc/self/fd/3`).
*/
const BWRAP_ARGS_FD = 9

/**
* The directory `--args` files are written to, or why there is none.
*
* INVARIANT: the directory is created before this process wraps its first
* sandbox and every profile ro-binds it, so no sandbox the process launched
* can rewrite a profile bwrap has yet to read. It is never created a second
* time: a sandbox wrapped earlier would lack the new one's bind, and the
* library cannot tell whether that sandbox is still running (reset() does
* not end sandboxes, and cleanupBwrapMountPoints() is only as exact as its
* caller). So once the directory cannot be created, or is found removed or
* replaced, profiles stop binding it and an over-long one is refused until
* the process restarts. Removed at exit, not at reset().
*/
type BwrapArgsDir =
| { kind: 'dir'; path: string; identity: string }
| { kind: 'unavailable'; why: string }
let bwrapArgsDir: BwrapArgsDir | undefined
const bwrapArgsFiles: Set<string> = new Set()
let bwrapArgsFileCount = 0

function directoryIdentity(dir: string): string {
const { dev, ino, uid, mode } = fs.statSync(dir)
return `${dev}:${ino}:${uid}:${mode}`
}

/** bwrapArgsDir, created on the first call and checked on every later one. */
function checkedBwrapArgsDir(): BwrapArgsDir {
if (bwrapArgsDir === undefined) {
try {
// Dot-prefixed: the bind is a mount point the sandbox cannot delete,
// and `rm -rf "$TMPDIR"/*` in a sandboxed build step should not trip
// on it. Resolved: bwrap before 0.12 cannot follow an absolute
// symlink (/tmp -> /scratch/tmp) in a bind destination.
const dir = fs.realpathSync(
fs.mkdtempSync(path.join(tmpdir(), '.srt-bwrap-args-')),
)
bwrapArgsDir = {
kind: 'dir',
path: dir,
identity: directoryIdentity(dir),
}
registerExitCleanupHandler()
} catch (error) {
bwrapArgsDir = {
kind: 'unavailable',
why: `it could not be created under ${tmpdir()} (${error instanceof Error ? error.message : String(error)})`,
}
}
} else if (bwrapArgsDir.kind === 'dir') {
let found: string | undefined
try {
found = directoryIdentity(bwrapArgsDir.path)
} catch {
// Gone, or no longer reachable: the same as replaced.
}
if (found !== bwrapArgsDir.identity) {
bwrapArgsDir = {
kind: 'unavailable',
why: `${bwrapArgsDir.path} was removed or replaced after this process created it`,
}
}
}
return bwrapArgsDir
}

/** `rm` at a fixed system location, never from PATH; undefined if absent. */
let systemRm: string | null | undefined
function systemRmPath(): string | undefined {
if (systemRm === undefined) {
systemRm =
['/usr/bin/rm', '/bin/rm'].find(candidate => {
try {
fs.accessSync(candidate, fs.constants.X_OK)
return true
} catch {
return false
}
}) ?? null
}
return systemRm ?? undefined
}

/**
* The shell string that runs bwrap with `bwrapArgs`, which the caller runs
* as one argument of `sh -c`. When that would not fit the kernel's
* per-argument cap, the words in `mounts` (a slice of `bwrapArgs`) are
* written NUL-separated to a file and bwrap reads them through `--args` at
* the same position. Every other word, the per-command environment and the
* command among them, stays on the line, because the file is readable in
* every sandbox of this process. The result stays a simple command, so a
* prefix (`exec`, `timeout 30`) or a suffix (`&& next`) still composes.
* Throws when the profile cannot run: too many words for bwrap, no usable
* directory for the file, or a line too long even without the mounts.
*/
function renderBwrapInvocation(
bwrapBinary: string,
bwrapArgs: string[],
mounts: { start: number; end: number },
argsDir: BwrapArgsDir,
): string {
if (bwrapArgs.length > BWRAP_MAX_ARGS) {
throw new Error(
`Sandbox profile has ${bwrapArgs.length} bwrap arguments and bwrap accepts at most ${BWRAP_MAX_ARGS} (about ${BWRAP_MAX_ARGS / 3} mounts); reduce the number of paths the configuration expands to`,
)
}
const inline = quote([bwrapBinary, ...bwrapArgs])
const inlineBytes = Buffer.byteLength(inline, 'utf8')
const limit = maxArgStrlen() - 1
if (inlineBytes <= limit - ARG_HEADROOM_BYTES) {
return inline
}

const tooLong = `Sandbox profile is too long for the command line (${inlineBytes} bytes; past ${limit - ARG_HEADROOM_BYTES} it goes through a file)`
// `--args <fd>` are two more words.
if (bwrapArgs.length + 2 > BWRAP_MAX_ARGS) {
throw new Error(
`${tooLong} and, passed through a file, would exceed the ${BWRAP_MAX_ARGS} arguments bwrap accepts`,
)
}
if (argsDir.kind === 'unavailable') {
throw new Error(
`${tooLong} and cannot be passed through a file until this process restarts: ${argsDir.why}`,
)
}
const mountWords = bwrapArgs.slice(mounts.start, mounts.end)
if (mountWords.some(word => word.includes('\0'))) {
// bwrap splits the file on NUL: the word would become several options.
throw new Error(
`${tooLong} and contains a path with a NUL byte, which a file of bwrap arguments cannot carry`,
)
}
const argsFile = path.join(
argsDir.path,
`args-${process.pid}-${++bwrapArgsFileCount}`,
)
// /bin/sh opens the file on the fd, unlinks it at once (bwrap reads the
// open fd) and execs bwrap, so a file lives only from the wrap to the
// spawn; one never spawned goes at cleanup.
const rm = systemRmPath()
const viaArgsFile = quote([
'/bin/sh',
'-c',
`exec ${BWRAP_ARGS_FD}<"$1" && ${rm === undefined ? '' : `${rm} -f -- "$1" && `}shift && exec "$@"`,
'srt-args',
argsFile,
bwrapBinary,
...bwrapArgs.slice(0, mounts.start),
'--args',
String(BWRAP_ARGS_FD),
...bwrapArgs.slice(mounts.end),
])
const viaArgsFileBytes = Buffer.byteLength(viaArgsFile, 'utf8')
if (viaArgsFileBytes > limit) {
throw new Error(
`Sandboxed command is too long for one shell argument even with the mounts passed through a file (${viaArgsFileBytes} bytes; the limit here is ${limit})`,
)
}
// Tracked before the write, so a failed write is cleaned up as well.
bwrapArgsFiles.add(argsFile)
try {
fs.writeFileSync(argsFile, mountWords.map(word => word + '\0').join(''), {
mode: 0o600,
flag: 'wx',
})
} catch (error) {
bwrapArgsDir = {
kind: 'unavailable',
why: `writing ${argsFile} failed (${error instanceof Error ? error.message : String(error)})`,
}
throw new Error(
`${tooLong} and cannot be passed through a file until this process restarts: ${bwrapArgsDir.why}`,
)
}
logForDebugging(
`[Sandbox Linux] bwrap mounts moved to ${argsFile} (fd ${BWRAP_ARGS_FD}): the command line would be ${inlineBytes} bytes as one argument`,
)
return viaArgsFile
}

function removeBwrapArgsDir(): void {
if (bwrapArgsDir?.kind !== 'dir') return
try {
fs.rmSync(bwrapArgsDir.path, { recursive: true, force: true })
} catch {
// Unremovable: nothing left to do at exit.
}
bwrapArgsDir = undefined
}

// Number of wrapped commands that have been generated but whose cleanup has
// not yet run. cleanupBwrapMountPoints() defers file deletion while this is
// positive, because deleting a mount point file on the host while another
Expand All @@ -460,6 +700,7 @@ function registerExitCleanupHandler(): void {

process.on('exit', () => {
cleanupBwrapMountPoints({ force: true })
removeBwrapArgsDir()
})

exitHandlerRegistered = true
Expand All @@ -485,6 +726,8 @@ function registerExitCleanupHandler(): void {
*
* Pass `{ force: true }` to delete unconditionally — used by the process-exit
* handler and reset() where deferral is not meaningful.
*
* Also removes the `--args` files of wraps that were never spawned.
*/
export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void {
if (!opts?.force) {
Expand Down Expand Up @@ -527,6 +770,16 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void {
}
}
bwrapMountPoints.clear()

for (const argsFile of bwrapArgsFiles) {
try {
fs.rmSync(argsFile, { force: true })
} catch {
// Unremovable (a permission change under the directory): cleanup
// must not throw at the caller.
}
}
bwrapArgsFiles.clear()
}

/**
Expand Down Expand Up @@ -2017,7 +2270,15 @@ export async function wrapCommandWithSandboxLinux(
allowGitConfig,
abortSignal,
)
const mountsStart = bwrapArgs.length
bwrapArgs.push(...fsArgs)
const argsDir = checkedBwrapArgsDir()
if (argsDir.kind === 'dir') {
// Last of the binds, so it lands over an allowWrite of tmpdir (and
// beneath a denyRead tmpfs over it: a pending file is readable).
bwrapArgs.push('--ro-bind', argsDir.path, argsDir.path)
}
const mounts = { start: mountsStart, end: bwrapArgs.length }

// Always bind /dev
bwrapArgs.push('--dev', '/dev')
Expand Down Expand Up @@ -2090,7 +2351,12 @@ export async function wrapCommandWithSandboxLinux(
bwrapArgs.push(command)
}

const wrappedCommand = quote([bwrapPath ?? 'bwrap', ...bwrapArgs])
const wrappedCommand = renderBwrapInvocation(
bwrapPath ?? 'bwrap',
bwrapArgs,
mounts,
argsDir,
)

const restrictions = []
if (needsNetworkRestriction) restrictions.push('network')
Expand Down
29 changes: 29 additions & 0 deletions test/helpers/bwrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { spawnSync } from 'node:child_process'

/**
* Whether bwrap can run the namespace/proc surface the wrapped commands use
* (--unshare-pid, --unshare-user, --proc): a bare --ro-bind probe passes on
* hosts where mounting a fresh /proc in the new PID namespace still EPERMs.
* No --unshare-net, so a netns-restricted host does not skip tests that
* never create one.
*/
export function bwrapCanNamespace(): boolean {
return (
spawnSync(
'bwrap',
[
'--unshare-pid',
'--unshare-user',
'--cap-drop',
'ALL',
'--ro-bind',
'/',
'/',
'--proc',
'/proc',
'true',
],
{ timeout: 5000 },
).status === 0
)
}
Loading
Loading