diff --git a/README.md b/README.md index 7a96720d2..f0d14abec 100644 --- a/README.md +++ b/README.md @@ -595,7 +595,7 @@ Examples: - `allowPty` - macOS only: allow pseudo-terminal operations (boolean, default: false). The seatbelt profile then carries `(allow pseudo-tty)` plus read, write and `ioctl` on `/dev/ptmx` and `/dev/ttys*`, which a command that allocates a pty of its own needs. Not read on Linux or Windows. - `bwrapPath` - Linux only: absolute path to the `bwrap` (bubblewrap) binary, used instead of resolving `bwrap` on `PATH`. A path that is not executable is a dependency error, and no `PATH` lookup is tried after it. - `socatPath` - Linux only: absolute path to the `socat` binary, used instead of resolving `socat` on `PATH`, with the same rule for a path that is not executable. -- `seccomp` - Linux only: `applyPath` points at the `apply-seccomp` binary to use instead of the packaged one, which is still used when nothing exists at that path. `argv0` invokes it as a multicall binary that dispatches on the `ARGV0` environment variable; `applyPath` is then used verbatim (no existence check) and must resolve inside the bubblewrap namespace. +- `seccomp` - Linux only: `applyPath` points at the `apply-seccomp` binary to use instead of the packaged one, which is still used when nothing exists at that path. `argv0` invokes it as a multicall binary that dispatches on the `ARGV0` environment variable; `applyPath` is then used verbatim (no existence check) and must resolve inside the bubblewrap namespace. `holdsLinks` says whether the helper takes `--hold-link` (see "Denied paths and symbolic links"), and is taken at its word: nothing is run to find out. Not set, a helper the library found for itself is read for the option's name, and one named by `applyPath` is handed no link. Set it from a probe of your own helper (`apply-seccomp --holds-links` exits 0), not from a constant: a helper without the option takes `--hold-link` for the command, so under a wrong `true` no sandboxed command starts where there is a link to hold. - `ripgrep` - Linux only: how to invoke ripgrep for the deny-path scan (default: `{ "command": "rg" }`). `args` are passed before srt's own arguments, and `argv0` overrides `argv[0]` for a multicall binary. - `git.safeDirectories` - Directories git should treat as `safe.directory` inside the sandbox, where the working tree is owned by another user — the repository top level when the command runs from a subdirectory, say. Injected through `GIT_CONFIG_*` environment variables, and it grants no write access of its own. - `credentials` - Environment variables (`credentials.envVars`) and files (`credentials.files`) the sandboxed command must not read the real values of. `mode: "deny"` withholds the value; `mode: "mask"` puts a sentinel in its place inside the sandbox and has the proxy substitute the real value back on egress to the hosts that entry's `injectHosts` lists (default: `network.allowedDomains`). Masking requires `network.tlsTerminate`, so the real value only leaves over a verified TLS connection, unless `credentials.allowPlaintextInject` opts out. Files are masked on Linux only; macOS denies a masked file instead. @@ -1172,6 +1172,18 @@ Filesystem restrictions are enforced at the OS level: **Writes through links (Linux and macOS):** a write is judged by where it lands, after symbolic links are followed. A link inside an allowed write path that leads out of it gives a command nothing: the write is refused. A link that leads into an allowed write path works like any other name for that place. A hard link is a second name for the file itself: on Linux a file outside the allowed write paths that already has a hard link inside one can be written through it, and a sandboxed command cannot create such a link. On macOS nothing is promised for such a file. +**Denied paths and symbolic links (Linux and macOS):** a `denyWrite` entry or a [mandatory deny path](#mandatory-deny-paths-auto-protected-files) with a symbolic link on the way to it, its last name included, denies the path the links lead to, whether or not anything is there yet, and keeps each link where it is: removing, renaming and replacing it are refused, and so is renaming a directory above it. A directory that a link's target goes into and out of again by `..` is kept in place too, and a name that is not there and that the target goes up from cannot be made. The link reads and leads as before, and the rest of its directory stays as writable as it was. The links are those found when the command is wrapped. + +- Nothing is denied among the devices: a name blanked with a link to `/dev/null` is kept, and `/dev/null` stays as it was. +- A tool that replaces a file instead of writing into it (`sed -i`, `mv -f`, `ln -sfn`, `rsync`, `tar x`, `git checkout` where the tracked link differs, `git clean -fdx`) is refused at such a name, as at any denied one, on Linux with `EBUSY` ("Device or resource busy"). +- A directory on the way that cannot be looked in (no search permission, or a look that fails twice for any reason but that nothing is or can be at the name) is denied whole in place of the path, its mode included. Never `/`: there the path is taken as written. +- On Linux the same links are kept on the way to a `denyRead` entry, to a link that a `denyRead` pattern matches, and to a `credentials.files` path: the next command's mounts are placed by the name again. +- On Linux bubblewrap cannot mount on a link, so the seccomp helper does (`apply-seccomp --hold-link `): each link in a directory the command can write to is mounted on itself, read-only, in the mount namespaces made for the command. Where that cannot be done (the name is no link any more, the link says something else than when the command was wrapped, the mount is refused), no command is started: the helper exits 1, and its one line on stderr starts with `apply-seccomp: --hold-link : `. `seccomp.holdsLinks: false` turns holding off. +- On Linux `SandboxManager.getLinuxUnheldLinks()` returns each link that should be held and is not, as `{ path, reason }`, to show or to look at again after the command; the only other sign is a line under `SRT_DEBUG`. What such a link leads to is denied all the same, and the command can replace the link. The reasons: `no_helper` (`allowAllUnixSockets`, or no binary for the architecture), `told_no` (`seccomp.holdsLinks` is false), `not_told` (the helper is named by `seccomp.applyPath` and `seccomp.holdsLinks` is not set), `older_helper` (the helper the library found for itself is built from older sources), `no_answer` (that helper's file could not be read). A link is listed by the latest wrap that met it. `reset()` and `updateConfig()` empty the list, and a wrap still in flight then lists its links afterwards. +- On Linux the held links' paths and targets stay on the command line (see the note on large profiles below): some thousand of them reach the limit of one argument, and the `command_too_long` refusal says how many bytes are theirs. +- Not covered: a mandatory deny name _below_ the working directory that is itself a link (the Linux scan does not see it; the macOS pattern keeps the name and says nothing of what it leads to). On Linux, a link on the way to what a `denyRead` pattern matches, other than the match itself (a `denyWrite` pattern is skipped there as a whole), and a writer outside the sandbox that changes a link while the command is being wrapped. +- Not reported on Linux: the violation monitor judges by the configured names, so a refused attempt on a kept link or directory is no violation in its store. + **Read-side rules (Linux):** an entry is matched by the name it is, not by what it points at. - An `allowRead` entry re-allows the name it names, so a link planted at an allowed path cannot re-open a denied one. One that is itself a symlink is bound back at its own name, from the target it was checked against: the target's own path stays hidden, and the target's contents are reachable only through the name. A `denyRead` entry or a credential mask that overlaps that target — at it, inside it, or covering it — wins over the carve-out, which is then not restored at all: the name is absent inside the sandbox rather than serving what the deny hides. The denied directory the carve-out is being restored into is not such an overlap, nor is anything above it: those are what the carve-out is an exception to. Neither is an entry that denies nothing — one naming a path that is not there, or a file an `allowRead` entry lifts. diff --git a/src/index.ts b/src/index.ts index f3f9f7874..229c83b35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,8 @@ export { type SandboxDependencyCheck, LinuxSandboxProfileError, type LinuxSandboxProfileErrorCode, + type LinuxUnheldLink, + type LinuxUnheldLinkReason, } from './sandbox/linux-sandbox-utils.js' // Windows install/status API diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 66f9db747..0103b9c1a 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1,6 +1,6 @@ import { quote } from '../utils/shell-quote.js' import { logForDebugging } from '../utils/debug.js' -import { realPathOf, wayTo } from '../utils/real-path.js' +import { realPathOf, wayPastMissingNames } from '../utils/real-path.js' import { whichSync } from '../utils/which.js' import { randomBytes } from 'node:crypto' import * as fs from 'fs' @@ -20,6 +20,7 @@ import { encodeSandboxedCommand, attributionKeyFor, DANGEROUS_FILES, + followLinks, isAbsenceErrno, isAtOrUnder, isStrictlyUnder, @@ -150,9 +151,6 @@ function findSymlinkInPath( return null } -/** As many missing names as one way is followed up from again. */ -const MAX_MISSING_NAMES_PASSED = 40 - /** * Canonicalize a deny path through symlinks before any mask or bind is * computed (resolve-before-mask). Deny paths regularly contain symlinked @@ -225,21 +223,11 @@ function resolveSymlinkedDenyPath( } catch { return rejoined // Not a symlink — nothing left to resolve. } - let why: unknown = 'it goes up from too many names that are not there' + let why: unknown try { - let onward = rejoined - for (let i = 0; i < MAX_MISSING_NAMES_PASSED; i++) { - const { real, rest, notThere } = wayTo(onward) - if ((notThere as NodeJS.ErrnoException | undefined)?.code === 'EACCES') { - return real || '/' - } - const names = rest.filter(name => name !== '' && name !== '.') - if (names[1] !== '..') { - return [real, ...names.slice(0, 2)].join('/') || '/' - } - alsoDenied?.push(`${real}/${names[0]}`) - onward = [real, ...names.slice(2)].join('/') - } + const { real, rest, shut, passed } = wayPastMissingNames(rejoined) + alsoDenied?.push(...passed) + return [real, ...(shut ? [] : rest.slice(0, 2))].join('/') || '/' } catch (err) { why = err } @@ -1035,7 +1023,13 @@ export class WorkingDirectoryChanged extends Error { function renderBwrapInvocation( bwrapBinary: string, bwrapArgs: string[], - mounts: { start: number; end: number; mandatoryDenyArgs: number }, + mounts: { + start: number + end: number + mandatoryDenyArgs: number + /** How much of the command word is the seccomp helper's `--hold-link`s. */ + holdWordsBytes: number + }, ): string { // Whose the arguments are and what lowers each share: the mandatory denies // follow from what the working directory holds, not from the configuration. @@ -1100,7 +1094,11 @@ function renderBwrapInvocation( closeBwrapArgsProfile(argsFd) throw new LinuxSandboxProfileError( 'command_too_long', - `Sandboxed command is too long for one shell argument even with the mounts passed through a file (${viaArgsFileBytes} bytes; the limit here is ${limit})`, + `Sandboxed command is too long for one shell argument even with the mounts passed through a file (${viaArgsFileBytes} bytes; the limit here is ${limit})${ + mounts.holdWordsBytes > 0 + ? `. At least ${mounts.holdWordsBytes} of them are not the caller's: they name the symbolic links on the way to denied paths, for the seccomp helper to hold, and stay on the line (seccomp.holdsLinks: false leaves them unheld)` + : '' + }`, ) } logForDebugging( @@ -1663,6 +1661,60 @@ export async function initializeLinuxNetworkBridge( } } +/** Why a symbolic link that should be held in place is not; see README.md. */ +export type LinuxUnheldLinkReason = + | 'no_helper' + | 'told_no' + | 'not_told' + | 'older_helper' + | 'no_answer' + +/** + * Why the seccomp helper is not handed the links to hold, or undefined where + * it is. One built from older sources takes `--hold-link` for the command to + * run, so the words go to no helper that is not known to take them. + */ +function whyLinksAreNotHeld( + helperInUse: boolean, + config: SeccompConfig | undefined, +): LinuxUnheldLinkReason | undefined { + if (!helperInUse) return 'no_helper' + if (config?.holdsLinks !== undefined) { + return config.holdsLinks ? undefined : 'told_no' + } + // A path the caller gave need name the helper only inside the sandbox. + const helper = config?.argv0 + ? null + : getApplySeccompBinaryPath(config?.applyPath) + if (helper === null || helper === config?.applyPath) return 'not_told' + // THREAT: read, never run. This is outside any sandbox, and where the + // library lies in a path the command may write (a checkout, a project's own + // node_modules) the file is whatever an earlier command left there. + try { + return fs.readFileSync(helper).includes('--holds-links') + ? undefined + : 'older_helper' + } catch { + return 'no_answer' + } +} + +/** A symbolic link where it lies, and why nothing holds it there. */ +export type LinuxUnheldLink = { path: string; reason: LinuxUnheldLinkReason } + +// By link: what the latest wrap that met it came to. +const unheldLinks = new Map() + +/** `SandboxManager.getLinuxUnheldLinks()`. */ +export function getLinuxUnheldLinks(): LinuxUnheldLink[] { + return [...unheldLinks].map(([path, reason]) => ({ path, reason })) +} + +/** For `reset()` and `updateConfig()`: the list is of one configuration. */ +export function forgetLinuxUnheldLinks(): void { + unheldLinks.clear() +} + /** * Read by the seccomp helper, never by this process. The write denies are * read-only binds, and a bind protects a path only in the mount namespace it @@ -2101,7 +2153,14 @@ async function generateFilesystemArgs( mandatoryDenySearchDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, -): Promise<{ args: string[]; mandatoryDenyArgs: number }> { + /** Whether the caller has `linksToHold` held. */ + linksAreHeld = false, +): Promise<{ + args: string[] + mandatoryDenyArgs: number + /** The links the command could replace, each with what it said. */ + linksToHold: Array<[link: string, target: string]> +}> { const args: string[] = [] const startedIn = workingDirectory() // fs already imported @@ -2114,6 +2173,19 @@ async function generateFilesystemArgs( const denyWriteArgs: string[] = [] // Where the mandatory denies' binds begin in it: after the configuration's. let mandatoryDenyArgsStart = Infinity + // What the ways to the denied paths depend on; see followLinks. + const linksToDenies = new Map() + const leftOnTheWay = new Set() + /** + * Returns what stands in for `name` where the walk to it could not look in + * a directory: that directory, and the missing names it went up from before. + */ + const noteLinksTo = (name: string): string[] | undefined => { + const { links, left, passed, unsearched } = followLinks(name) + for (const [link, target] of links) linksToDenies.set(link, target) + for (const dir of left) leftOnTheWay.add(dir) + return unsearched === undefined ? undefined : [unsearched, ...passed] + } // Directories that a deny entry re-binds read-only inside the sandbox // (--ro-bind ), keyed by resolved dest, with every raw // (pre-resolution) spelling each was reached through. A non-existent deny @@ -2619,6 +2691,21 @@ async function generateFilesystemArgs( // against it on both sides of the await above, the only one here. if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() + // Each deny path as both loops below take it. A directory the walk could + // not look in stands in for the path: bound read-only, it keeps its mode. + const rawDenyPaths = denyPaths.flatMap(pathPattern => { + const rawPath = normalizePathForSandbox(pathPattern, { literal: true }) + // Both loops skip it. + if (rawPath.startsWith('/dev/')) return rawPath + const instead = noteLinksTo(rawPath) + if (instead === undefined) return rawPath + logForDebugging( + `[Sandbox Linux] Write deny path ${rawPath} cannot be looked at; denying ${instead[0]} whole instead`, + { level: 'warn' }, + ) + return instead + }) + // Duplicate deny entries must be collapsed: a duplicate // --ro-bind /dev/null hits a char device on the second pass and // bwrap's ensure_file() falls through to creat() on a read-only mount. @@ -2643,12 +2730,11 @@ async function generateFilesystemArgs( // where the loop masks that component and emits no bind for the // directory (the re-check below); an emitted one missing from the record // only costs a spurious abort. - for (const pathPattern of denyPaths) { - const rawPath = normalizePathForSandbox(pathPattern, { literal: true }) + for (const rawPath of rawDenyPaths) { if (rawPath.startsWith('/dev/')) { continue } - const resolvedPath = resolveSymlinkedDenyPath(rawPath, denyPaths) + const resolvedPath = resolveSymlinkedDenyPath(rawPath, rawDenyPaths) if (resolvedPath === null || resolvedPath.startsWith('/dev/')) { continue } @@ -2921,11 +3007,10 @@ async function generateFilesystemArgs( return '/dev/null' } } - for (const [index, pathPattern] of denyPaths.entries()) { + for (const [index, rawPath] of rawDenyPaths.entries()) { if (index === configuredDenyPaths.length) { mandatoryDenyArgsStart = denyWriteArgs.length } - const rawPath = normalizePathForSandbox(pathPattern, { literal: true }) // Skip /dev/* paths since --dev /dev already handles them if (rawPath.startsWith('/dev/')) { @@ -3299,9 +3384,39 @@ async function generateFilesystemArgs( // each: a symlink spelling would pin the chain of the link, not of where // the mount sits. Deny binds, tmpfs units and masks are emitted later and // land on top of both pins and covers. + // + // The read side's names count like the write denies': the next wrap finds + // what to hide by the name again. A path that cannot be looked at has its + // stand-in already (readDenyTargetOf), and one that is where it resolves to + // has no link on the way. + for (const name of [ + ...readDenyPlan().map(entry => entry.normalizedPath), + ...(maskedFileBinds ?? []).map(bind => bind.realPath), + ]) { + const { canonical, resolved } = canonicalLocationOf(name) + if (!resolved || canonical !== name) noteLinksTo(name) + } + readConfig?.matchedLinks?.forEach(noteLinksTo) + // A link is held where the command could replace it: in a directory it can + // write to, and sees as it is on the host. + const linksToHold = [...linksToDenies].filter( + ([link]) => + isWithinAnyAllowedWritePath(path.dirname(link)) && + !KERNEL_TOP_LEVEL_DIRS.some(dir => isAtOrUnder(link, dir)) && + !isHiddenByTmpfs(path.dirname(link), 'writes and reads'), + ) const pinArgs = ancestorPinArgs( [ ...denyWriteRawDests.keys(), + // The directories above a held link are pinned like those above a + // mount, and so is each the way goes up from, itself included. Where + // nothing holds the links, pins would keep nothing from being led away. + ...(linksAreHeld || linksToHold.length === 0 + ? [ + ...linksToHold.map(([link]) => link), + ...[...leftOnTheWay].map(dir => `${dir}/.`), + ] + : []), ...fileMasks.map(mask => mask.landing), ...readDenyTmpfsUnits.map(unit => unit.landing), ], @@ -3480,7 +3595,7 @@ async function generateFilesystemArgs( args.push('--ro-bind', emptySource, emptySource) } - return { args, mandatoryDenyArgs } + return { args, mandatoryDenyArgs, linksToHold } } /** @@ -3779,7 +3894,15 @@ export async function wrapCommandWithSandboxLinux( } // ========== FILESYSTEM RESTRICTIONS ========== - const { args: fsArgs, mandatoryDenyArgs } = await generateFilesystemArgs( + const whyNotHeld = whyLinksAreNotHeld( + applySeccompPrefix !== undefined, + seccompConfig, + ) + const { + args: fsArgs, + mandatoryDenyArgs, + linksToHold, + } = await generateFilesystemArgs( readConfig, writeConfig, maskedFileBinds, @@ -3788,13 +3911,20 @@ export async function wrapCommandWithSandboxLinux( mandatoryDenySearchDepth, allowGitConfig, abortSignal, + whyNotHeld === undefined, ) + const holdWords = + whyNotHeld === undefined + ? quote(linksToHold.flatMap(pair => ['--hold-link', ...pair])) + : '' + if (holdWords !== '') applySeccompPrefix += `${holdWords} ` const mountsStart = bwrapArgs.length bwrapArgs.push(...fsArgs) const mounts = { start: mountsStart, end: bwrapArgs.length, mandatoryDenyArgs, + holdWordsBytes: Buffer.byteLength(holdWords, 'utf8'), } // Always bind /dev @@ -3921,6 +4051,18 @@ export async function wrapCommandWithSandboxLinux( `[Sandbox Linux] Wrapped command with bwrap (${restrictions.join(', ')} restrictions)`, ) + for (const [link] of linksToHold) { + if (whyNotHeld === undefined) { + unheldLinks.delete(link) + continue + } + unheldLinks.set(link, whyNotHeld) + logForDebugging( + `[Sandbox Linux] A denied path leads through the symbolic link ${link}, which the command can replace: nothing holds it (${whyNotHeld})`, + { level: 'warn' }, + ) + } + // INVARIANT: a wrap that hands out a command is in the count when it does, // whatever a forced cleanup did meanwhile: its caller gives one back. if (forcedCleanups !== countedAfter) activeSandboxCount++ diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 1eb0db73f..b80eeb46e 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -12,6 +12,7 @@ import { encodeSandboxedCommand, decodeSandboxedCommand, containsGlobChars, + followLinks, isAtOrUnder, isStrictlyUnder as isPathStrictlyUnder, nameLocation, @@ -362,14 +363,20 @@ interface ResolvedReadConfig { * links on the way resolved. A deny is resolved to its end. An allow is a * name, so its last component stays as written. Of a pattern, the directory * it starts from is what is resolved. The root is never a second spelling. + * `end` is the second spelling where the caller has walked there itself. */ -function inBothSpellings(kind: PathListKind, entry: PathEntry): PathEntry[] { +function inBothSpellings( + kind: PathListKind, + entry: PathEntry, + end?: string, +): PathEntry[] { const resolved = (p: string): string => pathSpellings(p)[1] ?? p const base = entryBaseDir(entry) const canonical = - kind === 'deny' || entry.glob + end ?? + (kind === 'deny' || entry.glob ? resolved(base) - : nameLocation(base, resolved) + : nameLocation(base, resolved)) // Nor among the devices: a rule on a name blanked with a link to /dev/null // would be one on /dev/null for every command. if ( @@ -998,12 +1005,20 @@ function generateWriteRules( // Combine user-specified and mandatory deny patterns (no ripgrep needed on // macOS). The caller's spellings are patterns when they read as patterns; // the mandatory entries carry their own literal/glob split. - const denyEntries = [ + const walked = [ ...(config.denyWithinAllow || []).map(toPathEntry), ...(config.literalDenyWithinAllow ?? []).map(toLiteralPathEntry), ...macGetMandatoryDenyEntries(allowGitConfig), - ] - denyEntries.push(...alsoReadAs(config.denyWithinAllow, 'deny')) + ...alsoReadAs(config.denyWithinAllow, 'deny'), + ].map(entry => ({ entry, ...followLinks(entryBaseDir(entry)) })) + // As written, and where the links on the way lead, there or not: that is the + // path Seatbelt compares a write through them with. Denied whole: a directory + // the walk could not look in, and a missing name the way goes up from. + const denyEntries = walked.flatMap(({ entry, end, unsearched, passed }) => [ + ...inBothSpellings('deny', entry, end), + ...(unsearched === undefined ? [] : [unsearched]).map(toLiteralPathEntry), + ...passed.map(toLiteralPathEntry), + ]) const { groups, rest: ungrouped } = groupLiteralDenyPaths(denyEntries) const groupFilters = groups.map(literalDenyGroupFilters) @@ -1029,6 +1044,14 @@ function generateWriteRules( moveFilters.add(`(literal ${escapePath(ancestorDir)})`) } } + // Each of those links stays where it lies, and each directory the way goes + // up from: whoever replaces one decides what the denied name leads to. + for (const stays of walked.flatMap(w => [...w.links.keys(), ...w.left])) { + if (isAtOrUnder(stays, '/dev')) continue + for (const kept of [stays, ...getAncestorDirectories(stays)]) { + moveFilters.add(`(literal ${escapePath(kept)})`) + } + } rules.push( ...renderRule( 'deny', diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 51bce7f20..b4eade164 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -75,12 +75,18 @@ function collapseReadDenyLocations({ * come back unmasked. * @param opts.anchor - as in `ExpandGlobOptions.anchor`. * @param opts.listings - one map for all the patterns of a configuration. + * @param opts.matchedLinks - receives the matches that are symbolic links, by + * name: what is returned for one is where it leads. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { anchor?: string; listings?: GlobWalkListings } = {}, + opts: { + anchor?: string + listings?: GlobWalkListings + matchedLinks?: Set + } = {}, ): string[] { return finish( expandReadDenyGlobLinuxSteps( @@ -97,7 +103,11 @@ export function* expandReadDenyGlobLinuxSteps( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { anchor?: string; listings?: GlobWalkListings } = {}, + opts: { + anchor?: string + listings?: GlobWalkListings + matchedLinks?: Set + } = {}, ): Steps { const walk = yield* walkGlobPatternSteps(globPattern, { withDirectoryForm: true, @@ -141,6 +151,7 @@ export function* expandReadDenyGlobLinuxSteps( if (!namedBy.has(location)) namedBy.set(location, candidate) } for (const candidate of candidates) { + if (walk.symlinks.has(candidate)) opts.matchedLinks?.add(candidate) if (walk.symlinks.has(candidate) && !walk.realOf.has(candidate)) { if (!walk.uninspectableLinks.has(candidate)) { // A link that resolves to nothing denies nothing, and bwrap cannot diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index b8930db2b..fa5fb1d91 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -1147,6 +1147,15 @@ export const SeccompConfigSchema = z.object({ 'applyPath resolves inside the bwrap namespace and that the target ' + 'binary implements the apply-seccomp interface when ARGV0 matches.', ), + holdsLinks: z + .boolean() + .optional() + .describe( + 'Whether the helper takes --hold-link, which keeps the symbolic links ' + + 'on the way to denied paths in place. Taken at its word. Not set, a ' + + 'helper the library found for itself is read for the option, and ' + + 'one named by applyPath is handed no link. false turns holding off.', + ), }) /** diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 9738c78dd..8192949f0 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -63,6 +63,9 @@ import { checkLinuxDependencies, type SandboxDependencyCheck, cleanupBwrapMountPoints, + forgetLinuxUnheldLinks, + getLinuxUnheldLinks, + type LinuxUnheldLink, linuxGetCwdMandatoryDenyPaths, } from './linux-sandbox-utils.js' import { expandReadDenyGlobLinuxSteps } from './read-deny-glob.js' @@ -2283,6 +2286,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { getFsWriteConfig(), ) const unlistableDenyDirs = new Set() + const matchedLinks = new Set() const listings: GlobWalkListings = new Map() const denyPaths = finish( resolveReadDenies( @@ -2293,7 +2297,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { pattern, reExposedPaths, unlistableDenyDirs, - { anchor, listings }, + { anchor, listings, matchedLinks }, ), ), ) @@ -2302,6 +2306,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { ...denyPaths, allowWithinDeny: allowPaths, unlistableDenyDirs: [...unlistableDenyDirs], + ...(matchedLinks.size > 0 && { matchedLinks: [...matchedLinks] }), ...literalReadLists( config.filesystem.denyRead, config.filesystem.allowRead, @@ -2875,6 +2880,7 @@ async function wrapWithSandboxOnce( writeConfig, ) const unlistableDenyDirs = new Set() + const matchedLinks = new Set() const listings: GlobWalkListings = new Map() const expandedDenyRead = await walked( resolveReadDenies( @@ -2885,7 +2891,7 @@ async function wrapWithSandboxOnce( pattern, reExposedPaths, unlistableDenyDirs, - { anchor, listings }, + { anchor, listings, matchedLinks }, ), ), ) @@ -2898,6 +2904,7 @@ async function wrapWithSandboxOnce( allowWithinDeny: expandedAllowRead, ...(ownAllowWithinDeny.length > 0 && { ownAllowWithinDeny }), unlistableDenyDirs: [...unlistableDenyDirs], + matchedLinks: [...matchedLinks], ...literalReadLists( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead, @@ -3518,6 +3525,7 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { config = structuredClone({ ...newConfig, network: rest }) config.network.filterRequest = filterRequest configInstalls++ + forgetLinuxUnheldLinks() resolvedAddressGuard = nextGuard // Re-resolve parent proxy so hot-reload picks up changes. Note: the proxy // servers capture `parentProxy` by value at creation, so changes here take @@ -3685,6 +3693,7 @@ async function reset(): Promise { // Clean up any leftover bwrap mount points. Force past the // active-sandbox counter — reset() means the session is over. cleanupBwrapMountPoints({ force: true }) + forgetLinuxUnheldLinks() // Stop log monitor if (logMonitorShutdown) { @@ -4013,6 +4022,12 @@ export interface ISandboxManager { getSandboxViolationStore(): SandboxViolationStore annotateStderrWithSandboxFailures(command: string, stderr: string): string getLinuxGlobPatternWarnings(): string[] + /** + * Linux: the symbolic links on the way to a denied path that a command + * could replace, each with the reason nothing holds it. Each is listed by + * the latest wrap that met it; `reset()` and `updateConfig()` empty the list. + */ + getLinuxUnheldLinks(): LinuxUnheldLink[] getConfig(): SandboxRuntimeConfig | undefined getMitmCA(): MitmCA | undefined /** @@ -4083,6 +4098,7 @@ export const SandboxManager: ISandboxManager = { getSandboxViolationStore, annotateStderrWithSandboxFailures, getLinuxGlobPatternWarnings, + getLinuxUnheldLinks, getConfig, updateConfig, } as const diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index e282b6025..52340805c 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -45,6 +45,11 @@ export interface FsReadRestrictionConfig { * unmasked. Linux only: the other backends match globs natively. */ unlistableDenyDirs?: string[] + /** + * The names a glob expansion matched that are symbolic links: `denyOnly` + * holds what each leads to. Linux only, likewise. + */ + matchedLinks?: string[] } /** diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index c85cbbdb9..349043e55 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -3,7 +3,7 @@ import * as path from 'path' import * as fs from 'fs' import { getPlatform } from '../utils/platform.js' import { logForDebugging } from '../utils/debug.js' -import { realPathOf } from '../utils/real-path.js' +import { realPathOf, wayPastMissingNames } from '../utils/real-path.js' import { denyGlobRegex, globToRegex, @@ -116,6 +116,63 @@ export function nameLocation( return `${parent === '/' ? '' : parent}/${path.basename(p)}` } +/** + * What has to stay as it is for `p`, a denied name, to go on leading where it + * leads, and where that is. `p` is folded in text first, as by `realPathOf`. + * + * INVARIANT: "could not look" is never "no link". A walk that stops for any + * reason but a name that is not there, or that nothing can be at, has no end, + * and `unsearched` is the directory it could not look in: a same-uid command + * can take a directory's search bit away and give it back from inside the next + * sandbox. Never the root, which stands in for nothing. + */ +export function followLinks(p: string): { + /** The symbolic links on the way: where each lies, and what it says, '' + * where that is no text. */ + links: Map + /** The directories a link's target goes into and, by `..`, out of again. */ + left: Set + /** The names that are not there which one goes up from in the same way. */ + passed: string[] + /** Where the way ends, what is not there taken as written: the path a write + * through a dangling link would create. A way that goes up again further on + * among such names ends at the first. None where the walk stopped. */ + end?: string + unsearched?: string +} { + const links = new Map() + const left = new Set() + const walk = (): ReturnType => { + const { real, rest, shut, passed } = wayPastMissingNames(path.resolve(p), { + link: (at, target) => links.set(at, target), + left: dir => left.add(dir), + }) + if (shut) return { links, left, passed, unsearched: real || undefined } + const names = rest.includes('..') ? rest.slice(0, 1) : rest + return { links, left, passed, end: [real, ...names].join('/') || '/' } + } + try { + return walk() + } catch { + // Once more: a look can fail in passing (EIO, ESTALE), and what would + // follow from that is a directory denied whole. + } + try { + return walk() + } catch (err) { + const { code, path: at } = err as NodeJS.ErrnoException + // A link all the same, which says what no string spells. + if (code === 'EILSEQ' && at) links.set(at, '') + const nothingCanBeThere = + isAbsenceErrno(err) || + code === 'EILSEQ' || + code === 'ERR_INVALID_ARG_VALUE' + const dir = path.dirname(at ?? path.resolve(p)) + const unsearched = nothingCanBeThere || dir === '/' ? undefined : dir + return { links, left, passed: [], unsearched } + } +} + /** `process.cwd()`, or undefined where that throws: it has been removed. */ export function workingDirectory(): string | undefined { try { diff --git a/src/utils/real-path.ts b/src/utils/real-path.ts index 8ffbceebd..d49b04c17 100644 --- a/src/utils/real-path.ts +++ b/src/utils/real-path.ts @@ -14,6 +14,7 @@ function textOf(ask: typeof fs.readlinkSync, at: string): string { if (text.includes('\uFFFD') && !ask(at, 'buffer').equals(Buffer.from(text))) { throw Object.assign(new Error(`EILSEQ: illegal byte sequence, '${at}'`), { code: 'EILSEQ', + path: at, }) } return text @@ -50,6 +51,15 @@ export function realPathOf(p: string): string { return real || '/' } +/** What {@link wayTo} passes that decides where it leads: whoever can put + * something else at one of these can lead it elsewhere. */ +export type WayWatcher = { + /** A link it follows: where the link lies, and what it says. */ + link?: (at: string, target: string) => void + /** A directory it goes up from again, by a `..` in a link's target. */ + left?: (dir: string) => void +} + /** * The kernel's way to `folded`, an absolute path with no `.`, `..` or `//` of * its own: name by name, split at `/` alone, every link followed. `real` is @@ -58,7 +68,10 @@ export function realPathOf(p: string): string { * look for (EACCES), with what the kernel said of it. Whatever else stops the * way is thrown. POSIX only. */ -export function wayTo(folded: string): { +export function wayTo( + folded: string, + watcher: WayWatcher = {}, +): { real: string rest: string[] notThere?: unknown @@ -77,7 +90,10 @@ export function wayTo(folded: string): { } return { real, rest: [name, ...rest], notThere } } - if (name === '..') real = real.slice(0, real.lastIndexOf('/')) + if (name === '..' && real !== '') { + watcher.left?.(real) + real = real.slice(0, real.lastIndexOf('/')) + } continue } const next = `${real}/${name}` @@ -104,8 +120,39 @@ export function wayTo(folded: string): { ) } const target = textOf(fs.readlinkSync, next) + watcher.link?.(next, target) if (target.startsWith('/')) real = '' rest.unshift(...target.split('/')) } return { real, rest } } + +/** + * {@link wayTo}, followed on where it goes up again at once from a name that + * is not there (`missing/../x`): a directory made at that name lets the way + * pass, and a link made there leads it elsewhere. `passed` holds each such + * name, `rest` no `.` or empty name, and `shut` whether `real` lets nobody + * look (EACCES). ELOOP past as many of them as links. + */ +export function wayPastMissingNames( + folded: string, + watcher?: WayWatcher, +): { real: string; rest: string[]; shut: boolean; passed: string[] } { + const passed: string[] = [] + let onward = folded + while (passed.length <= MAX_LINKS) { + const { real, rest, notThere } = wayTo(onward, watcher) + const names = rest.filter(name => name !== '' && name !== '.') + const shut = + (notThere as NodeJS.ErrnoException | undefined)?.code === 'EACCES' + if (shut || names[1] !== '..') return { real, rest: names, shut, passed } + passed.push(`${real}/${names[0]}`) + onward = [real, ...names.slice(2)].join('/') + } + throw Object.assign( + new Error( + `ELOOP: '${folded}' goes up from too many names that are not there`, + ), + { code: 'ELOOP', path: folded }, + ) +} diff --git a/test/docker-weak-sandbox.test.ts b/test/docker-weak-sandbox.test.ts index 2dbd49595..696b92714 100644 --- a/test/docker-weak-sandbox.test.ts +++ b/test/docker-weak-sandbox.test.ts @@ -19,8 +19,10 @@ import { spawnSync } from 'node:child_process' import { mkdirSync, rmSync, + symlinkSync, writeFileSync, readFileSync, + readlinkSync, existsSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -33,11 +35,14 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { const ALLOWED = join(WORK, 'allowed') const DENIED = join(WORK, 'denied') const SECRET = join(WORK, 'secret') + const LINK = join(ALLOWED, 'link') const CONFIG = join(WORK, 'srt.json') // Same policy without the seccomp helper: the command then runs in bwrap's // own namespaces, where --cap-drop ALL is the only thing between it and the // deny mounts. const CONFIG_NO_SECCOMP = join(WORK, 'srt-no-seccomp.json') + // Same policy with the helper's namespaces filter lifted. + const CONFIG_NAMESPACES = join(WORK, 'srt-namespaces.json') // umount(8) reports through /proc/self/mountinfo, which under the helper // belongs to another pid namespace; call the syscall so the kernel's own // errno is what the test reads. @@ -71,6 +76,7 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { mkdirSync(DENIED, { recursive: true }) mkdirSync(SECRET, { recursive: true }) writeFileSync(join(SECRET, 'key'), 'TOPSECRET') + symlinkSync('leads-to', LINK) writeFileSync( UMOUNT_PROBE, [ @@ -78,7 +84,8 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { "libc = ctypes.CDLL('libc.so.6', use_errno=True)", 'for target in sys.argv[1:]:', ' ctypes.set_errno(0)', - ' rc = libc.umount2(target.encode(), 0)', + // UMOUNT_NOFOLLOW, which also reaches a mount on a symbolic link. + ' rc = libc.umount2(target.encode(), 8)', ' e = ctypes.get_errno()', " print('umount2 %s rc=%d errno=%s (%s)' % (", ' target, rc, errno.errorcode.get(e, str(e)), os.strerror(e)))', @@ -89,7 +96,7 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { filesystem: { denyRead: [SECRET], allowWrite: [ALLOWED], - denyWrite: [], + denyWrite: [LINK], }, enableWeakerNestedSandbox: true, } @@ -100,6 +107,14 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { network: { allowedDomains: [], deniedDomains: [] }, }), ) + writeFileSync( + CONFIG_NAMESPACES, + JSON.stringify({ + ...policy, + network: { allowedDomains: [], deniedDomains: [] }, + allowNestedUserNamespaces: true, + }), + ) writeFileSync( CONFIG_NO_SECCOMP, JSON.stringify({ @@ -166,6 +181,26 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { expect(existsSync(out)).toBe(false) }) + // The filter refuses the unmount; where it is lifted, the lock does. + it.each([ + ['', CONFIG, 'EPERM'], + [' from a command allowed namespaces', CONFIG_NAMESPACES, 'EINVAL'], + ])( + 'keeps a write-denied symbolic link in place%s', + (_from, config, errno) => { + const r = srt( + `echo SANDBOX-RAN; python3 ${UMOUNT_PROBE} ${LINK}; rm ${LINK} 2>&1`, + config, + ) + + expect(r.stdout).toContain('SANDBOX-RAN') + expect(r.stdout).toContain(`umount2 ${LINK} rc=-1 errno=${errno}`) + // EBUSY, as glibc words it ("Device or resource busy") or musl. + expect(r.stdout).toMatch(/resource busy/i) + expect(readlinkSync(LINK)).toBe('leads-to') + }, + ) + // The kernel would let it: the command holds the capability. it('cannot make a mount namespace of its own (seccomp helper)', () => { const probe = join(WORK, 'mount-namespace-probe.py') diff --git a/test/sandbox/literal-path-entries.test.ts b/test/sandbox/literal-path-entries.test.ts index 5dc79e9a4..ec32a9202 100644 --- a/test/sandbox/literal-path-entries.test.ts +++ b/test/sandbox/literal-path-entries.test.ts @@ -1321,9 +1321,9 @@ describe.if(isLinux)( it.skipIf(!CAN_RUN || process.getuid?.() === 0)( 'takes any write deny with glob characters for the path it spells when the folder cannot be looked at', async () => { - // Whether anything in the folder has the name cannot be told, so a - // pattern is also applied like a path that is not there: the name is - // kept from being created, and what it would match is not denied. + // Whether anything in the folder has the name cannot be told, nor + // what: so the folder is denied whole, wherever it lies. Its mode + // cannot be given back, so what the pattern would match is safe too. const locked = join(root, 'locked') const elsewhere = join(root, 'elsewhere') mkdirSync(locked) @@ -1377,13 +1377,12 @@ describe.if(isLinux)( const { denied, stdout } = await wrapsWith(name, elsewhere) expect(denied).toEqual([join(locked, name)]) expect(stdout).not.toContain('CREATED') - expect(stdout).toContain('WROTE') + expect(stdout).not.toContain('WROTE') expect(stdout).toContain('END') expect(existsSync(join(locked, name))).toBe(false) - // Below the working directory the folder is denied whole, because - // the scan for dangerous names could not read it either: its mode - // cannot be given back, so what the pattern would match is safe too. + // Below the working directory the scan for dangerous names, which + // could not read it either, comes to the same. const below = await wrapsWith(name, root) expect(below.denied).toEqual([join(locked, name)]) expect(below.stdout).not.toContain('CREATED') @@ -1391,12 +1390,10 @@ describe.if(isLinux)( expect(below.stdout).toContain('END') expect(existsSync(join(locked, name))).toBe(false) } - // The folder cannot be cleaned by the host while it cannot be looked - // at, for a name and for a pattern alike. - const leftByName = await leftBy('absent') - expect(await leftBy('*.pem')).toEqual( - leftByName.map(entry => (entry === 'absent' ? '*.pem' : entry)), - ) + // Nothing is made in a folder the host could not clean while it cannot + // be looked at, for a name and for a pattern alike. + expect(await leftBy('absent')).toEqual([]) + expect(await leftBy('*.pem')).toEqual([]) }, WRAP_TIMEOUT_MS, ) diff --git a/test/sandbox/symlinked-deny-paths.test.ts b/test/sandbox/symlinked-deny-paths.test.ts index b6ba7b03e..4ee1b8548 100644 --- a/test/sandbox/symlinked-deny-paths.test.ts +++ b/test/sandbox/symlinked-deny-paths.test.ts @@ -269,6 +269,10 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { const shut = join(PROJ, 'shut') expect(countMounts(result, '--ro-bind', shut, shut)).toBe(1) expect(result).not.toContain(`/dev/null ${join(PROJ, 'name')}`) + // Made a link, the missing name would lead to another `shut`. + expect( + countMounts(result, '--ro-bind', '/dev/null', join(PROJ, 'gone')), + ).toBe(target.startsWith('gone') ? 1 : 0) }, ) @@ -281,7 +285,8 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { const result = await wrap([join(PROJ, 'name')]) - expect(result).not.toContain(shut) + // Among the mounts: the helper is told what the link it holds says. + expect(result.split(' -- ')[0]).not.toContain(shut) expect(result).not.toContain(`/dev/null ${join(PROJ, 'name')}`) }, ) diff --git a/test/sandbox/write-deny-through-links.test.ts b/test/sandbox/write-deny-through-links.test.ts new file mode 100644 index 000000000..fff4a4f4e --- /dev/null +++ b/test/sandbox/write-deny-through-links.test.ts @@ -0,0 +1,682 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + mock, + spyOn, +} from 'bun:test' +import * as fs from 'fs' +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { homedir, tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import type { LinuxUnheldLink } from '../../src/index.js' +import * as seccomp from '../../src/sandbox/generate-seccomp-filter.js' +import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' +import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import { SandboxRuntimeConfigSchema } from '../../src/sandbox/sandbox-config.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { followLinks } from '../../src/sandbox/sandbox-utils.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' +import { isLinux, isMacOS, isWindows } from '../helpers/platform.js' + +/** + * A deny on a path with a symbolic link on the way to it, the last name + * included, covers what the links lead to and keeps the links where they are. + * Everything beside them stays as writable as it was. + * + * Every link here is made on the host before the command is wrapped. + */ + +const CWD = process.cwd() +// Where the temp dir really is: macOS names it through a link. +const BASE = join(fs.realpathSync(tmpdir()), `write-deny-links-${process.pid}`) +const ROOT = join(BASE, 'root') // the one write root +const OUTSIDE = join(BASE, 'outside') // beside it, under no write root +const HOLDER = join(ROOT, 'holder') +const inRoot = (names: string[]): string[] => names.map(n => join(ROOT, n)) + +/** Link name in ROOT -> what it says. A deny leads through every one. */ +const LINKS = { + name: 'conf/x', + 'holder/commands': '../shared/commands', + dangling: 'later', + way: 'real', + here: '.', + first: 'middle', + middle: 'conf/y', + up: 'in/to/../../real/secret', + past: 'lost/../gone/../name', + deep: 'void/deeper/../../real/secret', + far: 'holder/blank', + 'holder/blank': '/dev/null', + out: '/dev/stdout', +} +type Link = keyof typeof LINKS +const said = (names: Link[]): string[][] => names.map(n => [n, LINKS[n]]) +/** + * A name in ROOT, the links on the way to it and where they lead; then the + * directories the way goes up from, and the names that are not there and that + * it goes up from all the same. + */ +const WAYS: Array<[string, Link[], string, string[], string[]]> = [ + ['name', ['name'], 'conf/x', [], []], + ['holder/commands', ['holder/commands'], 'shared/commands', ['holder'], []], + ['dangling', ['dangling'], 'later', [], []], + ['way/secret', ['way'], 'real/secret', [], []], + ['way/absent', ['way'], 'real/absent', [], []], + // A link is named where it lies, whatever it was reached through. + ['here/name', ['here', 'name'], 'conf/x', [], []], + ['first', ['first', 'middle'], 'conf/y', [], []], + ['up', ['up'], 'real/secret', ['in/to', 'in'], []], + ['past', ['past', 'name'], 'conf/x', [], ['lost', 'gone']], + // Further on among names that are not there: it ends at the first. + ['deep', ['deep'], 'void', [], []], +] +const DENIED = inRoot([...WAYS.map(([name]) => name), 'far', 'out']) +const FILES = ['conf/x', 'conf/y', 'conf/z', 'real/secret', 'beside'] +const ABSENT = ['later', 'real/absent', 'shared/commands/new', 'lost', 'gone'] +/** EBUSY, as glibc words it ("Device or resource busy") or musl. */ +const BUSY = /resource busy/i +/** No mode keeps uid 0 from looking. */ +const modesCount = process.getuid?.() !== 0 +const namespaces = isLinux && bwrapCanNamespace() + +beforeEach(() => { + if (isWindows) return + for (const dir of ['conf', 'shared/commands', 'real', 'holder', 'in/to']) { + fs.mkdirSync(join(ROOT, dir), { recursive: true }) + } + fs.mkdirSync(OUTSIDE) + for (const file of FILES) fs.writeFileSync(join(ROOT, file), 'original') + for (const [link, says] of Object.entries(LINKS)) { + fs.symlinkSync(says, join(ROOT, link)) + } +}) + +afterEach(async () => { + if (isWindows) return + mock.restore() + process.chdir(CWD) + // Removes what bubblewrap made to mount on, and empties the list of links. + await SandboxManager.reset() + fs.chmodSync(HOLDER, 0o755) + fs.rmSync(BASE, { recursive: true, force: true }) +}) + +/** `command`, run by a shell: unless told otherwise under no write root, so + * that nothing of the working directory's is protected or made. */ +const run = (command: string, cwd = OUTSIDE): SpawnSyncReturns => + spawnSync(command, { shell: true, encoding: 'utf8', cwd, timeout: 30000 }) + +type Options = Parameters[0] & + Parameters[0] +type Rest = Partial + +/** `denyWithinAllow` denied in ROOT, the write root beside two devices. */ +function denying(denyWithinAllow: string[], rest?: Rest): Options { + const allowOnly = [ROOT, '/dev/null', '/dev/stdout'] + return { + command: 'echo started', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly, denyWithinAllow }, + ...rest, + } +} + +describe.if(!isWindows)('the links on the way to a path', () => { + /** What is said of `p`, a name in ROOT, with the names in ROOT again. */ + const followed = (p: string): object => { + const { links, left, ...rest } = followLinks(`${ROOT}/${p}`) + const told = JSON.stringify({ links: [...links], left: [...left], ...rest }) + return JSON.parse(told.split(`${ROOT}/`).join('')) as object + } + const NONE = { left: [], passed: [] } + + it.each<(typeof WAYS)[number]>([ + ...WAYS, + ['beside', [], 'beside', [], []], + ['absent/name', [], 'absent/name', [], []], + ['beside/name', [], 'beside/name', [], []], + ['way/absent/deeper', ['way'], 'real/absent/deeper', [], []], + // Folded in text, as everywhere a configured name is read. + ['./way/./secret/', ['way'], 'real/secret', [], []], + ['holder/commands/../name', [], 'holder/name', [], []], + ])('%s: %p, ending at %s', (p, links, end, left, passed) => { + expect(followed(p)).toEqual({ links: said(links), left, passed, end }) + }) + + it.each([ + ['longer than a name can be', 'a'.repeat(300)], + ['with a NUL byte', 'nul\0byte'], + ])('a name %s: nothing can be there, so it has looked', (_what, name) => { + expect(followed(`way/${name}`)).toEqual({ links: said(['way']), ...NONE }) + }) + + // Not every file system takes such a target. + it.if(isLinux)('has no end past a target that is no text', () => { + fs.symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'raw')) + expect(followed('raw/file')).toEqual({ links: [['raw', '']], ...NONE }) + }) + + it('follows 40 links, and has no end past them, where the kernel gives up too', () => { + for (let i = 1; i <= 41; i++) { + fs.symlinkSync(i > 1 ? `hop-${i - 1}` : 'beside', join(ROOT, `hop-${i}`)) + } + expect(followed('hop-40')).toMatchObject({ end: 'beside' }) + expect(Object.keys(followed('hop-41'))).toEqual(['links', 'left', 'passed']) + expect(() => fs.readFileSync(join(ROOT, 'hop-41'))).toThrow('ELOOP') + }) + + it.if(modesCount)('says which directory it could not look in', () => { + fs.chmodSync(HOLDER, 0) + fs.symlinkSync('holder/commands/../made', join(ROOT, 'via')) + expect(followed('via')).toMatchObject({ ...NONE, unsearched: 'holder' }) + }) + + const failing = (at?: fs.PathLike): never => { + throw Object.assign(new Error('EIO'), { code: 'EIO', path: at }) + } + + it('looks again where a look fails', () => { + spyOn(fs, 'readlinkSync').mockImplementationOnce(failing) + expect(followed('name')).toMatchObject({ end: 'conf/x' }) + }) + + it('where that fails too: the directory of what it looked at', () => { + spyOn(fs, 'readlinkSync').mockImplementation(failing) + expect(followed('way/secret')).toMatchObject({ unsearched: ROOT }) + }) + + it('and does not say where: the directory of the path as written', () => { + spyOn(fs, 'readlinkSync').mockImplementation(() => failing()) + expect(followed('way/secret')).toMatchObject({ unsearched: 'way' }) + }) + + it('in the root: no directory, which would stand in for everything', () => { + spyOn(fs, 'lstatSync').mockImplementation(failing) + expect(followed('name')).toEqual({ links: [], ...NONE }) + }) +}) + +describe.if(namespaces || isMacOS)('a deny with a link on the way', () => { + /** What `attempt` came to, run in ROOT with every one of `denied` denied. */ + async function outcomeOf(attempt: string, denied = DENIED): Promise { + const options = denying(denied, { + command: `cd ${ROOT} && if ( ${attempt} ); then echo DONE; else echo refused; fi`, + // Linux mounts a read deny where it resolves, under the /dev it then + // makes anew, and bubblewrap before 0.5.0 takes no device to mount on. + readConfig: { denyOnly: isMacOS ? inRoot(['far']) : [] }, + }) + const { stdout } = run( + isLinux + ? await wrapCommandWithSandboxLinux(options) + : wrapCommandWithSandboxMacOS(options), + ) + SandboxManager.cleanupAfterCommand() + return stdout.trim() + } + + it.each([ + ...Object.keys(LINKS).flatMap(link => [ + `rm ${link}`, + `mv ${link} aside`, + `ln -sfn beside ${link}`, + ]), + ...['name', 'dangling', 'middle'].map( + link => `echo new > made && mv -f made ${link}`, + ), + // A write or a create through the links, and by the path they lead to. + ...[...new Set(WAYS.flatMap(([name, , end]) => [name, end]))].map( + at => `echo changed >> ${at} || echo new > ${at}/new`, + ), + 'rm conf/x', + // The directories above a link and above what it leads to, those the way + // goes up from, and the names that are not there. + 'mv holder aside', + 'mv conf aside', + 'mv in/to aside', + 'mv in aside', + 'mkdir lost', + 'ln -s conf gone', + ])('refuses: %s', async attempt => { + expect(await outcomeOf(attempt)).toBe('refused') + + for (const [link, says] of Object.entries(LINKS)) { + expect(fs.readlinkSync(join(ROOT, link))).toBe(says) + } + for (const file of FILES) { + expect(fs.readFileSync(join(ROOT, file), 'utf8')).toBe('original') + } + expect(inRoot(ABSENT).filter(fs.existsSync)).toEqual([]) + }) + + it.each([ + 'echo changed >> beside && rm beside', + 'echo new > new && echo new > holder/new', + 'echo changed >> conf/z', + 'ln -s conf/z free && ln -sfn beside free', + 'test "$(readlink name)$(cat name way/secret)" = conf/xoriginaloriginal', + 'echo x > /dev/null && cat /dev/null', + ])('allows: %s', async attempt => { + expect(await outcomeOf(attempt)).toBe('DONE') + }) + + it.each(['rm .bashrc', 'echo changed >> conf/z', 'rm .claude/commands'])( + 'the same of the names denied in every working directory: %s', + async attempt => { + fs.mkdirSync(join(ROOT, '.claude')) + fs.symlinkSync('conf/z', join(ROOT, '.bashrc')) + fs.symlinkSync('../shared/commands', join(ROOT, '.claude', 'commands')) + process.chdir(ROOT) + expect(await outcomeOf(attempt, [])).toBe('refused') + }, + ) + + it.if(modesCount)('a directory it cannot look in is kept whole', async () => { + expect(await outcomeOf('chmod 000 holder')).toBe('DONE') + const undoing = 'chmod 755 holder; rm holder/commands || rm -r holder' + expect(await outcomeOf(undoing)).toBe('refused') + expect(fs.lstatSync(HOLDER).mode & 0o777).toBe(0) + fs.chmodSync(HOLDER, 0o755) + expect(fs.readdirSync(HOLDER).sort()).toEqual(['blank', 'commands']) + }) +}) + +// Profile text only, so it runs on every POSIX host. +describe.if(!isWindows)('Seatbelt profile', () => { + /** The filters of the rule that denies `ops`, one to a line. */ + function deniedBy(ops: string, write: string[], read: string[] = []): string { + const profile = wrapCommandWithSandboxMacOS( + denying(write, { readConfig: { denyOnly: read } }), + ) + const at = profile.indexOf(`(deny ${ops}\n`) + expect(at).toBeGreaterThan(-1) + return profile.slice(at, profile.indexOf('(with message', at)) + } + const KEPT = 'file-write-unlink file-write-create' + const subpath = (name: string): string => `(subpath "${join(ROOT, name)}")\n` + const literal = (name: string): string => `(literal "${join(ROOT, name)}")\n` + + it.each(WAYS)('%s keeps %p, denies %s', (name, links, end, left, passed) => { + const denies = deniedBy('file-write*', inRoot([name])) + for (const at of [name, end, ...passed]) { + expect(denies).toContain(subpath(at)) + } + const kept = deniedBy(KEPT, inRoot([name])) + for (const at of [...links, ...left]) expect(kept).toContain(literal(at)) + }) + + it('so is one on a name denied in every working directory', () => { + fs.symlinkSync('conf/x', join(ROOT, '.bashrc')) + process.chdir(ROOT) + expect(deniedBy('file-write*', [])).toContain(subpath('conf/x')) + }) + + it('a pattern deny starts from both spellings of its directory', () => { + const denies = deniedBy('file-write*', inRoot(['way/*.key'])) + const matches = (file: string): boolean => + [...denies.matchAll(/^ {2}\(regex (".*")\)$/gm)].some(([, text]) => + new RegExp(JSON.parse(text!) as string).test(join(ROOT, file)), + ) + expect(matches('way/a.key')).toBe(true) + expect(matches('real/a.key')).toBe(true) + expect(matches('conf/a.key')).toBe(false) + }) + + it('nothing among the devices is denied or kept; the links to them are kept, and what holds one', () => { + fs.symlinkSync('/dev', join(ROOT, 'devices')) + const denied = inRoot(['far', 'out', 'devices']) + const kept = deniedBy(KEPT, denied) + expect(deniedBy('file-write*', denied) + kept).not.toContain('"/dev') + for (const name of ['far', 'holder/blank', 'holder', 'out', 'devices']) { + expect(kept).toContain(literal(name)) + } + }) + + it('nor is a device hidden by a read deny on a link to it', () => { + const denies = deniedBy('file-read*', [], inRoot(['far', 'name'])) + expect(denies).toContain(subpath('conf/x')) + expect(denies).not.toContain('"/dev') + }) + + it.if(modesCount)('a directory it cannot look in is denied whole', () => { + fs.chmodSync(HOLDER, 0) + expect(deniedBy('file-write*', inRoot(['holder/commands']))).toContain( + subpath('holder'), + ) + }) +}) + +function wrapLinux(denied: string[], rest?: Rest): Promise { + return wrapCommandWithSandboxLinux(denying(denied, rest)) +} + +/** An executable file in OUTSIDE that holds `script`. */ +function program(name: string, script: string): string { + const file = join(OUTSIDE, name) + fs.writeFileSync(file, `#!/bin/sh\n${script}\n`, { mode: 0o755 }) + return file +} + +/** + * The words a wrap hands the seccomp helper before the command, as [link in + * ROOT, target] pairs. Read by the shells themselves, through every level of + * quoting, and with no namespace made: a stand-in for bubblewrap runs what + * follows `--`, three words, and one for the helper only prints its words. + */ +async function heldBy(denied: string[], rest?: Rest): Promise { + const bwrapPath = program('bwrap-stand-in', 'shift $(($# - 3)); exec "$@"') + const applyPath = program('helper-stand-in', `printf '%s\\0' "$@"`) + const seccompConfig = { applyPath, holdsLinks: true } + const wrapped = await wrapLinux(denied, { bwrapPath, seccompConfig, ...rest }) + const words = run(wrapped).stdout.split('\0') + const held: string[][] = [] + for (let i = 0; words[i] === '--hold-link'; i += 3) { + held.push([relative(ROOT, words[i + 1]!), words[i + 2]!]) + } + expect(words.slice(3 * held.length + 1)).toEqual(['-c', 'echo started', '']) + return held.sort() +} + +describe.if(isLinux)('what the seccomp helper is asked to hold', () => { + it('each link once, with what it says; none for a path with no link on the way, or that lies where the command cannot write', async () => { + fs.symlinkSync(join(ROOT, 'conf', 'x'), join(OUTSIDE, 'in')) + const none = [...inRoot(['beside', 'absent']), join(OUTSIDE, 'in')] + expect(await heldBy([...DENIED, ...none])).toEqual( + Object.entries(LINKS).sort(), + ) + }) + + it('whatever characters the name and the target hold, U+FFFD too, with no word on a target that is no text', async () => { + const odd = ["sp ace'quote", 'new\nline', '$(echo x)`y`', 'x�y'] + for (const name of odd) fs.symlinkSync(`to ${name}`, join(ROOT, name)) + fs.symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'bytes')) + expect(await heldBy(inRoot([...odd, 'bytes']))).toEqual( + [...odd.map(name => [name, `to ${name}`]), ['bytes', '']].sort(), + ) + }) + + it('for a deny written from the home or the working directory, and a name denied in every one', async () => { + fs.symlinkSync('conf/x', join(ROOT, '.bashrc')) + process.chdir(ROOT) + const fromHome = `~/${relative(homedir(), join(ROOT, 'way', 'x'))}` + expect(await heldBy(['name', fromHome])).toEqual([ + ['.bashrc', 'conf/x'], + ...said(['name', 'way']), + ]) + }) + + it('under a write root that is the root, but none in the directories the sandbox mounts anew', async () => { + const denyWithinAllow = ['/proc/self/cwd/file', ...inRoot(['name', 'out'])] + expect( + await heldBy([], { writeConfig: { allowOnly: ['/'], denyWithinAllow } }), + ).toEqual(said(['name', 'out'])) + }) + + it('none in a directory that a read deny hides, though the link is bound back by itself', async () => { + fs.mkdirSync(join(HOLDER, 'inner')) + fs.symlinkSync('inner', join(HOLDER, 'to-inner')) + const denied = inRoot(['holder/to-inner/file', 'holder/commands', 'name']) + const allowWithinDeny = [join(HOLDER, 'to-inner')] + const hiding = { readConfig: { denyOnly: [HOLDER], allowWithinDeny } } + expect(await heldBy(denied, hiding)).toEqual(said(['name'])) + if (namespaces) { + expect(run(await wrapLinux(denied, hiding)).stdout).toBe('started\n') + } + }) + + // In place of the paths in it. + it.if(modesCount)('denies a directory it cannot look in whole', async () => { + fs.chmodSync(HOLDER, 0) + const { result: wrapped, warnings } = await withCapturedWarnings(() => + wrapLinux(inRoot(['holder/commands', 'holder/other'])), + ) + // Beneath the write root's bind it is a pin, after it a deny. + expect( + wrapped.lastIndexOf(`--ro-bind ${HOLDER} ${HOLDER} `), + ).toBeGreaterThan(wrapped.indexOf(`--bind ${ROOT} ${ROOT} `)) + expect(wrapped).not.toContain(`${HOLDER}/`) + expect(warnings.join()).toContain(`denying ${HOLDER} whole`) + }) + + it('the links on the way to a read deny, one that leads to nothing yet too, to a match of a read-deny pattern and to a masked file', async () => { + const [realPath, ...denyOnly] = inRoot(['way/secret', 'first', 'dangling']) + expect( + await heldBy([], { + readConfig: { denyOnly, matchedLinks: inRoot(['name']) }, + maskedFileBinds: [{ realPath: realPath!, fakePath: '/fakes/0' }], + }), + ).toEqual(said(['dangling', 'first', 'middle', 'name', 'way'])) + }) + + it('a read deny that is where it resolves to is not walked', async () => { + const { calls } = spyOn(fs, 'lstatSync').mock + await wrapLinux([], { readConfig: { denyOnly: inRoot(['conf/x']) } }) + expect(calls.filter(([p]) => String(p).startsWith(ROOT))).toEqual([]) + }) + + it('a refusal for length says how much of it names links', async () => { + const refusal = (denied: string[]): Promise => + wrapLinux(denied, { command: 'a'.repeat(2 ** 21) }).catch(String) + const words = `--hold-link ${ROOT}/name conf/x` + expect(await refusal(inRoot(['name']))).toContain( + `At least ${words.length} of them are not the caller's`, + ) + expect(await refusal([])).toMatch(/the limit here is \d+\)$/) + }) +}) + +type Config = Parameters[0] + +/** ROOT for the one write root, and `first` denied in it. */ +const config = (denyRead: string[], rest: Partial = {}): Config => ({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead, allowWrite: [ROOT], denyWrite: inRoot(['first']) }, + ...rest, +}) + +describe.if(isLinux)('where no link is held', () => { + const [NEWER, OLDER] = [join(OUTSIDE, 'newer'), join(OUTSIDE, 'older')] + const listed = SandboxManager.getLinuxUnheldLinks + type Reason = LinuxUnheldLink['reason'] + const unheld = (reason: Reason, ...names: string[]): LinuxUnheldLink[] => + inRoot(names).map(path => ({ path, reason })) + + // The reason if any, `seccomp`, and the helper the library finds for itself. + // The links are named, what holds one is not pinned for it either, and no + // helper is run to find out. + it.each<[Reason | '', Options['seccompConfig'], string]>([ + ['no_helper', {}, ''], + ['told_no', { holdsLinks: false }, ''], + ['told_no', { applyPath: NEWER, holdsLinks: false }, ''], + ['not_told', { applyPath: NEWER }, ''], + // What the library finds where nothing is at the path says nothing of it. + ['not_told', { applyPath: '/in/the/sandbox', argv0: 'apply-seccomp' }, ''], + ['', { applyPath: OLDER, holdsLinks: true }, ''], + ['', {}, NEWER], + ['older_helper', {}, OLDER], + ['no_answer', {}, OUTSIDE], + ])('"%s", told %j and finding "%s"', async (reason, seccompConfig, own) => { + // One names the option that says so, one runs its words as older builds do. + program('newer', `echo run > ${OUTSIDE}/run # --holds-links`) + program('older', `echo run > ${OUTSIDE}/run; exec "$@"`) + if (own) spyOn(seccomp, 'getApplySeccompBinaryPath').mockReturnValue(own) + const allowAllUnixSockets = reason === 'no_helper' + const { result: wrapped, warnings } = await withCapturedWarnings(() => + wrapLinux(inRoot(['far']), { seccompConfig, allowAllUnixSockets }), + ) + + for (const words of ['--hold-link', `--ro-bind ${HOLDER} ${HOLDER} `]) { + expect(wrapped.includes(words)).toBe(reason === '') + } + const links = reason ? unheld(reason, 'far', 'holder/blank') : [] + expect(listed()).toEqual(links) + for (const { path } of links) { + expect( + warnings.some(l => l.includes(`${path},`) && l.includes(reason)), + ).toBe(true) + } + expect(fs.existsSync(join(OUTSIDE, 'run'))).toBe(false) + if (namespaces && !seccompConfig?.applyPath && !own) { + expect(run(wrapped).stdout).toBe('started\n') + } + }) + + it.each<[string, () => void | Promise]>([ + ['reset()', () => SandboxManager.reset()], + ['updateConfig()', () => SandboxManager.updateConfig(config([]))], + ])('listed by the latest wrap to meet it, until %s', async (_what, act) => { + await wrapLinux(inRoot(['first']), { allowAllUnixSockets: true }) + await wrapLinux(inRoot(['first']), { seccompConfig: { holdsLinks: false } }) + expect(await wrapLinux(inRoot(['middle']))).toContain('--hold-link') + expect(listed()).toEqual(unheld('told_no', 'first')) + await act() + expect(listed()).toEqual([]) + }) + + it('a wrap that throws names none', async () => { + const throwing = { allowAllUnixSockets: true, binShell: 'no-such-shell' } + const refusal = await wrapLinux(inRoot(['first']), throwing).catch(String) + expect(refusal).toContain('no-such-shell') + expect(listed()).toEqual([]) + }) + + it('what the way goes up from is pinned all the same where the command can replace no link on it', async () => { + fs.symlinkSync(`${ROOT}/${LINKS.up}`, join(OUTSIDE, 'up')) + const wrapped = await wrapLinux([join(OUTSIDE, 'up')], { + seccompConfig: { holdsLinks: false }, + }) + for (const dir of inRoot(['in', 'in/to'])) { + expect(wrapped).toContain(`--ro-bind ${dir} ${dir} `) + } + expect(listed()).toEqual([]) + }) + + it('the manager takes the word on the helper from the configuration, and names the links its patterns match', async () => { + await SandboxManager.initialize( + SandboxRuntimeConfigSchema.parse( + config(inRoot(['nam*', 'besid*']), { seccomp: { holdsLinks: false } }), + ), + ) + expect(SandboxManager.getFsReadConfig().matchedLinks).toEqual( + inRoot(['name']), + ) + await SandboxManager.wrapWithSandbox('true') + expect(listed()).toEqual(unheld('told_no', 'first', 'middle', 'name')) + }) +}) + +describe.if(namespaces)('the seccomp helper', () => { + const helper = (): string => seccomp.getApplySeccompBinaryPath()! + /** For a shell in ROOT. With no word on what the first of them says. */ + const HOLDS = + '--hold-link name "" --hold-link way real --hold-link dangling later' + /** Lifts the helper's namespaces filter, which refuses every mount call. */ + const NAMESPACES_ALLOWED = 'SRT_ALLOW_NESTED_USERNS=1' + const ELSEWHERE = 'leads elsewhere than it did' + + it('says that it holds links', () => { + expect(run(`${helper()} --holds-links`).status).toBe(0) + }) + + it.each([ + ['a file is at the name', 'beside', '""', 'not a symbolic link'], + ['nothing is at the name', 'absent', '""', 'No such file or directory'], + ['the link says more', 'name', 'conf', ELSEWHERE], + ['the link says less', 'name', 'conf/xy', ELSEWHERE], + ['the link says another thing', 'name', 'conf/y', ELSEWHERE], + ])('runs no command where %s, and says why', (_what, name, target, why) => { + expect( + run(`${helper()} --hold-link ${name} ${target} echo ran 2>&1`, ROOT), + ).toMatchObject({ + status: 1, + stdout: `apply-seccomp: --hold-link ${name}: ${why} (seccomp.holdsLinks: false in the sandbox configuration holds no link)\n`, + }) + }) + + // It has every capability in its namespace. The filter refuses the unmount; + // where it is lifted, the lock does. + it.each([ + ['', 'EPERM'], + [NAMESPACES_ALLOWED, 'EINVAL'], + ])('%s a command of uid 0 cannot unmount the hold', (env, errno) => { + // umount2() with UMOUNT_NOFOLLOW, which reaches a mount on a link. + const unmount = + 'import ctypes, errno; ctypes.CDLL(None, use_errno=True).umount2(b"name", 8); print(errno.errorcode[ctypes.get_errno()])' + const { stdout } = run( + `${env} unshare -Ur ${helper()} ${HOLDS} sh -c 'python3 -c "$0"; rm name 2>&1' '${unmount}'`, + ROOT, + ) + expect(stdout).toStartWith(`${errno}\n`) + expect(stdout).toMatch(BUSY) + }) + + it('a hold keeps the flags of the file system that holds the link', () => { + const { stdout } = run( + `unshare -Urm sh -c 'mount -t tmpfs -o noexec,noatime,nosymfollow none ${OUTSIDE} && ` + + `cd ${OUTSIDE} && echo read > x && ln -s x link && ` + + `exec ${helper()} --hold-link link x sh -c "rm link; cat link"' 2>&1`, + ) + expect(stdout).toMatch(BUSY) + expect(stdout).toContain('Too many levels of symbolic links') + expect(stdout).not.toContain('read') + }) + + it.each(['--ro-bind', '--bind', '--dev-bind'])( + 'a bubblewrap the command may start, with %s / /, starts, and the hold holds there', + bind => { + const { stdout } = run( + `${NAMESPACES_ALLOWED} ${helper()} ${HOLDS} bwrap ${bind} / / --bind ${ROOT} ${ROOT} --dev /dev sh -c 'echo started; rm name way dangling' 2>&1`, + ROOT, + ) + expect(stdout).toContain('started') + expect(stdout.match(new RegExp(BUSY, 'gi'))).toHaveLength(3) + }, + ) + + it('a link that says another thing by the time the command runs: nothing runs', async () => { + const wrapped = await wrapLinux(inRoot(['name'])) + fs.rmSync(join(ROOT, 'name')) + fs.symlinkSync('conf/z', join(ROOT, 'name')) + expect(run(wrapped)).toMatchObject({ + stdout: '', + stderr: expect.stringContaining(`--hold-link ${ROOT}/name: ${ELSEWHERE}`), + }) + }) +}) + +// The name stays, and what it leads to stays hidden. +describe.if(namespaces)('a read deny on a link, over two commands', () => { + it.each<[string, string, string[], Array<'deny' | 'mask'>]>([ + ['denyRead', 'file', [''], []], + ['denyRead', 'dir', [''], []], + ['a denyRead pattern', 'file', ['*'], []], + ['a credential deny', 'file', [], ['deny']], + ['a credential mask', 'file', [], ['mask']], + ])('%s on a link to a %s', async (_kind, leadsTo, endings, modes) => { + const [name, target] = [join(ROOT, 'linked'), join(OUTSIDE, leadsTo)] + fs.mkdirSync(join(OUTSIDE, 'dir')) + fs.writeFileSync(join(OUTSIDE, 'file'), 'secret') + fs.writeFileSync(join(OUTSIDE, 'dir', 'file'), 'secret') + fs.symlinkSync(target, name) + const files = modes.map(mode => ({ path: name, mode })) + const denied = endings.map(ending => name + ending) + await SandboxManager.initialize(config(denied, { credentials: { files } })) + const saidBy = async (command: string): Promise => { + const { stdout } = run( + await SandboxManager.wrapWithSandbox(`echo ran; ${command}`), + ) + SandboxManager.cleanupAfterCommand() + expect(stdout).toStartWith('ran\n') + return stdout + } + + await saidBy(`rm ${name}; mv ${name} ${name}-aside; echo x > ${name}`) + expect(fs.readlinkSync(name)).toBe(target) + expect(await saidBy(`cat ${target} ${target}/file`)).not.toContain('secret') + }) +}) diff --git a/vendor/seccomp-src/apply-seccomp.c b/vendor/seccomp-src/apply-seccomp.c index c7c020bc6..39530aab0 100644 --- a/vendor/seccomp-src/apply-seccomp.c +++ b/vendor/seccomp-src/apply-seccomp.c @@ -1,7 +1,8 @@ /* * apply-seccomp.c - Apply seccomp BPF filter in an isolated PID namespace * - * Usage: apply-seccomp [args...] + * Usage: apply-seccomp [--hold-link ]... [args...] + * apply-seccomp --holds-links * * This program applies a baked-in seccomp BPF filter, isolates the * target command in a nested user+PID+mount namespace so it cannot see or @@ -38,6 +39,10 @@ * filter. SRT_ALLOW_NESTED_USERNS=1, read before the command exists and * removed from its environment, turns both off. * + * --hold-link keeps a symbolic link where it is: see hold_link(). The second + * form exits 0 and does nothing else. A build that lacks the option takes the + * word for a command, finds none, and exits 1. + * * Compile: gcc -static -O2 -o apply-seccomp apply-seccomp.c */ @@ -57,6 +62,8 @@ #include #include #include +#include +#include #include #include #include @@ -83,6 +90,13 @@ #define SECCOMP_MODE_FILTER 2 #endif +#ifndef MS_NOSYMFOLLOW +#define MS_NOSYMFOLLOW 256 +#endif +#ifndef ST_NOSYMFOLLOW +#define ST_NOSYMFOLLOW 0x2000 +#endif + #ifndef SECCOMP_FILTER_FLAG_NEW_LISTENER #define SECCOMP_FILTER_FLAG_NEW_LISTENER (1UL << 3) #endif @@ -670,13 +684,134 @@ static int reap_until(pid_t main_child) { } } +/* A new user namespace, with this process's uid and gid mapped to themselves + * and every capability in it. */ +static void enter_user_namespace(void) { + uid_t uid = geteuid(); + gid_t gid = getegid(); + + /* If this binary was exec'd without read permission (e.g. installed + * mode 0111), the kernel marked the process non-dumpable, which + * makes /proc/self/{setgroups,uid_map,gid_map} root-owned, so the + * writes below would fail with EACCES. Temporarily flip dumpable + * on for the uid/gid mapping; it is cleared right after. While + * dumpable is 1, a same-uid process can ptrace us (under yama + * ptrace_scope=0) and dump the mapped pages that mode 0111 is + * meant to hide; that exposure is kept to a + * few-syscall race window — the same unavoidable window runc and + * systemd accept for this pattern. + * + * A prctl failure here is ignored: it is next to impossible for + * this call, and if raising dumpable did fail the map writes + * below fail with their own clearer errors. */ + (void)prctl(PR_SET_DUMPABLE, 1); + + if (unshare(CLONE_NEWUSER) < 0) { + die("apply-seccomp: unshare(CLONE_NEWUSER)"); + } + if (write_file("/proc/self/setgroups", "deny") < 0) { + die("apply-seccomp: write /proc/self/setgroups " + "(nested userns is capability-restricted; " + "caller must provide CAP_SYS_ADMIN)"); + } + if (write_file("/proc/self/uid_map", "%u %u 1\n", uid, uid) < 0) { + die("apply-seccomp: write /proc/self/uid_map"); + } + if (write_file("/proc/self/gid_map", "%u %u 1\n", gid, gid) < 0) { + die("apply-seccomp: write /proc/self/gid_map"); + } + /* Zero whatever the flag was on entry, as before the fork in main(): the + * links are held between two calls of this, which is no few syscalls. */ + if (prctl(PR_SET_DUMPABLE, 0) < 0) { + die("apply-seccomp: prctl(PR_SET_DUMPABLE)"); + } +} + +static void die_holding(const char *path, const char *why) { + fprintf(stderr, + "apply-seccomp: --hold-link %s: %s (seccomp.holdsLinks: false in the " + "sandbox configuration holds no link)\n", + path, why); + _exit(1); +} + +/* + * Mount the symbolic link at `path` on itself, read-only. unlink and rename + * refuse a name that is a mount point in the caller's mount namespace (EBUSY), + * and the directory that holds it stays as writable as it was. The link reads + * and leads as before. + * + * `target` is what the link said when the denies were placed by it, or "" for + * no word on that. One that says something else by now leads past them. + * + * mount(2) follows a link at its target, so the link is named through a + * descriptor of itself: the kernel does not follow what /proc/self/fd/N leads + * to any further. What is looked at and what is mounted are one descriptor. + */ +static void hold_link(const char *path, const char *target) { + char self[32]; + char says[PATH_MAX]; + struct stat st; + struct statvfs fs; + + int fd = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0 || fstat(fd, &st) < 0) { + die_holding(path, strerror(errno)); + } + if (!S_ISLNK(st.st_mode)) { + die_holding(path, "not a symbolic link"); + } + ssize_t length = readlinkat(fd, "", says, sizeof(says)); + if (length < 0) { + die_holding(path, strerror(errno)); + } + if (*target && ((size_t)length != strlen(target) || + memcmp(says, target, (size_t)length) != 0)) { + die_holding(path, "leads elsewhere than it did"); + } + snprintf(self, sizeof(self), "/proc/self/fd/%d", fd); + if (mount(self, self, NULL, MS_BIND, NULL) < 0) { + die_holding(path, strerror(errno)); + } + close(fd); + + /* Read-only, nosuid and nodev are all the flags bubblewrap ever adds. One + * that the command starts applies its flags to every mount it finds, by + * path, which for this mount leads elsewhere; it leaves a mount that has + * them already alone. The flags the mount came with are given again: + * noexec is locked, and without nosymfollow the link would lead where it + * did not. A remount keeps the access-time flags by itself. Opened anew: + * the first descriptor is of what now lies beneath the mount. */ + fd = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0 || fstatvfs(fd, &fs) < 0) { + die_holding(path, strerror(errno)); + } + unsigned long flags = MS_RDONLY | MS_NOSUID | MS_NODEV; + if (fs.f_flag & ST_NOEXEC) flags |= MS_NOEXEC; + if (fs.f_flag & ST_NOSYMFOLLOW) flags |= MS_NOSYMFOLLOW; + snprintf(self, sizeof(self), "/proc/self/fd/%d", fd); + if (mount(NULL, self, NULL, MS_REMOUNT | MS_BIND | flags, NULL) < 0) { + die_holding(path, strerror(errno)); + } + close(fd); +} + int main(int argc, char *argv[]) { - if (argc < 2) { - fprintf(stderr, "Usage: %s [args...]\n", argv[0]); + if (argc == 2 && strcmp(argv[1], "--holds-links") == 0) { + return 0; + } + int links = 0; + while (3 * links + 3 < argc && strcmp(argv[3 * links + 1], "--hold-link") == 0) { + links++; + } + if (argc < 3 * links + 2) { + fprintf(stderr, + "Usage: %s [--hold-link ]... [args...]\n", + argv[0]); return 1; } - char **command_argv = &argv[1]; + char **command_argv = &argv[3 * links + 1]; /* Read once, from the environment the caller built; the worker's copy is * removed below. */ @@ -719,7 +854,8 @@ int main(int argc, char *argv[]) { * map uid/gid, then unshare. This also works when apply-seccomp is * run standalone outside bwrap. * - * Path (a) is tried first. If we don't have the cap, the + * Path (a) is tried first, unless there are links to hold, which takes + * (b): see the invariant there. If we don't have the cap, the * kernel returns EPERM and we fall through to (b). Path (b) can itself * fail on hosts where unprivileged user namespaces are gated by an LSM * (Ubuntu 24.04's AppArmor restriction, for example) — the unshare @@ -734,44 +870,30 @@ int main(int argc, char *argv[]) { ? open("/proc", O_PATH | O_DIRECTORY | O_CLOEXEC) : -1; - if (unshare(CLONE_NEWPID | CLONE_NEWNS) < 0) { - if (errno != EPERM) { + if (links > 0 || unshare(CLONE_NEWPID | CLONE_NEWNS) < 0) { + if (links == 0 && errno != EPERM) { die("apply-seccomp: unshare(CLONE_NEWPID|CLONE_NEWNS)"); } - uid_t uid = geteuid(); - gid_t gid = getegid(); - - /* If this binary was exec'd without read permission (e.g. installed - * mode 0111), the kernel marked the process non-dumpable, which - * makes /proc/self/{setgroups,uid_map,gid_map} root-owned, so the - * writes below would fail with EACCES. Temporarily flip dumpable - * on for the uid/gid mapping; it is cleared before the fork. While - * dumpable is 1, a same-uid process can ptrace us (under yama - * ptrace_scope=0) and dump the mapped pages that mode 0111 is - * meant to hide; that exposure is kept to a - * few-syscall race window — the same unavoidable window runc and - * systemd accept for this pattern. - * - * A prctl failure here is ignored: it is next to impossible for - * this call, and if raising dumpable did fail the map writes - * below fail with their own clearer errors. */ - (void)prctl(PR_SET_DUMPABLE, 1); - - if (unshare(CLONE_NEWUSER) < 0) { - die("apply-seccomp: unshare(CLONE_NEWUSER)"); - } + enter_user_namespace(); made_userns = 1; - if (write_file("/proc/self/setgroups", "deny") < 0) { - die("apply-seccomp: write /proc/self/setgroups " - "(nested userns is capability-restricted; " - "caller must provide CAP_SYS_ADMIN)"); - } - if (write_file("/proc/self/uid_map", "%u %u 1\n", uid, uid) < 0) { - die("apply-seccomp: write /proc/self/uid_map"); - } - if (write_file("/proc/self/gid_map", "%u %u 1\n", gid, gid) < 0) { - die("apply-seccomp: write /proc/self/gid_map"); + if (links > 0) { + /* INVARIANT: a user namespace is entered after the last hold. The + * mount namespace made below is then a copy made across that + * boundary, in which every mount is locked: it cannot be + * unmounted, which is all a lock refuses. A command started by uid + * 0 holds all capabilities in its user namespace, and could + * unmount what was mounted in a mount namespace of that one. */ + if (unshare(CLONE_NEWNS) < 0) { + die("apply-seccomp: unshare(CLONE_NEWNS)"); + } + if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) < 0) { + die("apply-seccomp: mount(MS_PRIVATE)"); + } + for (int i = 0; i < links; i++) { + hold_link(argv[3 * i + 2], argv[3 * i + 3]); + } + enter_user_namespace(); } if (unshare(CLONE_NEWPID | CLONE_NEWNS) < 0) { die("apply-seccomp: unshare(CLONE_NEWPID|CLONE_NEWNS) after userns");