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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,9 @@ Running under a distinct user SID structurally closes the surrogate-spawn class
- `filesystem.allowWrite` → an inheriting `MODIFY` ALLOW ACE (`READ|WRITE|EXECUTE|DELETE`, with `FILE_DELETE_CHILD` withheld). The sandboxed process can create, modify, and delete files inside the working tree; withholding `FILE_DELETE_CHILD` from the grant is defense-in-depth for the deny stamps below, not a guard on the tree root.
- `filesystem.allowRead` → an inheriting `READ|EXECUTE` ALLOW ACE
- `filesystem.denyRead` / `filesystem.denyWrite` → an inheriting DENY ACE on the target, plus an inheriting `FILE_DELETE_CHILD` DENY on its parent — together with the withheld `FILE_DELETE_CHILD` on the working-tree grant, this stops the sandboxed process from renaming or deleting a denied path via its parent directory
- `credentials.files` → the DENY of `denyRead`, for `mode: "deny"` and `mode: "mask"` alike: an ACL cannot redirect a read, so a masked file is unreadable inside the sandbox, as on macOS

The lists rank as on the other platforms. The ACEs set on an object come before the ones it inherits, so an `allowRead` beneath a `denyRead` directory is readable. An `allowWrite` at or beneath a `denyWrite` path grants `READ|EXECUTE` only, and an `allowRead` or `allowWrite` at or beneath a `credentials.files` entry grants nothing. "Beneath" is decided on the paths as they resolve on disk: links, junctions, 8.3 names and letter case make no difference (a UNC path is compared as spelled, whatever its case). A deny that one command brings in its `customConfig` ranks the same way against the session's grants.

