From fedd6fb1dc27fe81732be1a08d1ab726b6367433 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 10 Oct 2026 17:56:24 +0000 Subject: [PATCH] windows: deny entries rank as on the other platforms The ACEs set on an object come before the ones it inherits, so beneath a DENY on a directory an ALLOW set further down is the one that counts. That is the rank of allowRead over denyRead. rankWindowsFsAccess hands srt-win no such ALLOW for the two opposite ranks: a write grant at or beneath a write deny is a read grant, and a grant at or beneath a credentials.files entry is left out. A masked file is read-denied, as on macOS. A deny that one command brings is also stamped on the session's grants beneath it. --- README.md | 5 +- src/sandbox/credential-mask-files.ts | 3 +- src/sandbox/sandbox-config.ts | 4 +- src/sandbox/sandbox-manager.ts | 176 +++--- src/sandbox/sandbox-schemas.ts | 2 +- src/sandbox/windows-sandbox-utils.ts | 124 +++++ test/sandbox/winsrt.test.ts | 797 ++++++++++++++++++++++++++- 7 files changed, 1038 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index e8e4160bc..b8cf8174d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/src/sandbox/credential-mask-files.ts b/src/sandbox/credential-mask-files.ts index cc05e8299..2ee8e136e 100644 --- a/src/sandbox/credential-mask-files.ts +++ b/src/sandbox/credential-mask-files.ts @@ -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' diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 968c82d75..2a17c190f 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -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']) @@ -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( diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 35c11df39..29e7e9576 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -80,6 +80,7 @@ import { wrapCommandWithSandboxWindows, parseWindowsBinShell, expandWindowsFsPaths, + rankWindowsFsAccess, stampWindowsAcl, restoreWindowsAcl, grantWindowsAcl, @@ -92,6 +93,7 @@ import { WindowsSandboxError, type SrtWinSpawn, type WindowsBinShell, + type WindowsFsAccessSet, DEFAULT_WINDOWS_PROXY_PORT_RANGE, } from './windows-sandbox-utils.js' import { @@ -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: 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 - | 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. @@ -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%, @@ -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. @@ -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, }) @@ -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 @@ -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`, ) @@ -1684,49 +1696,60 @@ function getFsWriteConfig(): FsWriteRestrictionConfig { * `READ|EXECUTE` ALLOW ACE, and `denyRead`/`denyWrite` become an * explicit DENY ACE for `` 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, + ] } /** @@ -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 { @@ -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( @@ -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 diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index 5c16faca7..8c92c02a1 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -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. diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index fe9d63c3c..a170d0e9a 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -13,8 +13,11 @@ import { normalizePathForSandbox, containsGlobCharsWin, expandGlobPattern, + isAtOrUnder, isUncPath, markedLiteralPath, + normalizeCaseForComparison, + stripExtendedPathPrefix, } from './sandbox-utils.js' // Kept on this module's surface for out-of-tree importers; the // implementations live in sandbox-utils.ts. @@ -1759,6 +1762,127 @@ export function expandWindowsFsPaths( return [...out] } +/** + * `p` for comparison with another path, as the object `srt-win` opens for it: + * links, junctions, 8.3 names and the case on disk are resolved by the same + * final-path lookup the helper makes. On top of that, lower case, `/` + * separators and none at the end. A path that cannot be opened is compared as + * spelled, and so is a UNC path, which is never probed (see + * {@link isUncPath}). + */ +function windowsPathKey(p: string): string { + let resolved = p + if (!isUncPath(p)) { + try { + resolved = fs.realpathSync.native(p) + } catch { + // Not there, so nothing lies beneath it either. + } + } + return normalizeCaseForComparison( + path.win32 + .normalize(stripExtendedPathPrefix(resolved)) + .replace(/\\/g, '/') + .replace(/\/+$/, ''), + ) +} + +/** The one of `dirs` that a path is, or lies beneath. */ +function coveringWindowsPath( + dirs: readonly string[], +): (p: string) => string | undefined { + const keys = dirs.map(windowsPathKey) + return p => { + if (keys.length === 0) return undefined + const key = windowsPathKey(p) + const covering = keys.findIndex(dir => isAtOrUnder(key, dir)) + return covering < 0 ? undefined : dirs[covering] + } +} + +/** What `srt-win` is handed: see {@link rankWindowsFsAccess}. */ +export interface WindowsFsAccessSet { + grantRead: string[] + grantWrite: string[] + denyRead: string[] + denyWrite: string[] +} + +/** + * Ranks the lists against each other, as on the other platforms, and returns + * the grants and stamps that say so. Takes concrete paths + * ({@link expandWindowsFsPaths}). + * + * The ACEs set on an object come before the ones it inherits, so beneath a + * DENY on a directory an ALLOW set further down is the one that counts. That + * is the rank of `allowRead` over `denyRead`. The other two ranks are the + * opposite one, so `srt-win` is handed no such ALLOW for them: + * + * - `denyWrite` over `allowWrite`: a write grant at or beneath a write deny + * is a read grant. + * - `credentialDeny` (`credentials.files`) over `allowRead` and `allowWrite`: + * a grant at or beneath a credential deny is left out. The caller adds the + * grants for the library's own files to the result, so they are the only + * exceptions. + * + * `held` are grants that are on disk already: the session's, when one command + * brings denies of its own. They cannot be taken back for one command, so + * each gets the deny at its own path, where `srt-win` puts DENY first. + */ +export function rankWindowsFsAccess(lists: { + allowRead: readonly string[] + allowWrite: readonly string[] + denyRead: readonly string[] + denyWrite: readonly string[] + credentialDeny: readonly string[] + held?: Pick +}): WindowsFsAccessSet { + const credentialDenyOver = coveringWindowsPath(lists.credentialDeny) + const writeDenyOver = coveringWindowsPath(lists.denyWrite) + const grantRead = new Set() + const grantWrite = new Set() + const rank = (list: 'allowRead' | 'allowWrite', p: string): void => { + const credentialDeny = credentialDenyOver(p) + const writeDeny = list === 'allowWrite' ? writeDenyOver(p) : undefined + if (credentialDeny === undefined && writeDeny === undefined) { + const grants = list === 'allowRead' ? grantRead : grantWrite + grants.add(p) + return + } + if (credentialDeny === undefined) grantRead.add(p) + logForDebugging( + `[Sandbox Windows] ${list} ${p} is at or beneath ` + + (credentialDeny !== undefined + ? `the credential file entry ${credentialDeny}: not granted` + : `denyWrite ${writeDeny}: granted read only`), + { level: 'warn' }, + ) + } + for (const p of lists.allowRead) rank('allowRead', p) + for (const p of lists.allowWrite) rank('allowWrite', p) + const heldRead = lists.held?.grantRead ?? [] + const heldWrite = lists.held?.grantWrite ?? [] + return { + grantRead: [...grantRead], + grantWrite: [...grantWrite], + denyRead: [ + ...new Set([ + ...lists.denyRead, + ...lists.credentialDeny, + ...[...heldRead, ...heldWrite].filter( + p => credentialDenyOver(p) !== undefined, + ), + ]), + ], + denyWrite: [ + ...new Set([ + ...lists.denyWrite, + ...heldWrite.filter(p => writeDenyOver(p) !== undefined), + ]), + ], + } +} + /** * {@link WindowsSandboxError} narrowed to `mapped_drive_cwd`, * carrying the `DRIVE_REMOTE` root that made the launch fail diff --git a/test/sandbox/winsrt.test.ts b/test/sandbox/winsrt.test.ts index 9c755655b..1223e0f7d 100644 --- a/test/sandbox/winsrt.test.ts +++ b/test/sandbox/winsrt.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, + beforeEach, describe, expect, it, @@ -12,24 +13,31 @@ import { spawn, spawnSync } from 'node:child_process' import { EventEmitter } from 'node:events' import { existsSync, + mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { isAbsolute, join } from 'node:path' +import { dirname, isAbsolute, join } from 'node:path' import { createServer, type Server } from 'node:net' import type { AddressInfo } from 'node:net' import { isMacOS, isWindows } from '../helpers/platform.js' import { spawnAsync } from '../helpers/spawn.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import type { + CredentialFileConfig, SandboxRuntimeConfig, SrtWinConfig, } from '../../src/sandbox/sandbox-config.js' import { WindowsConfigSchema } from '../../src/sandbox/sandbox-config.js' import { CA_TRUST_VARS } from '../../src/sandbox/sandbox-utils.js' +import { certThumbprint } from '../../src/sandbox/mitm-ca.js' +import * as platform from '../../src/utils/platform.js' +import * as winUtils from '../../src/sandbox/windows-sandbox-utils.js' import { getSrtWinPath, getWindowsWfpStatus, @@ -50,11 +58,15 @@ import { windowsStateDir, wrapCommandWithSandboxWindows, parseWindowsBinShell, + rankWindowsFsAccess, resolveSrtWin, buildGitConfigEnv, SRT_WIN_DISPATCH_ARG1, DEFAULT_WINDOWS_PROXY_PORT_RANGE, VENDORED_SRT_WIN_EXE, + type WindowsAclGrantOptions, + type WindowsAclStampOptions, + type WindowsFsAccessSet, } from '../../src/sandbox/windows-sandbox-utils.js' /** @@ -656,6 +668,453 @@ describe('windowsStateDir (pure, all platforms)', () => { }) }) +describe('rankWindowsFsAccess (pure, all platforms)', () => { + type Lists = Parameters[0] + const rank = (lists: Partial): WindowsFsAccessSet => + rankWindowsFsAccess({ + allowRead: [], + allowWrite: [], + denyRead: [], + denyWrite: [], + credentialDeny: [], + ...lists, + }) + + // On no disk, so compared as spelled. + const W = 'C:\\srt-w' + const D = 'C:\\srt-w\\d' + const SUB = 'C:\\srt-w\\d\\sub' + const SUB2 = 'C:\\srt-w\\d\\sub2' + const BESIDE = 'C:\\srt-w\\dx' + + it.each<[string, Partial, Partial]>([ + [ + 'allowWrite around a denyWrite: write', + { allowWrite: [W], denyWrite: [D] }, + { grantWrite: [W], denyWrite: [D] }, + ], + [ + 'allowWrite beside a denyWrite: write', + { allowWrite: [BESIDE], denyWrite: [D] }, + { grantWrite: [BESIDE], denyWrite: [D] }, + ], + [ + 'allowWrite beneath a denyWrite: read', + { allowWrite: [SUB], denyWrite: [D] }, + { grantRead: [SUB], denyWrite: [D] }, + ], + [ + 'allowWrite at a denyWrite: read', + { allowWrite: [D], denyWrite: [D] }, + { grantRead: [D], denyWrite: [D] }, + ], + [ + 'allowWrite around and beneath a denyWrite: write, read', + { allowWrite: [W, SUB], denyWrite: [D] }, + { grantWrite: [W], grantRead: [SUB], denyWrite: [D] }, + ], + [ + 'a path in allowRead too is granted once', + { allowRead: [SUB], allowWrite: [SUB], denyWrite: [D] }, + { grantRead: [SUB], denyWrite: [D] }, + ], + [ + 'allowRead beneath a denyWrite: read', + { allowRead: [SUB], denyWrite: [D] }, + { grantRead: [SUB], denyWrite: [D] }, + ], + [ + 'allowRead at and beneath a denyRead: read', + { allowRead: [D, SUB], denyRead: [D] }, + { grantRead: [D, SUB], denyRead: [D] }, + ], + [ + 'allowWrite beneath a denyRead: write', + { allowWrite: [SUB], denyRead: [D] }, + { grantWrite: [SUB], denyRead: [D] }, + ], + [ + 'allowRead and allowWrite around a credential deny: read, write', + { allowRead: [W], allowWrite: [W], credentialDeny: [D] }, + { grantRead: [W], grantWrite: [W], denyRead: [D] }, + ], + [ + 'allowRead and allowWrite at or beneath a credential deny: nothing', + { allowRead: [D, SUB], allowWrite: [D, SUB2], credentialDeny: [D] }, + { denyRead: [D] }, + ], + [ + 'allowWrite beneath a denyWrite and a credential deny: nothing', + { allowWrite: [SUB], denyWrite: [D], credentialDeny: [W] }, + { denyRead: [W], denyWrite: [D] }, + ], + [ + 'a credential deny that is in denyRead too is stamped once', + { allowRead: [SUB], denyRead: [D], credentialDeny: [D] }, + { denyRead: [D] }, + ], + [ + 'a held write grant beneath a denyWrite: write-denied at its own path', + { + denyWrite: [D], + held: { grantRead: [SUB2], grantWrite: [W, SUB, BESIDE] }, + }, + { denyWrite: [D, SUB] }, + ], + [ + 'held grants at or beneath a credential deny: read-denied at their own', + { + credentialDeny: [D], + held: { grantRead: [W, D, SUB], grantWrite: [SUB2, BESIDE] }, + }, + { denyRead: [D, SUB, SUB2] }, + ], + [ + 'held grants beneath a denyRead: as they are', + { denyRead: [D], held: { grantRead: [SUB], grantWrite: [SUB2] } }, + { denyRead: [D] }, + ], + ])('%s', (_name, lists, want) => { + expect(rank(lists)).toEqual({ + grantRead: [], + grantWrite: [], + denyRead: [], + denyWrite: [], + ...want, + }) + }) + + const isBeneath = (deny: string, grant: string): boolean => + rank({ allowRead: [grant], credentialDeny: [deny] }).grantRead.length === 0 + + it.each<[string, string, boolean]>([ + [D, 'c:\\SRT-W\\D\\Sub', true], + [D + '\\', SUB, true], + ['C:/srt-w/d', SUB, true], + [D, 'C:/srt-w//d/sub/', true], + ['C:\\srt-w\\x\\..\\d', SUB, true], + ['\\\\?\\' + D, SUB, true], + ['C:\\', SUB, true], + [D, BESIDE, false], + [D, 'D:\\srt-w\\d\\sub', false], + [SUB, D, false], + ['\\\\srv\\share\\d', '\\\\SRV\\Share\\D\\sub', true], + ['\\\\?\\UNC\\srv\\share\\d', '\\\\srv\\share\\d\\sub', true], + ['\\\\srv\\share\\d', '\\\\srv\\share2\\d\\sub', false], + ])('as spelled, beneath %p is %p: %p', (deny, grant, beneath) => { + expect(isBeneath(deny, grant)).toBe(beneath) + }) + + it('a UNC path is not looked up', () => { + const lookup = spyOn(realpathSync, 'native') + try { + expect(isBeneath('\\\\srv\\share', '//srv/share/sub')).toBe(true) + expect(isBeneath('\\\\?\\UNC\\srv\\share', D)).toBe(false) + expect(lookup.mock.calls).toEqual([[D]]) + } finally { + lookup.mockRestore() + } + }) + + describe('on disk', () => { + let spelled: string + let root: string + + beforeAll(() => { + spelled = mkdtempSync(join(tmpdir(), 'srt-rank-')) + root = realpathSync.native(spelled) + mkdirSync(join(root, 'd', 'sub'), { recursive: true }) + mkdirSync(join(root, 'e', 'sub'), { recursive: true }) + symlinkSync(join(root, 'd'), join(root, 'to-d'), 'junction') + symlinkSync(join(root, 'e'), join(root, 'd', 'to-e'), 'junction') + }) + + afterAll(() => { + try { + rmSync(root, { recursive: true, force: true }) + } catch { + // Not every runtime removes a junction. + } + }) + + it.each<[string, string, boolean]>([ + ['d', 'd/sub', true], + ['d', 'e/sub', false], + ['d', 'to-d/sub', true], + ['to-d', 'd/sub', true], + ['to-d', 'to-d', true], + ['e', 'd/to-e/sub', true], + ['d', 'd/to-e/sub', false], + ['absent', 'd/sub', false], + ])('beneath %p is %p: %p', (deny, grant, beneath) => { + expect(isBeneath(join(root, deny), join(root, grant))).toBe(beneath) + }) + + it('a path counts as it resolves, however the temp folder is spelled', () => { + console.error(`[winsrt rank] spelled=${spelled} resolved=${root}`) + expect(isBeneath(join(spelled, 'd'), join(root, 'd', 'sub'))).toBe(true) + expect(isBeneath(join(root, 'd'), join(spelled, 'd', 'sub'))).toBe(true) + expect( + isBeneath(join(root, 'd').toUpperCase(), join(root, 'd', 'sub')), + ).toBe(true) + }) + + it('a path is handed on as it was spelled', () => { + const grant = join(root, 'to-d', 'sub') + expect( + rank({ allowWrite: [grant], denyWrite: [join(root, 'd')] }).grantRead, + ).toEqual([grant]) + }) + }) +}) + +// The manager's Windows path with the helper calls recorded, not made. Not on +// Windows: the L-rows below make them there, and initialize() under a stub +// would use up the one egress check a process makes. +describe.if(!isWindows)('what srt-win is handed (platform spy)', () => { + const spies: Array> = [] + let granted: WindowsAclGrantOptions | undefined + let stamped: WindowsAclStampOptions | undefined + let root: string + let d: string + let sub: string + let sub2: string + let token: string + + const configOf = ( + filesystem: Partial, + files: CredentialFileConfig[] = [], + ): SandboxRuntimeConfig => ({ + // External proxy ports: nothing is bound. + network: { + allowedDomains: [], + deniedDomains: [], + httpProxyPort: 1, + socksProxyPort: 1, + }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [], ...filesystem }, + credentials: { files }, + windows: { srtWin: { path: process.execPath } }, + }) + + /** The values of `flag` in what one command is wrapped to. */ + const flagged = async ( + flag: '--deny-read' | '--deny-write', + customConfig?: Partial, + ): Promise => { + const { argv } = await SandboxManager.wrapWithSandboxArgv( + 'rem', + undefined, + customConfig, + ) + return argv.flatMap((a, i) => (a === flag ? [argv[i + 1]] : [])) + } + + beforeEach(() => { + granted = stamped = undefined + root = realpathSync.native(mkdtempSync(join(tmpdir(), 'srt-handed-'))) + d = join(root, 'd') + sub = join(d, 'sub') + sub2 = join(d, 'sub2') + token = join(d, 'token') + mkdirSync(sub, { recursive: true }) + mkdirSync(sub2) + writeFileSync(token, 'token-content') + spies.push( + spyOn(platform, 'getPlatform').mockReturnValue('windows'), + spyOn(winUtils, 'checkWindowsDependenciesAsync').mockResolvedValue({ + errors: [], + warnings: [], + }), + spyOn(winUtils, 'getWindowsSandboxUserStatusAsync').mockResolvedValue({ + provisioned: true, + sid: 'S-1-5-21-1', + groupExists: true, + inBuiltinUsers: true, + inSandboxGroup: true, + hiddenFromLogon: true, + credPresent: true, + realUserSid: 'S-1-5-21-3', + caCertThumb: certThumbprint(readFileSync(CA_CERT, 'utf8')), + caCertPem: readFileSync(CA_CERT, 'utf8'), + }), + spyOn(winUtils, 'verifyWindowsWfpEgress').mockResolvedValue({ + target: '', + stderr: '', + }), + spyOn(winUtils, 'grantWindowsAcl').mockImplementation(o => { + granted = o + }), + spyOn(winUtils, 'stampWindowsAcl').mockImplementation(o => { + stamped = o + }), + spyOn(winUtils, 'revokeWindowsAcl').mockReturnValue([]), + spyOn(winUtils, 'restoreWindowsAcl').mockReturnValue([]), + ) + }) + + afterEach(async () => { + await SandboxManager.reset() + for (const spy of spies.splice(0)) spy.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + it('the session: an allowWrite beneath a denyWrite is a read grant', async () => { + await SandboxManager.initialize( + configOf({ allowWrite: [root, sub], denyWrite: [d] }), + ) + expect(granted).toMatchObject({ read: [sub], write: [root] }) + expect(stamped).toMatchObject({ denyRead: [], denyWrite: [d] }) + }) + + it('the session: an allowRead beneath a denyRead is granted', async () => { + await SandboxManager.initialize( + configOf({ allowRead: [sub], denyRead: [d] }), + ) + expect(granted).toMatchObject({ read: [sub], write: [] }) + expect(stamped).toMatchObject({ denyRead: [d], denyWrite: [] }) + }) + + it('the session: nothing is granted beneath a credential deny', async () => { + await SandboxManager.initialize( + configOf({ allowRead: [sub], allowWrite: [root, sub2] }, [ + { path: d, mode: 'deny' }, + ]), + ) + expect(granted).toMatchObject({ read: [], write: [root] }) + expect(stamped).toMatchObject({ denyRead: [d], denyWrite: [] }) + }) + + it.each<[string, Omit]>([ + ['masked', { mode: 'mask' }], + [ + 'masked with no match', + { mode: 'mask', extract: 'absent-(text)', onExtractNoMatch: 'deny' }, + ], + ])('the session: a %s file is a credential deny', async (_name, entry) => { + await SandboxManager.initialize( + configOf({ allowRead: [token], allowWrite: [root] }, [ + { path: token, ...entry }, + ]), + ) + expect(granted).toMatchObject({ read: [], write: [root] }) + expect(stamped).toMatchObject({ denyRead: [token], denyWrite: [] }) + expect(await flagged('--deny-read')).toEqual([]) + }) + + it('the session: filesystem.disabled leaves a masked file denied, and that alone', async () => { + await SandboxManager.initialize( + configOf({ disabled: true, allowWrite: [root], denyWrite: [d] }, [ + { path: token, mode: 'mask' }, + { path: sub, mode: 'deny' }, + ]), + ) + expect(granted).toBeUndefined() + expect(stamped).toMatchObject({ denyRead: [token], denyWrite: [] }) + }) + + it('onExtractNoMatch "error": initialize() resolves and the wrap throws', async () => { + await SandboxManager.initialize( + configOf({ allowWrite: [root] }, [ + { + path: token, + mode: 'mask', + extract: 'absent-(text)', + onExtractNoMatch: 'error', + }, + ]), + ) + expect(granted).toMatchObject({ write: [root] }) + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun:test types .rejects.toThrow() as void; the await is required at runtime + await expect(flagged('--deny-read')).rejects.toThrow(/onExtractNoMatch/) + }) + + it('one command: a file masked since initialize() is denied', async () => { + const late = join(root, 'late') + await SandboxManager.initialize( + configOf({ allowWrite: [root] }, [{ path: late, mode: 'mask' }]), + ) + expect(stamped).toBeUndefined() + expect(await flagged('--deny-read')).toEqual([]) + writeFileSync(late, 'late-content') + expect(await flagged('--deny-read')).toEqual([late]) + }) + + it('one command: a masked file of its own is denied, filesystem.disabled or not', async () => { + await SandboxManager.initialize(configOf({ allowWrite: [root] })) + const credentials = { files: [{ path: token, mode: 'mask' as const }] } + expect(await flagged('--deny-read', { credentials })).toEqual([token]) + expect( + await flagged('--deny-read', { + credentials, + filesystem: { + disabled: true, + denyRead: [sub], + allowWrite: [], + denyWrite: [], + }, + }), + ).toEqual([token]) + }) + + it("one command: its denyWrite reaches the session's write grants beneath it", async () => { + await SandboxManager.initialize( + configOf({ allowRead: [sub2], allowWrite: [root, sub] }), + ) + const filesystem = { denyRead: [], allowWrite: [], denyWrite: [d] } + expect(await flagged('--deny-write', { filesystem })).toEqual([d, sub]) + expect(await flagged('--deny-read', { filesystem })).toEqual([]) + }) + + it("one command: its credential deny reaches the session's grants beneath it", async () => { + await SandboxManager.initialize( + configOf({ allowRead: [sub], allowWrite: [root, sub2] }), + ) + const credentials = { files: [{ path: d, mode: 'deny' as const }] } + expect(await flagged('--deny-read', { credentials })).toEqual([ + d, + sub, + sub2, + ]) + }) + + it("one command: its denyRead leaves the session's grants beneath it", async () => { + await SandboxManager.initialize( + configOf({ allowRead: [sub], allowWrite: [sub2] }), + ) + const filesystem = { denyRead: [d], allowWrite: [], denyWrite: [] } + expect(await flagged('--deny-read', { filesystem })).toEqual([d]) + expect(await flagged('--deny-write', { filesystem })).toEqual([]) + }) + + it('one command: a credential deny on a path the session read-denies', async () => { + await SandboxManager.initialize( + configOf({ allowRead: [sub], denyRead: [d] }), + ) + const credentials = { files: [{ path: d, mode: 'deny' as const }] } + expect(await flagged('--deny-read', { credentials })).toEqual([sub]) + }) + + it("the library's trust bundle is granted beneath a credential deny", async () => { + const above = { path: tmpdir(), mode: 'deny' as const } + const base = configOf({ allowRead: [sub] }, [above]) + await SandboxManager.initialize({ + ...base, + network: { + ...base.network, + tlsTerminate: { caCertPath: CA_CERT, caKeyPath: CA_KEY }, + }, + }) + const own = dirname(SandboxManager.getMitmCA()!.trustBundlePath) + expect(granted).toMatchObject({ read: [own], write: [] }) + expect( + await flagged('--deny-read', { + credentials: { files: [{ path: dirname(own), mode: 'deny' }] }, + }), + ).not.toContain(own) + }) +}) + describe('windows-sandbox-utils async twins (pure, all platforms)', () => { const RAW_USER = JSON.stringify({ user: { @@ -2030,6 +2489,309 @@ describe.if(isWindows)( } rmSync(hScratch, { recursive: true, force: true }) }, 30_000) + + // ── L-rows: how the lists rank against each other ── + // A tree per row: d\own.txt, d\sub\in.txt, beside\b.txt, and to-d, a + // junction to d. Each row logs what it ran, so a job log tells a row + // that ran from one that was skipped. + describe('L-rows: how the lists rank', () => { + let spelled: string + let root: string + const at = (rel: string): string => join(root, rel) + const CONTENT: Record = { + 'd/own.txt': 'IN-D', + 'd/sub/in.txt': 'IN-SUB', + 'beside/b.txt': 'IN-BESIDE', + } + + beforeEach(() => { + spelled = mkdtempSync(join(tmpdir(), 'srt-l-')) + root = realpathSync.native(spelled) + for (const [rel, content] of Object.entries(CONTENT)) { + mkdirSync(dirname(at(rel)), { recursive: true }) + writeFileSync(at(rel), content) + } + symlinkSync(at('d'), at('to-d'), 'junction') + }) + + afterEach(() => { + try { + rmSync(root, { recursive: true, force: true }) + } catch { + // Not every runtime removes a junction. + } + }) + + type Expected = { + /** A file of CONTENT, and whether the command reads it. */ + reads?: Record + /** A file, there or not, and whether the command writes it. */ + writes?: Record + } + + /** One command that tries all of `expected`, held against it. */ + async function tryAll( + id: string, + expected: Expected, + customConfig?: Partial, + ): Promise { + const reads = Object.entries(expected.reads ?? {}) + const writes = Object.entries(expected.writes ?? {}) + const { argv, env } = await SandboxManager.wrapWithSandboxArgv( + [ + 'echo RAN', + // `type` lists the folder first; `<` opens the file alone. + ...reads.map(([rel]) => `type "${at(rel)}" & more < "${at(rel)}"`), + ...writes.map(([rel]) => `echo WROTE>"${at(rel)}"`), + ].join(' & '), + undefined, + customConfig, + ) + const r = await spawnAsync(argv[0], argv.slice(1), { + env, + timeout: 60_000, + }) + const found = { + reads: Object.fromEntries( + reads.map(([rel]) => [rel, r.stdout.includes(CONTENT[rel])]), + ), + writes: Object.fromEntries( + writes.map(([rel]) => [ + rel, + existsSync(at(rel)) && + readFileSync(at(rel), 'utf8').includes('WROTE'), + ]), + ), + } + console.error( + `[winsrt ${id}] ran: exit=${r.status} found=${JSON.stringify(found)} ` + + `stderr=${JSON.stringify(r.stderr)}`, + ) + expect(r.stdout).toContain('RAN') + expect(found).toEqual({ + reads: expected.reads ?? {}, + writes: expected.writes ?? {}, + }) + } + + /** `run` in a session of these lists, which leaves no ACE behind. */ + async function inSession( + fs: FsOverrides, + files: CredentialFileConfig[], + run: () => Promise, + ): Promise { + await SandboxManager.initialize({ + ...createFsTestConfig(fs), + credentials: { files }, + }) + try { + await run() + } finally { + await SandboxManager.reset() + } + for (const rel of ['.', 'beside', 'd', 'd/own.txt', 'd/sub']) { + const acl = spawnSync('icacls', [at(rel)], { + encoding: 'utf8', + timeout: 5_000, + }).stdout + expect(acl).toContain(root) + expect(acl).not.toContain(sbSid) + expect(acl).not.toContain('srt-sandbox') + } + } + + type Lists = Partial< + Record<'allowRead' | 'allowWrite' | 'denyRead' | 'denyWrite', string[]> + > + it.each<[string, string, Lists, CredentialFileConfig[], Expected]>([ + [ + 'L1', + 'a denyWrite inside an allowWrite: written around, read within', + { allowWrite: ['.'], denyWrite: ['d'] }, + [], + { + reads: { 'd/own.txt': true, 'd/sub/in.txt': true }, + writes: { + 'new.txt': true, + 'd/new.txt': false, + 'd/sub/new.txt': false, + }, + }, + ], + [ + 'L2', + 'an allowWrite beneath a denyWrite: read, not written; beside it: written', + { allowWrite: ['d/sub', 'beside'], denyWrite: ['d'] }, + [], + { + reads: { 'd/sub/in.txt': true, 'beside/b.txt': true }, + writes: { + 'd/sub/new.txt': false, + 'd/sub/in.txt': false, + 'beside/new.txt': true, + }, + }, + ], + [ + 'L3', + 'an allowWrite around and beneath a denyWrite: written around alone', + { allowWrite: ['.', 'd/sub'], denyWrite: ['d'] }, + [], + { + reads: { 'd/sub/in.txt': true }, + writes: { 'new.txt': true, 'd/sub/new.txt': false }, + }, + ], + [ + 'L4', + 'an allowWrite beneath a denyWrite, named through a junction', + { allowWrite: ['to-d/sub'], denyWrite: ['d'] }, + [], + { + reads: { 'd/sub/in.txt': true }, + writes: { 'd/sub/new.txt': false }, + }, + ], + [ + 'L5', + 'an allowRead beneath a denyRead: read', + { allowRead: ['d/sub', 'beside'], denyRead: ['d'] }, + [], + { + reads: { + 'd/sub/in.txt': true, + 'd/own.txt': false, + 'beside/b.txt': true, + }, + }, + ], + [ + 'L6', + 'a credential deny inside an allowWrite: neither read nor written', + { allowWrite: ['.'] }, + [{ path: 'd', mode: 'deny' }], + { + reads: { + 'd/own.txt': false, + 'd/sub/in.txt': false, + 'beside/b.txt': true, + }, + writes: { 'd/new.txt': false, 'new.txt': true }, + }, + ], + [ + 'L7', + 'an allowRead beneath a credential deny: not read', + { allowRead: ['d/sub', 'beside'] }, + [{ path: 'd', mode: 'deny' }], + { reads: { 'd/sub/in.txt': false, 'beside/b.txt': true } }, + ], + [ + 'L8', + 'an allowWrite beneath a credential deny: neither read nor written', + { allowWrite: ['d/sub', 'beside'] }, + [{ path: 'd', mode: 'deny' }], + { + reads: { 'd/sub/in.txt': false, 'beside/b.txt': true }, + writes: { 'd/sub/new.txt': false, 'beside/new.txt': true }, + }, + ], + [ + 'L9', + 'a masked file inside an allowWrite: neither read nor written', + { allowWrite: ['.'] }, + [{ path: 'd/own.txt', mode: 'mask' }], + { + reads: { 'd/own.txt': false, 'd/sub/in.txt': true }, + writes: { 'd/own.txt': false, 'd/new.txt': true }, + }, + ], + [ + 'L10', + 'a masked file that is an allowRead too: not read', + { allowRead: ['d/own.txt', 'beside'] }, + [{ path: 'd/own.txt', mode: 'mask' }], + { reads: { 'd/own.txt': false, 'beside/b.txt': true } }, + ], + [ + 'L11', + 'a masked file with no match, to be denied: not read', + { allowWrite: ['.'] }, + [ + { + path: 'd/own.txt', + mode: 'mask', + extract: 'absent-(text)', + onExtractNoMatch: 'deny', + }, + ], + { reads: { 'd/own.txt': false, 'd/sub/in.txt': true } }, + ], + ])( + '%s: %s', + async (id, _name, lists, files, expected) => { + await inSession( + Object.fromEntries( + Object.entries(lists).map(([list, rels]) => [list, rels.map(at)]), + ), + files.map(f => ({ ...f, path: at(f.path) })), + () => tryAll(id, expected), + ) + }, + 90_000, + ) + + it('L12: an allowWrite beneath a denyWrite, each spelled its own way', async () => { + console.error(`[winsrt L12] spelled=${spelled} resolved=${root}`) + await inSession( + { + allowWrite: [at('d/sub').toUpperCase()], + denyWrite: [join(spelled, 'd')], + }, + [], + () => + tryAll('L12', { + reads: { 'd/sub/in.txt': true }, + writes: { 'd/sub/new.txt': false }, + }), + ) + }, 90_000) + + it("L13: one command's denyWrite over the session's allowWrite, for that command", async () => { + await inSession({ allowWrite: [at('d/sub')] }, [], async () => { + await tryAll( + 'L13 with', + { + reads: { 'd/sub/in.txt': true }, + writes: { 'd/sub/new.txt': false }, + }, + { + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [at('d')], + }, + }, + ) + await tryAll('L13 without', { writes: { 'd/sub/new.txt': true } }) + }) + }, 120_000) + + it("L14: one command's credential deny over the session's grants, for that command", async () => { + await inSession( + { allowRead: [at('d/sub')], allowWrite: [at('beside')] }, + [], + async () => { + await tryAll( + 'L14 with', + { reads: { 'd/sub/in.txt': false, 'beside/b.txt': true } }, + { credentials: { files: [{ path: at('d'), mode: 'deny' }] } }, + ) + await tryAll('L14 without', { reads: { 'd/sub/in.txt': true } }) + }, + ) + }, 120_000) + }) }, ) @@ -2464,6 +3226,39 @@ describe.if(isWindows)('Windows sandbox: tlsTerminate (G)', () => { }, 120_000, ) + + // Last: it replaces the session the rows above share. + it('G10: beneath a credential deny the trust bundle is read, what lies beside it is not', async () => { + const scratch = realpathSync.native(mkdtempSync(join(tmpdir(), 'srt-g10-'))) + const beside = join(scratch, 'beside.txt') + writeFileSync(beside, 'IN-BESIDE') + await SandboxManager.reset() + const temp = process.env.TEMP + try { + // The bundle gets a folder of its own in the temp folder. + process.env.TEMP = scratch + await SandboxManager.initialize({ + ...createTlsTestConfig(['example.com']), + credentials: { files: [{ path: scratch, mode: 'deny' }] }, + }) + const bundle = SandboxManager.getMitmCA()!.trustBundlePath + expect(dirname(dirname(bundle))).toBe(scratch) + const r = await runSandboxed( + `echo RAN & type "%SSL_CERT_FILE:/=\\%" & type "${beside}"`, + ) + console.error( + `[winsrt G10] ran: exit=${r.status} bundle=${bundle} ` + + `stdout=${r.stdout.length} chars stderr=${JSON.stringify(r.stderr)}`, + ) + expect(r.stdout).toContain('RAN') + expect(r.stdout).toContain('-----BEGIN CERTIFICATE-----') + expect(r.stdout).not.toContain('IN-BESIDE') + } finally { + process.env.TEMP = temp + await SandboxManager.reset() + rmSync(scratch, { recursive: true, force: true }) + } + }, 120_000) }) // ────────────────────────────────────────────────────────────────────