diff --git a/README.md b/README.md index 14d896861..9d3963dfb 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ child.on('exit', async code => { }) ``` -**Violation attribution (`commandId` / `commandText`).** Violations observed while a wrapped command runs (seatbelt log lines, seccomp events, proxy denies) are stored under an attribution key, and `annotateStderrWithSandboxFailures(key, stderr)` / `getViolationsForCommand(key)` look them up by that same key. By default the key is the wrapped string itself. Pass an opaque per-invocation `commandId` (e.g. a tool-use id) to key by that instead — recommended: keys compare on their first 100 characters, so long commands sharing a prefix would otherwise cross-attribute, and a rerun of the same text would inherit the earlier run's events. If the string you *execute* is not the command the invocation *represents* (e.g. you wrap an assembled `source && eval ''`), also pass `commandText: ''`: it is what `ignoreViolations` command patterns match against and what each violation reports as its `command`. +**Violation attribution (`commandId` / `commandText`).** Violations observed while a wrapped command runs (seatbelt log lines, seccomp events, proxy denies) are stored under an attribution key, and `annotateStderrWithSandboxFailures(key, stderr)` / `getViolationsForCommand(key)` look them up by that same key. By default the key is the wrapped string itself. Pass an opaque per-invocation `commandId` (e.g. a tool-use id) to key by that instead — recommended: keys compare on their first 100 characters, so long commands sharing a prefix would otherwise cross-attribute, and a rerun of the same text would inherit the earlier run's events. If the string you _execute_ is not the command the invocation _represents_ (e.g. you wrap an assembled `source && eval ''`), also pass `commandText: ''`: it is what `ignoreViolations` command patterns match against and what each violation reports as its `command`. ```typescript const wrapped = await SandboxManager.wrapWithSandbox( @@ -226,7 +226,10 @@ const wrapped = await SandboxManager.wrapWithSandbox( { commandId: invocationId, commandText: rawCommand }, ) // ... run it ... -const annotated = SandboxManager.annotateStderrWithSandboxFailures(invocationId, stderr) +const annotated = SandboxManager.annotateStderrWithSandboxFailures( + invocationId, + stderr, +) ``` #### Available exports @@ -652,6 +655,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 an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. + +- The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. +- Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. +- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile is released. +- The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. +- 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. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index adab73a71..26ac1bce1 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -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' @@ -441,6 +441,223 @@ 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 + +/** + * Linux's O_TMPFILE, which neither Node nor Bun exposes in `fs.constants`: + * the asm-generic __O_TMPFILE, the value on every architecture they build + * for Linux, together with the O_DIRECTORY the flag is defined to carry — a + * kernel or filesystem that does not know it therefore fails the open on the + * directory rather than creating a named file. + */ +const O_TMPFILE = 0o20000000 | fs.constants.O_DIRECTORY + +/** + * The `--args` profiles this process holds open. + * + * A profile is an unnamed file (O_TMPFILE): written at wrap time, kept open + * here, and opened again by the string bwrap runs through + * `/proc//fd/`, which is a fresh read-only description of the same + * inode at offset 0. Nothing is named at any point, so there is nothing for + * anyone to substitute between the wrap and the execution — not a sandbox + * that renames an ancestor of tmpdir, not a sandbox another process of the + * same user launched with tmpdir writable. Every sandbox this library starts + * has its own PID namespace and a fresh /proc, so none of them can reach + * /proc/ either. + * + * Fds close when the sandboxes of a batch are cleaned up, and fd numbers are + * reused: a string kept past its cleanup and run later opens whatever the + * number means by then — nothing (the redirection fails and the command does + * not run), something bwrap refuses, or another profile of this process, + * never one from outside it. + */ +const bwrapArgsFds: Set = new Set() + +/** Where the string bwrap runs opens the profile held on `fd`. */ +function bwrapArgsProfilePath(fd: number): string { + return `/proc/${process.pid}/fd/${fd}` +} + +/** + * Writes `mountWords` NUL-separated to an unnamed file and returns the fd it + * stays open on. Throws when no directory takes an O_TMPFILE file, or when + * the profile cannot be opened again through /proc: there is no named-file + * fallback, and before any of this an over-long profile failed with E2BIG + * anyway. The check is this process's own open; a child's can still be + * refused (a process made non-dumpable owns its /proc entries as root), and + * the string then fails in the redirection and runs no command. + */ +function openBwrapArgsProfile(mountWords: string[]): number { + const contents = mountWords.map(word => word + '\0').join('') + const failures: string[] = [] + for (const dir of new Set([tmpdir(), '/dev/shm'])) { + let fd: number + try { + fd = fs.openSync(dir, O_TMPFILE | fs.constants.O_RDWR, 0o600) + } catch (error) { + failures.push(`${dir}: ${errorText(error)}`) + continue + } + try { + fs.writeFileSync(fd, contents) + // Read-only from here: this process is done writing it. + fs.fchmodSync(fd, 0o400) + // The string opens this path. Fail now, where the caller is told why, + // rather than when the command runs. + fs.closeSync(fs.openSync(bwrapArgsProfilePath(fd), fs.constants.O_RDONLY)) + } catch (error) { + failures.push(`${dir}: ${errorText(error)}`) + fs.closeSync(fd) + continue + } + bwrapArgsFds.add(fd) + return fd + } + throw new Error( + `no unnamed file could be opened for it (${failures.join('; ')})`, + ) +} + +function closeBwrapArgsProfile(fd: number): void { + bwrapArgsFds.delete(fd) + try { + fs.closeSync(fd) + } catch { + // Already closed: nothing left to release. + } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * 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`) go to an + * unnamed file this process holds open (see `bwrapArgsFds`) and bwrap reads + * them through `--args` at the same position; the string opens that file + * again through /proc. Every other word, the per-command environment and the + * command among them, stays on the line. 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 unnamed + * file to put the mounts in, or a line too long even without them. + */ +function renderBwrapInvocation( + bwrapBinary: string, + bwrapArgs: string[], + mounts: { start: number; end: number }, +): 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 ` 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`, + ) + } + 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`, + ) + } + let argsFd: number + try { + argsFd = openBwrapArgsProfile(mountWords) + } catch (error) { + throw new Error( + `${tooLong} and cannot be passed through a file: ${errorText(error)}`, + ) + } + // /bin/sh opens the profile on the fd and execs bwrap, which reads it to + // EOF and closes it before running the command. + const viaArgsFile = quote([ + '/bin/sh', + '-c', + `exec ${BWRAP_ARGS_FD}<"$1" && shift && exec "$@"`, + 'srt-args', + bwrapArgsProfilePath(argsFd), + bwrapBinary, + ...bwrapArgs.slice(0, mounts.start), + '--args', + String(BWRAP_ARGS_FD), + ...bwrapArgs.slice(mounts.end), + ]) + const viaArgsFileBytes = Buffer.byteLength(viaArgsFile, 'utf8') + if (viaArgsFileBytes > limit) { + closeBwrapArgsProfile(argsFd) + 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})`, + ) + } + logForDebugging( + `[Sandbox Linux] bwrap mounts moved to an unnamed file, read through ${bwrapArgsProfilePath(argsFd)} on bwrap's fd ${BWRAP_ARGS_FD}: the command line would be ${inlineBytes} bytes as one argument`, + ) + return viaArgsFile +} + // 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 @@ -485,6 +702,8 @@ function registerExitCleanupHandler(): void { * * Pass `{ force: true }` to delete unconditionally — used by the process-exit * handler and reset() where deferral is not meaningful. + * + * Also closes the `--args` profiles the wraps of this batch opened. */ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { if (!opts?.force) { @@ -527,6 +746,10 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { } } bwrapMountPoints.clear() + + for (const argsFd of [...bwrapArgsFds]) { + closeBwrapArgsProfile(argsFd) + } } /** @@ -2017,7 +2240,9 @@ export async function wrapCommandWithSandboxLinux( allowGitConfig, abortSignal, ) + const mountsStart = bwrapArgs.length bwrapArgs.push(...fsArgs) + const mounts = { start: mountsStart, end: bwrapArgs.length } // Always bind /dev bwrapArgs.push('--dev', '/dev') @@ -2090,7 +2315,11 @@ export async function wrapCommandWithSandboxLinux( bwrapArgs.push(command) } - const wrappedCommand = quote([bwrapPath ?? 'bwrap', ...bwrapArgs]) + const wrappedCommand = renderBwrapInvocation( + bwrapPath ?? 'bwrap', + bwrapArgs, + mounts, + ) const restrictions = [] if (needsNetworkRestriction) restrictions.push('network') diff --git a/test/helpers/bwrap.ts b/test/helpers/bwrap.ts new file mode 100644 index 000000000..6b891137d --- /dev/null +++ b/test/helpers/bwrap.ts @@ -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 + ) +} diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts new file mode 100644 index 000000000..0cc9dadbf --- /dev/null +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { isLinux } from '../helpers/platform.js' +import { bwrapCanNamespace } from '../helpers/bwrap.js' + +/** + * A bwrap profile too large for one shell argument (32 pages) has its mounts + * handed to bwrap through `--args`, from an unnamed file this process holds + * open and the string reopens through /proc; a profile that fits stays on the + * command line. + */ +describe.if(isLinux)('bwrap --args for over-long profiles', () => { + const MAX_ARG_STRLEN = + 32 * Number(spawnSync('getconf', ['PAGESIZE'], { encoding: 'utf8' }).stdout) + // The largest rendering kept on the command line: the kernel's limit less + // the NUL, less the 4 KiB left for a prefix of the caller's own. + const INLINE_MAX = MAX_ARG_STRLEN - 1 - 4096 + // The one rendered shape: the profile's path, then the options left before + // and the words left after `--args 9`. + const VIA_ARGS_FILE = + /^\/bin\/sh -c 'exec 9<"\$1" && shift && exec "\$@"' srt-args (\S+) bwrap (.*?) ?--args 9 (.*)$/s + const MODULE = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', + ) + + let BASE: string + const savedCwd = process.cwd() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + + beforeEach(() => { + // Other suites wrap without cleaning up, and the active count is shared. + cleanupBwrapMountPoints({ force: true }) + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'bwrap-args-'))) + // cwd outside the write allowlist keeps the mandatory-deny scan from + // adding mounts of its own. + process.chdir(BASE) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + // `count` files with names near the 255-byte limit, each its own /dev/null + // mask, as the concrete list the wrapper takes (glob expansion happens a + // layer up, in SandboxManager). + function maskedFiles(count: number): string[] { + const dir = join(BASE, 'many') + mkdirSync(dir, { recursive: true }) + const files: string[] = [] + for (let i = 0; i < count; i++) { + const file = join(dir, `${'a'.repeat(240)}-${i}.log`) + // Content, so the e2e case can tell the host file was left alone. + writeFileSync(file, 'secret\n') + files.push(file) + } + return files + } + + // Each mask renders as about 300 bytes: comfortably past the cap. + const overLongProfile = (): string[] => + maskedFiles(Math.ceil(MAX_ARG_STRLEN / 300) + 50) + + async function wrap( + files: string[], + opts: { + command?: string + allowOnly?: string[] + denyWithinAllow?: string[] + setEnvVars?: Record + unsetEnvVars?: string[] + mandatoryDenySearchDepth?: number + } = {}, + ): Promise { + return wrapCommandWithSandboxLinux({ + command: opts.command ?? 'echo hello', + needsNetworkRestriction: false, + readConfig: { denyOnly: files }, + writeConfig: { + allowOnly: opts.allowOnly ?? [], + denyWithinAllow: opts.denyWithinAllow ?? [], + }, + setEnvVars: opts.setEnvVars, + unsetEnvVars: opts.unsetEnvVars, + mandatoryDenySearchDepth: opts.mandatoryDenySearchDepth, + }) + } + + function rejection(wrapping: Promise): Promise { + return wrapping.then( + () => 'resolved', + (error: unknown) => String(error), + ) + } + + function argsPathOf(wrapped: string): string { + const rendered = wrapped.match(VIA_ARGS_FILE) + expect(rendered).not.toBeNull() + return rendered![1]! + } + + // A fresh process for what depends on the module's per-process state (the + // open profiles, the fd baseline) or on TMPDIR at first use. `body` runs + // after the prelude and prints one JSON value; `launcher` runs the runtime + // itself under something (bwrap, to take away the directories an unnamed + // file can go in). + function isolated( + body: string, + env: Record = {}, + launcher: string[] = [], + ): unknown { + const files = overLongProfile() + const script = ` + import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${JSON.stringify(MODULE)} + import * as fs from 'node:fs' + const overLong = ${JSON.stringify(files)} + const small = overLong.slice(0, 1) + const wrap = (denyOnly, command = 'echo hello') => wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly }, + writeConfig: { allowOnly: [], denyWithinAllow: [] }, + }) + const outcome = wrapping => wrapping.then(() => 'resolved', error => String(error)) + const argsPathOf = wrapped => wrapped.match(/' srt-args (\\S+) /)?.[1] + ${body} + ` + // A file, not `-e`: the script names every fixture path, and would not + // fit one argument itself. + const scriptFile = join(BASE, 'isolated.ts') + writeFileSync(scriptFile, script) + // A tmpdir of its own, so what a scenario leaves there goes with BASE. + mkdirSync(join(BASE, 'tmp'), { recursive: true }) + const argv = [...launcher, process.execPath, 'run', scriptFile] + const run = spawnSync(argv[0]!, argv.slice(1), { + cwd: BASE, + encoding: 'utf8', + env: { ...process.env, TMPDIR: join(BASE, 'tmp'), ...env }, + timeout: 60000, + }) + expect(run.stderr).toBe('') + return JSON.parse(run.stdout) + } + + it('keeps a profile that fits on the command line, and names no file for it', async () => { + const files = maskedFiles(20) + const wrapped = await wrap(files) + expect(wrapped).not.toContain('--args') + expect(wrapped).not.toContain('srt-args') + expect(wrapped).toContain(`--ro-bind /dev/null ${files[0]}`) + }) + + it('moves the mounts, and only the mounts, to an unnamed file the string reopens through /proc', async () => { + const files = overLongProfile() + const wrapped = await wrap(files, { + setEnvVars: { SRT_TEST_VAR: "value with spaces and 'quotes'" }, + }) + + expect(Buffer.byteLength(wrapped)).toBeLessThan(MAX_ARG_STRLEN) + const [, argsPath, before, after] = wrapped.match(VIA_ARGS_FILE)! + // This process's own fd: the profile has no name anywhere, so nothing + // can be put in its place between here and the execution. + expect(argsPath).toMatch(new RegExp(`^/proc/${process.pid}/fd/\\d+$`)) + expect(readlinkSync(argsPath!)).toMatch(/\(deleted\)$/) + + const words = readFileSync(argsPath!, 'utf8').split('\0') + expect(words[words.length - 1]).toBe('') + const mounts = words.slice(0, -1) + expect(mounts.filter(w => w === '/dev/null').length).toBe(files.length) + expect(mounts).toContain(files[0]) + // Only mounts go to the file: nothing about the command or its + // environment is in it. + expect( + mounts.filter( + w => w.startsWith('--') && !/^--(ro-bind|bind|tmpfs)$/.test(w), + ), + ).toEqual([]) + expect(before).toContain( + `--setenv SRT_TEST_VAR 'value with spaces and '"'"'quotes'"'"''`, + ) + expect(before).toContain('--new-session') + expect(before).not.toContain('--ro-bind /dev/null') + // What followed the mounts still follows them. + expect(after).toMatch(/--unshare-pid .* -- \S+ -c /s) + }) + + it('switches to the file exactly where one argument would come within 4 KiB of the cap', async () => { + // The command is the last word on the line; a trailing two-byte + // character keeps the shell quoter's output constant while every + // added 'a' adds one byte, so the padding sets the rendered size byte + // for byte, and a regression to string length (UTF-16 units) would + // miscount it by one. + const files = maskedFiles(20) + const base = await wrap(files, { command: 'é' }) + expect(base).not.toContain('--args') + const renderedAt = (bytes: number) => + wrap(files, { + command: 'a'.repeat(bytes - Buffer.byteLength(base)) + 'é', + }) + + const fits = await renderedAt(INLINE_MAX) + expect(Buffer.byteLength(fits)).toBe(INLINE_MAX) + expect(fits).not.toContain('--args') + + expect(await renderedAt(INLINE_MAX + 1)).toMatch(VIA_ARGS_FILE) + }) + + it('refuses a command that is too long for one argument by itself', async () => { + expect( + await rejection( + wrap(maskedFiles(20), { command: 'a'.repeat(MAX_ARG_STRLEN) }), + ), + ).toMatch(/too long for one shell argument even with the mounts/) + }) + + it('refuses a profile past the 9000 arguments bwrap accepts', async () => { + const names = Array.from({ length: 4500 }, (_, i) => `V${i}`) + expect( + await rejection(wrap(maskedFiles(1), { unsetEnvVars: names })), + ).toMatch(/bwrap accepts at most 9000/) + }) + + it('refuses a mount path with a NUL byte, which bwrap would split into several options', async () => { + expect( + await rejection( + wrap(overLongProfile(), { + allowOnly: [BASE], + denyWithinAllow: [join(BASE, 'x\0--cap-add\0ALL')], + }), + ), + ).toMatch(/NUL byte/) + }) + + it('holds one fd per pending profile and gives them all back at cleanup, a refused wrap included', () => { + const seen = isolated(` + const openFds = () => fs.readdirSync('/proc/self/fd').length + // The runtime opens event-loop fds of its own on the first wrap. + await wrap(small) + const baseline = openFds() + await wrap(overLong) + await wrap(overLong) + const held = openFds() - baseline + // A rendering that cannot be run releases its profile too. + const refused = await outcome(wrap(overLong, 'a'.repeat(${MAX_ARG_STRLEN}))) + const afterRefusal = openFds() - baseline + cleanupBwrapMountPoints({ force: true }) + console.log(JSON.stringify({ held, refused, afterRefusal, afterCleanup: openFds() - baseline })) + `) + expect(seen).toEqual({ + held: 2, + refused: expect.stringMatching(/too long for one shell argument/), + afterRefusal: 2, + afterCleanup: 0, + }) + }) + + it('refuses at wrap time, with the reason, when no directory takes an unnamed file', () => { + // Both candidates read-only: tmpdir and /dev/shm. A profile that fits + // needs neither and is unaffected. + const roTmp = join(BASE, 'ro-tmp') + mkdirSync(roTmp) + const seen = isolated( + ` + const fits = await wrap(small) + console.log(JSON.stringify({ + fits: fits.includes('--ro-bind /dev/null') && !fits.includes('srt-args'), + refused: await outcome(wrap(overLong)), + })) + `, + { TMPDIR: roTmp }, + [ + 'bwrap', + '--dev-bind', + '/', + '/', + '--ro-bind', + '/etc', + '/dev/shm', + '--ro-bind', + '/etc', + roTmp, + ], + ) + expect(seen).toEqual({ + fits: true, + refused: expect.stringMatching( + /cannot be passed through a file: no unnamed file could be opened for it \(.*read-only/s, + ), + }) + }) + + it('fails in the redirection, and does not run the command, when the string is run after its cleanup', async () => { + const marker = join(BASE, 'ran') + const wrapped = await wrap(overLongProfile(), { + command: `touch ${marker}`, + }) + cleanupBwrapMountPoints({ force: true }) + // No pipes for the run: a parent-side fd would take the number the + // profile just gave up. + const run = spawnSync(wrapped, { + shell: true, + stdio: 'ignore', + timeout: 60000, + }) + expect(run.status).not.toBe(0) + expect(existsSync(marker)).toBe(false) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'e2e: a pending profile survives a tmpdir whose parent is replaced, because no path leads to it', + () => { + const parent = join(BASE, 'scratch') + mkdirSync(join(parent, 'tmp'), { recursive: true }) + const marker = join(BASE, 'written-by-the-sandbox') + const seen = isolated( + ` + const { spawnSync } = await import('node:child_process') + const wrapped = await wrap(overLong, 'touch ${marker} 2>/dev/null && echo WROTE || echo DENIED') + const argsPath = argsPathOf(wrapped) + // The runtime leaves a cache of its own there; nothing of ours. + const namedUnderTmpdir = fs.readdirSync(process.env.TMPDIR, { recursive: true }) + .filter(entry => /srt|args|bwrap/.test(entry)) + // What a sandbox with the parent writable, or another process of + // this user sandboxing with tmpdir writable, can arrange: the + // directory goes aside, and a profile that binds / read-write takes + // the place of every path the string could still open. + fs.renameSync(${JSON.stringify(parent)}, ${JSON.stringify(parent + '.aside')}) + let planted = null + if (!argsPath.startsWith('/proc/')) { + fs.mkdirSync(argsPath.slice(0, argsPath.lastIndexOf('/')), { recursive: true }) + fs.writeFileSync(argsPath, '--bind\\0/\\0/\\0') + planted = argsPath + } + const run = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 }) + console.log(JSON.stringify({ + argsPath, + namedUnderTmpdir, + planted, + status: run.status, + stdout: run.stdout.trim(), + marker: fs.existsSync(${JSON.stringify(marker)}), + })) + `, + { TMPDIR: join(parent, 'tmp') }, + ) + expect(seen).toEqual({ + argsPath: expect.stringMatching(/^\/proc\/\d+\/fd\/\d+$/), + namedUnderTmpdir: [], + planted: null, + status: 0, + // The command ran under the profile that was wrapped, not one + // planted after it. + stdout: 'DENIED', + marker: false, + }) + }, + 60_000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'e2e: bwrap applies the mounts from the file, the string composes with a prefix and a suffix, and the command reaches neither fd 9 nor the profile', + async () => { + const files = overLongProfile() + // tmpdir writable inside the sandbox: with nothing named there, that + // is no longer a way to the pending profile. The runner's tmpdir is + // scanned shallowly, since every mount costs bwrap time. + const wrapped = await wrap(files, { + allowOnly: [tmpdir()], + mandatoryDenySearchDepth: 1, + command: [ + // The mask is a bind of /dev/null: a character device in place + // of the file (opening a device node inside the user namespace + // is not portable across hosts, so its type is the oracle). + `[ -c ${files[0]} ] && echo MASKED || echo UNMASKED`, + '[ -e /proc/self/fd/9 ] && echo FD9_OPEN || echo FD9_CLOSED', + // A fresh /proc in its own PID namespace: the wrapping process, + // and so the fd its profiles are on, is not there at all. + `[ -e /proc/${process.pid} ] && echo RUNTIME_PROC_VISIBLE || echo RUNTIME_PROC_HIDDEN`, + ].join('; '), + }) + expect(argsPathOf(wrapped)).toMatch( + new RegExp(`^/proc/${process.pid}/fd/\\d+$`), + ) + const run = spawnSync(`timeout 60 ${wrapped} && echo AFTER`, { + shell: true, + encoding: 'utf8', + timeout: 60000, + cwd: BASE, + }) + expect(run.status).toBe(0) + expect(run.stdout.trim().split('\n')).toEqual([ + 'MASKED', + 'FD9_CLOSED', + 'RUNTIME_PROC_HIDDEN', + 'AFTER', + ]) + expect(readFileSync(files[0]!, 'utf8')).toBe('secret\n') + }, + 60_000, + ) +})