`reset()` removes every ACE this session added (refcounted across this user's concurrent hosts via the per-user session DB; a crash-recovery pass on the next `initialize()` cleans up after an unclean exit). Directory targets are supported (the ACEs inherit to the whole subtree). Glob patterns are expanded to concrete paths at `initialize()` time — a matching path that appears later is not covered. In an entry without `*` or `?`, `[` and `]` are characters of the name. In a pattern they are read as a character class, as on the other platforms, and the pattern is also expanded beneath a folder that has them in its name.

Expand Down Expand Up @@ -846,7 +849,7 @@ Filesystem restrictions are enforced at the OS level:
- Empty `allowWrite: []` = no write access (nothing allowed)
- `denyWrite` creates exceptions within allowed paths (deny takes precedence)

**Precedence is intentionally opposite for reads vs writes:** `allowRead` overrides `denyRead`, while `denyWrite` overrides `allowWrite`. This lets you carve out readable regions within denied areas, and carve out protected regions within writable areas. On Linux that also holds when the `denyWrite` entry is at or above the `allowWrite` one — `allowWrite: ["/", "/work"]` with `denyWrite: ["/"]` leaves `/work` read-only rather than writable — and with debug logging on (`SRT_DEBUG`) the wrap logs a warning naming both paths.
**Precedence is intentionally opposite for reads vs writes:** `allowRead` overrides `denyRead`, while `denyWrite` overrides `allowWrite`. This lets you carve out readable regions within denied areas, and carve out protected regions within writable areas. On Linux and Windows that also holds when the `denyWrite` entry is at or above the `allowWrite` one — `allowWrite: ["/", "/work"]` with `denyWrite: ["/"]` leaves `/work` read-only rather than writable — and with debug logging on (`SRT_DEBUG`) a warning names both paths.

**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.

Expand Down
3 changes: 2 additions & 1 deletion src/sandbox/credential-mask-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
* {@link extractAndSubstitute} and {@link CredentialFileConfigSchema}.
*
* On macOS, SBPL cannot redirect reads, so masked files degrade to
* `mode: "deny"` (see macos-sandbox-utils.ts).
* `mode: "deny"` (see macos-sandbox-utils.ts). On Windows an ACL cannot
* either (see `windowsMaskedFileDenies` in sandbox-manager.ts).
*/

import { randomUUID } from 'node:crypto'
Expand Down
4 changes: 2 additions & 2 deletions src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ const ParentProxyConfigSchema = z.object({
* - `mask` — the sandboxed process sees a per-session sentinel value; the
* host proxy substitutes sentinel→real on egress to `injectHosts`.
* For files this is whole-file masking (Linux only; degrades to `deny`
* on macOS — see {@link CredentialFileConfigSchema}).
* on macOS and Windows — see {@link CredentialFileConfigSchema}).
*/
const credentialModeSchema = z.enum(['deny', 'mask'])

Expand Down Expand Up @@ -271,7 +271,7 @@ const extractPatternSchema = z.string().superRefine((val, ctx) => {
*
* On macOS, SBPL cannot redirect reads, so `mode: "mask"` (with or without
* `extract`/`decode`) currently degrades to `mode: "deny"` (the file is
* unreadable inside the sandbox).
* unreadable inside the sandbox). Nor can an ACL, so on Windows it does too.
*/
export const CredentialFileConfigSchema = z.object({
path: filesystemPathSchema.describe(
Expand Down
176 changes: 109 additions & 67 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
wrapCommandWithSandboxWindows,
parseWindowsBinShell,
expandWindowsFsPaths,
rankWindowsFsAccess,
stampWindowsAcl,
restoreWindowsAcl,
grantWindowsAcl,
Expand All @@ -92,6 +93,7 @@ import {
WindowsSandboxError,
type SrtWinSpawn,
type WindowsBinShell,
type WindowsFsAccessSet,
DEFAULT_WINDOWS_PROXY_PORT_RANGE,
} from './windows-sandbox-utils.js'
import {
Expand Down Expand Up @@ -182,12 +184,10 @@ let javaAgentJarPath: string | undefined
// the sandbox child env, checked on every CONNECT/request — so a host process
// dialing 127.0.0.1:<proxyPort> can't reach the filter callback.
let proxyAuthToken: string | undefined
// Windows: the resolved access set that was actually applied at
// initialize(). `undefined` means no stamp/grant was applied
// Windows: the config's resolved access set that was actually applied
// at initialize(). `undefined` means no stamp/grant was applied
// (gates running `acl restore`/`acl revoke` at reset()).
let windowsFsStampedSet:
| ReturnType<typeof computeWindowsFsAccessSet>
| undefined
let windowsFsStampedSet: WindowsFsAccessSet | undefined
// The sandbox user SID captured at initialize(). reset() uses this
// so a config change between init and reset can't strand ACEs
// under a different SID.
Expand Down Expand Up @@ -1055,7 +1055,18 @@ async function initializeSteps(
}
// Filesystem grants/denies — additive sandbox-user ACEs.
try {
const acc = computeWindowsFsAccessSet(runtimeConfig)
let maskedFiles: string[] = []
try {
maskedFiles = windowsMaskedFileDenies(
getCredentialRestrictions(
runtimeConfig.credentials,
runtimeConfig.network.allowedDomains,
),
)
} catch {
// onExtractNoMatch: "error" is thrown by every wrap, so nothing runs.
}
const acc = computeWindowsFsAccessSet(runtimeConfig, maskedFiles)
// The trust bundle the CA-trust env vars point at
// (NODE_EXTRA_CA_CERTS etc.) must be readable by the
// srt-sandbox child. It's written into the broker's %TEMP%,
Expand All @@ -1065,10 +1076,11 @@ async function initializeSteps(
// so the (OI)(CI) ACE covers both the file open AND the
// parent-directory list that cmd's `type`/`FindFirstFile`
// does before opening. Mirrors the mac/linux
// `expandedAllowRead` push in wrapWithSandbox.
if (mitmCA) {
acc.grantRead.push(dirname(mitmCA.trustBundlePath))
}
// `ownAllowWithinDeny` in wrapWithSandbox: it is not ranked
// against the config's lists, nor one of the config's grants.
const grantRead = mitmCA
? [...acc.grantRead, dirname(mitmCA.trustBundlePath)]
: acc.grantRead
// `u` was fetched once above for the provisioning gate; the
// same status carries the SID — don't re-spawn `srt-win user
// status` here.
Expand All @@ -1086,10 +1098,10 @@ async function initializeSteps(
// Grant FIRST so the sandbox user has working-tree access by
// the time the deny stamp runs. The two are independent
// refcounted state-DB sets keyed on the same holder PID.
if (acc.grantRead.length > 0 || acc.grantWrite.length > 0) {
if (grantRead.length > 0 || acc.grantWrite.length > 0) {
grantWindowsAcl({
sandboxUserSid: sb,
read: acc.grantRead,
read: grantRead,
write: acc.grantWrite,
srtWin,
})
Expand All @@ -1108,7 +1120,7 @@ async function initializeSteps(
// stampedSet would leave reset()/updateConfig() seeing state
// that never landed.
const anyApplied =
acc.grantRead.length > 0 ||
grantRead.length > 0 ||
acc.grantWrite.length > 0 ||
acc.denyRead.length > 0 ||
acc.denyWrite.length > 0
Expand All @@ -1117,7 +1129,7 @@ async function initializeSteps(
logForDebugging(
`[Sandbox Windows] fs applied: ` +
`${acc.grantWrite.length} grantWrite, ` +
`${acc.grantRead.length} grantRead, ` +
`${grantRead.length} grantRead, ` +
`${acc.denyRead.length} denyRead, ` +
`${acc.denyWrite.length} denyWrite`,
)
Expand Down Expand Up @@ -1684,49 +1696,60 @@ function getFsWriteConfig(): FsWriteRestrictionConfig {
* `READ|EXECUTE` ALLOW ACE, and `denyRead`/`denyWrite` become an
* explicit DENY ACE for `<sb-SID>` on the target plus a
* `(OI)(CI) FILE_DELETE_CHILD` DENY on its parent.
* {@link rankWindowsFsAccess} ranks the lists against each other.
*
* `maskedFiles`: see {@link windowsMaskedFileDenies}.
*/
function computeWindowsFsAccessSet(c: SandboxRuntimeConfig): {
grantRead: string[]
grantWrite: string[]
denyRead: string[]
denyWrite: string[]
} {
function computeWindowsFsAccessSet(
c: SandboxRuntimeConfig,
maskedFiles: readonly string[],
): WindowsFsAccessSet {
const fs = c.filesystem
// filesystem.disabled bypasses ALL filesystem rule generation —
// same as the macOS/Linux wrapWithSandbox path (readConfig /
// writeConfig left undefined). On Windows this means no ACL
// stamp/grant; credential FILE denies are dropped along with the
// rest (credential ENV: mode:'deny' is structural under the
// fresh srt-sandbox env; mode:'mask' sentinels are passed via
// the --env overlay).
// writeConfig left undefined). On Windows this means no grant, and
// no stamp but a masked file's; credential FILE denies are dropped
// along with the rest (credential ENV: mode:'deny' is structural
// under the fresh srt-sandbox env; mode:'mask' sentinels are passed
// via the --env overlay).
if (fs?.disabled) {
return { grantRead: [], grantWrite: [], denyRead: [], denyWrite: [] }
return {
grantRead: [],
grantWrite: [],
denyRead: [...maskedFiles],
denyWrite: [],
}
}
const expand = expandWindowsFsPaths
// `mode: 'deny'` — non-existent literals reach srt-win, which
// creates a placeholder chain and stamps it (deny lands on the
// exact target path). `mode: 'grant'` drops them (a grant on
// nothing is meaningless).
const denyRead = expand(
[
...new Set([
...(fs?.denyRead ?? []),
...getCredentialDenyReadPaths(c.credentials),
]),
return rankWindowsFsAccess({
allowRead: expand(fs?.allowRead ?? [], { mode: 'grant' }),
allowWrite: expand(fs?.allowWrite ?? [], { mode: 'grant' }),
denyRead: expand(fs?.denyRead ?? [], { mode: 'deny' }),
denyWrite: expand(fs?.denyWrite ?? [], { mode: 'deny' }),
credentialDeny: [
...expand(getCredentialDenyReadPaths(c.credentials), { mode: 'deny' }),
...maskedFiles,
],
{ mode: 'deny' },
)
const denyWrite = expand(fs?.denyWrite ?? [], { mode: 'deny' })
return {
// `allowRead` also serves as `allowWithinDeny`: a file under a
// denied dir gets an explicit ALLOW ACE for the sandbox user,
// and explicit DENY on the parent doesn't override it because
// the recompose chokepoint orders deny-before-allow per-path.
grantRead: expand(fs?.allowRead ?? [], { mode: 'grant' }),
grantWrite: expand(fs?.allowWrite ?? [], { mode: 'grant' }),
denyRead,
denyWrite,
}
})
}

/**
* The `mode: "mask"` files, as credential denies: an ACL cannot redirect a
* read, so on Windows, as on macOS, a masked file is unreadable. Each is a
* file the library opened, so none is expanded, and `filesystem.disabled`
* leaves them in force, as it leaves the masks on the other platforms.
*/
function windowsMaskedFileDenies(
credentialRestrictions: CredentialRestrictionConfig,
): string[] {
return [
...credentialRestrictions.maskedFileBinds.map(b => b.realPath),
...credentialRestrictions.degradeToDenyPaths,
]
}

/**
Expand All @@ -1736,7 +1759,9 @@ function computeWindowsFsAccessSet(c: SandboxRuntimeConfig): {
* expansion) when nothing relevant changed.
*/
function rawWindowsFsInputs(c: SandboxRuntimeConfig) {
// Keyed exactly on what {@link computeWindowsFsAccessSet} reads.
// Keyed exactly on what {@link computeWindowsFsAccessSet} reads of
// the config. The masked files are not session-wide: each wrap
// denies the ones the session has not stamped.
// `network.allowedDomains` does NOT feed file-deny (only mask
// injectHosts), so a network-only updateConfig hits the cache.
return {
Expand Down Expand Up @@ -2343,12 +2368,15 @@ async function wrapWithSandboxArgv(
// as session-level `computeWindowsFsAccessSet` (credential
// ENV: mode:'deny' is structural under the fresh srt-sandbox
// env; mode:'mask' sentinels are passed via the --env
// overlay).
// overlay). The masked files stay denied, as of this wrap: the
// session has stamped the ones it found at initialize().
// Per-exec allowRead/allowWrite throw — `srt-win exec` only
// exposes `--deny-*`; per-exec grants are not implemented.
const fsCfg = customConfig?.filesystem
let perExecDenyRead: string[] = []
let perExecDenyWrite: string[] = []
const maskedFiles = windowsMaskedFileDenies(credentialRestrictions)
let rawRead: readonly FilesystemPathEntry[] = []
let rawWrite: readonly FilesystemPathEntry[] = []
let rawCredential: readonly string[] = []
if (!fsCfg?.disabled) {
if (fsCfg?.allowRead?.length || fsCfg?.allowWrite?.length) {
throw new Error(
Expand All @@ -2357,24 +2385,38 @@ async function wrapWithSandboxArgv(
`denies. Set them at the session level (initialize()).`,
)
}
const rawRead = [
...(fsCfg?.denyRead ?? []),
...getCredentialDenyReadPaths(customConfig?.credentials),
]
const rawWrite = fsCfg?.denyWrite ?? []
// Skip on the dominant path (no per-exec fs or
// credential-file deny).
if (rawRead.length > 0 || rawWrite.length > 0) {
const sessRead = new Set(windowsFsStampedSet?.denyRead ?? [])
const sessWrite = new Set(windowsFsStampedSet?.denyWrite ?? [])
const expand = expandWindowsFsPaths
perExecDenyRead = expand(rawRead, { mode: 'deny' }).filter(
p => !sessRead.has(p),
)
perExecDenyWrite = expand(rawWrite, { mode: 'deny' }).filter(
p => !sessRead.has(p) && !sessWrite.has(p),
)
}
rawRead = fsCfg?.denyRead ?? []
rawWrite = fsCfg?.denyWrite ?? []
rawCredential = getCredentialDenyReadPaths(customConfig?.credentials)
}
let perExecDenyRead: string[] = []
let perExecDenyWrite: string[] = []
// Skip on the dominant path (no per-exec fs deny, no credential
// file).
if (
rawRead.length > 0 ||
rawWrite.length > 0 ||
rawCredential.length > 0 ||
maskedFiles.length > 0
) {
const sessRead = new Set(windowsFsStampedSet?.denyRead ?? [])
const sessWrite = new Set(windowsFsStampedSet?.denyWrite ?? [])
const expand = expandWindowsFsPaths
const ranked = rankWindowsFsAccess({
allowRead: [],
allowWrite: [],
denyRead: expand(rawRead, { mode: 'deny' }),
denyWrite: expand(rawWrite, { mode: 'deny' }),
credentialDeny: [
...expand(rawCredential, { mode: 'deny' }),
...maskedFiles,
],
held: windowsFsStampedSet,
})
perExecDenyRead = ranked.denyRead.filter(p => !sessRead.has(p))
perExecDenyWrite = ranked.denyWrite.filter(
p => !sessRead.has(p) && !sessWrite.has(p),
)
}
// Per-exec deny rides on argv (`acl stamp` reads stdin, but
// exec's stdin belongs to the child). The CreateProcessW
Expand Down
2 changes: 1 addition & 1 deletion src/sandbox/sandbox-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface FsWriteRestrictionConfig {
* - `maskedFileBinds`: (realPath → fakePath) pairs for whole-file masking;
* the platform layer binds fakePath over realPath read-only so the
* sandbox reads a sentinel instead of the real bytes (Linux only —
* macOS degrades these to denyReadPaths).
* macOS and Windows degrade these to read denies).
* - `maskedFileStoreDir`: host directory holding the fake files. The
* Linux layer ro-binds it over itself so the sandbox cannot tamper
* with the bind sources regardless of allowWrite.
Expand Down
Loading
Loading