Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ Uses two different patterns:

- `filesystem.denyRead` - Array of paths to deny read access. Empty array = full read access.
- `filesystem.allowRead` - Array of paths to re-allow read access within denied regions (takes precedence over denyRead). **Note:** this is the opposite of write, where `denyWrite` takes precedence over `allowWrite`.
- `filesystem.denyReadGlobBudget` - Optional, Linux only: `{ "maxEntries": 20000000, "timeoutMs": 60000 }` (the defaults; positive integers, either or both). What expanding the `denyRead` globs of one configuration may spend, in directory entries looked at and in milliseconds, before the wrap is refused; see "Path Syntax (Linux)".

**Write restrictions** (allow-only pattern) - all writes denied by default:

Expand Down Expand Up @@ -440,6 +441,7 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS:
- A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is an EMPTY WRITABLE directory, so a command that used to write through a read-denied `build/` still writes, into the tmpfs, and loses that output when the command exits. A file added to the directory on the host afterwards is hidden too. A matched directory that is empty when the command is wrapped gets no mount (a matched symlink to a directory always gets one, on the directory it leads to). An `allowRead` beneath a mounted directory is bound back over the tmpfs, but each entry beneath it that the pattern matches keeps its own mask: under a `/**` pattern that is every entry there, so only what is created beneath the `allowRead` later is readable.
- A directory the expansion cannot list is denied as a whole, and nothing is bound back beneath the mount that hides it, `allowRead` and `allowWrite` paths included: what the pattern matches under them cannot be found. A `denyRead` entry that cannot be inspected (its parent directory is readable but not searchable, say), or that leads to `/`, hides the nearest directory above it instead, in the same way.
- Symlinked directories are descended. A directory is listed once for each way the pattern can carry on beneath it, however many links lead to it, so the cost of the expansion follows the size of the tree and the length of the pattern, not the number or length of the names its links offer; what is found through a link is reported, and denied, where it really is. A link that leads back up the tree (to the directory holding it or above, or to the pattern's starting directory or above) is not descended. A `**` written against other text (`**.pem`, `a**/x`) and a bracket expression that can match `/` span directories as they do on macOS, through symlinked directories too. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself.
- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 20,000,000 directory entries looked at and 60 seconds, unless `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) sets another, in the initialized configuration, through `updateConfig()`, or in the `customConfig` of one wrap. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `wrapWithSandboxArgv()` rejects the same way, and `getFsReadConfig()` throws the same error. `initialize()` and `updateConfig()` expand nothing, so they accept a configuration that is past the budget, and nothing is kept between wraps: every wrap of it is refused, each after spending the budget again. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, remove or move what it walks into, or raise the budget. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount), the resolution of a link that leads to one, or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The patterns share one deadline, so what is done with one pattern's matches after its walk is time taken from the patterns still to come; only what follows the last walk is not counted. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed and the entries looked at.
- An `allowRead` or `allowWrite` path is bound back over a denied directory only where it really is, so no directory shows under a second name inside the sandbox.
- `denyRead: ["/"]` denies each directory in `/` (`/proc`, `/dev` and `/sys` aside); a symlink there (`/bin`, `/lib` on a usr-merged system) gets no mount of its own, because what it leads to is denied together with the directory that holds it.

Expand Down Expand Up @@ -745,7 +747,7 @@ Filesystem restrictions are enforced at the OS level:
- 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.
- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points.
- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and `deny_glob_too_large`, which says the `denyRead` patterns could not be expanded within their budget (see "Path Syntax (Linux)"). A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points.

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

Expand Down
9 changes: 8 additions & 1 deletion src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,11 +750,18 @@ export type LinuxSandboxProfileErrorCode =
| 'args_file_unavailable'
/** The line does not fit one shell argument even with the mounts in a file. */
| 'command_too_long'
/**
* The `denyRead` patterns could not be expanded within their budget, so
* which paths to hide is not known. The error on `.cause` names the
* pattern, the directory being listed and what had been spent.
*/
| 'deny_glob_too_large'

/**
* Thrown when a Linux bubblewrap profile cannot be run on this host: what the
* configuration expands to is past a limit, or, for `command_too_long` and
* `nul_in_path`, what the caller passed in is. The command was not run and no
* `nul_in_path`, what the caller passed in is; for `deny_glob_too_large` the
* profile could not be worked out at all. The command was not run and no
* profile file stays open, so do not run the per-command cleanup
* (`cleanupAfterCommand()`, `cleanupBwrapMountPoints()`) for a wrap that
* threw: it would release a second time, and a sandbox still running would
Expand Down
13 changes: 12 additions & 1 deletion src/sandbox/read-deny-glob.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { logForDebugging } from '../utils/debug.js'
import {
type GlobWalkBudget,
isAtOrUnder,
normalizePathForSandbox,
pathSpellings,
Expand Down Expand Up @@ -65,20 +66,27 @@ function collapseReadDenyLocations({
* could not list is denied whole. Sorted, so an ancestor precedes its
* descendants.
*
* Throws {@link GlobWalkBudgetError} when `budget` runs out. There is no
* shorter list to fall back on: the caller must not run the command.
*
* @param unlistableDirs - receives the returned locations that hide something
* the walk could not enumerate, whether by being that directory or by
* covering it. The Linux wrapper binds nothing back beneath one: what the
* pattern matches under an allowed path in there was never found, and would
* come back unmasked.
* @param opts.budget - shared with every expansion handed the same object.
*/
export function expandReadDenyGlobLinux(
globPattern: string,
reExposedPaths: readonly string[],
unlistableDirs?: Set<string>,
opts: { budget?: GlobWalkBudget } = {},
): string[] {
const startedAt = performance.now()
const walk = walkGlobPattern(globPattern, {
withDirectoryForm: true,
followSymlinkedDirectories: true,
budget: opts.budget,
})
// Where a path the walk reported really lives: the denyRead loop mounts an
// entry there, whatever spelling named it.
Expand Down Expand Up @@ -181,8 +189,11 @@ export function expandReadDenyGlobLinux(
}
}

// One line per expansion: a caller that times its wraps reads the cost here.
logForDebugging(
`[Sandbox Linux] Expanded denyRead glob "${globPattern}": ${walk.matches.length} matches -> ${mounts.size} mounts`,
`[Sandbox Linux] Expanded denyRead glob "${globPattern}" in ${Math.round(performance.now() - startedAt)} ms: ` +
`${walk.matches.length} matches -> ${mounts.size} mounts; ` +
`directories listed: ${walk.directoriesListed}, entries looked at: ${walk.entriesExamined}`,
)
for (const mount of mounts) {
// A matched link decides what is hidden for the whole sandbox: a
Expand Down
12 changes: 12 additions & 0 deletions src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,18 @@ export const FilesystemConfigSchema = z.object({
'Paths to re-allow reading within denied regions (takes precedence over denyRead). ' +
'Use with denyRead to deny a broad region then allow back specific subdirectories.',
),
denyReadGlobBudget: z
.object({
maxEntries: z.number().int().positive().optional(),
timeoutMs: z.number().int().positive().optional(),
})
.strict()
.optional()
.describe(
'Linux: what expanding the denyRead globs of one configuration may spend, all of them together, ' +
'before the wrap is refused: directory entries looked at (default 20,000,000) and ' +
'milliseconds (default 60,000).',
),
allowWrite: z
.array(filesystemPathSchema)
.describe('Paths allowed for writing'),
Expand Down
57 changes: 52 additions & 5 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
type SandboxDependencyCheck,
cleanupBwrapMountPoints,
linuxGetCwdMandatoryDenyPaths,
LinuxSandboxProfileError,
} from './linux-sandbox-utils.js'
import { expandReadDenyGlobLinux } from './read-deny-glob.js'
import {
Expand Down Expand Up @@ -85,6 +86,8 @@ import {
normalizePathForSandbox,
removeTrailingGlobSuffix,
expandGlobPattern,
GlobWalkBudgetError,
newGlobWalkBudget,
attributionKeyFor,
decodeSandboxedCommand,
encodeSandboxedCommand,
Expand Down Expand Up @@ -1305,12 +1308,47 @@ function expandAllowReadGlob(pattern: string): string[] {
return expanded
}

/**
* The expansion of every denyRead glob of one read configuration, on Linux,
* all on one budget of `limits` (`filesystem.denyReadGlobBudget`). When it
* runs out the returned function throws {@link LinuxSandboxProfileError}
* `deny_glob_too_large`.
*/
function readDenyGlobExpander(
reExposedPaths: readonly string[],
unlistableDenyDirs: Set<string>,
limits: SandboxRuntimeConfig['filesystem']['denyReadGlobBudget'],
): (pattern: string) => string[] {
const budget = newGlobWalkBudget(limits)
return pattern => {
try {
return expandReadDenyGlobLinux(
pattern,
reExposedPaths,
unlistableDenyDirs,
{ budget },
)
} catch (error) {
if (!(error instanceof GlobWalkBudgetError)) throw error
throw new LinuxSandboxProfileError(
'deny_glob_too_large',
`denyRead pattern "${pattern}" could not be expanded: the denyRead patterns of one configuration share ` +
`${error.maxEntries} directory entries and ${error.timeoutMs} ms, and the ${error.exhausted} ran out ` +
`with ${error.entries} entries looked at after ${error.elapsedMs} ms, while listing ${error.directory}; ` +
`narrow the pattern, remove or move what it walks into, or raise filesystem.denyReadGlobBudget`,
error,
)
}
}
}

/**
* The read policy of the initialized config, for inspection and display.
* On Linux, denyRead globs are collapsed to covering directory mounts against
* this config's allowRead and {@link getFsWriteConfig}'s allowOnly, so
* `denyOnly` is only sound alongside that write config and must not be handed
* to wrapCommandWithSandboxLinux with a different one.
* to wrapCommandWithSandboxLinux with a different one. Throws
* {@link LinuxSandboxProfileError} `deny_glob_too_large` as the wrap does.
*/
function getFsReadConfig(): FsReadRestrictionConfig {
if (!config || config.filesystem.disabled) {
Expand All @@ -1333,8 +1371,11 @@ function getFsReadConfig(): FsReadRestrictionConfig {
const unlistableDenyDirs = new Set<string>()
const denyPaths = resolveReadPathEntries(
unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions),
pattern =>
expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs),
readDenyGlobExpander(
reExposedPaths,
unlistableDenyDirs,
config.filesystem.denyReadGlobBudget,
),
credentialRestrictions.degradeToDenyPaths,
)

Expand Down Expand Up @@ -1722,8 +1763,12 @@ async function wrapWithSandbox(
customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [],
credentialRestrictions,
),
pattern =>
expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs),
readDenyGlobExpander(
reExposedPaths,
unlistableDenyDirs,
customConfig?.filesystem?.denyReadGlobBudget ??
config?.filesystem.denyReadGlobBudget,
),
credentialRestrictions.degradeToDenyPaths,
)
readConfig = {
Expand Down Expand Up @@ -2431,6 +2476,8 @@ export interface ISandboxManager {
command: string
args?: string[]
}): Promise<SandboxDependencyCheck>
/** On Linux it expands the `denyRead` globs, and throws
* {@link LinuxSandboxProfileError} `deny_glob_too_large` as the wrap does. */
getFsReadConfig(): FsReadRestrictionConfig
getFsWriteConfig(): FsWriteRestrictionConfig
getNetworkRestrictionConfig(): NetworkRestrictionConfig
Expand Down
Loading
Loading