fix(sandbox): extend the restricted token's default DACL with a write-SID ACE
New objects created without an explicit security descriptor take their DACL from the token's default DACL, which CreateRestrictedToken builds from the user's ambient SIDs — none of them a restricting SID. Confined children therefore failed the write pass-2 check when creating anonymous pipes (CreatePipe: ERROR_ACCESS_DENIED, surfaced as Node EPERM), breaking PowerShell pipelines and other CreatePipe consumers. Merge a full-access write-SID ACE (Everyone under read-only) into the token default DACL at init via SetTokenInformation(TokenDefaultDacl). Named pipes are EXEMPT: their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which no token change influences, so libuv's piped stdio capture stays denied for confined grandchildren — the POC-documented boundary, now pinned by the runner suite (inherit/ignore OK, pipe DENIED) and documented in the README pair. The NUL paragraph is corrected to the measured matrix (Everyone has 0x1201BF on the device: cmd/node writes land; Set-Content fails at the PS layer).
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md
|
||||
README.md: 3185510142750178b11c2f6bad863a0a4861a29c
|
||||
README.zh.md: ab45d29c7dea79c8a75ac2187140e5926cf71581
|
||||
README.md: 835b7dace0baf10eec78b35172109a37688d99c2
|
||||
README.zh.md: 82a890d75f857b766c7ae47cef7a374edb5ecd63
|
||||
|
||||
@@ -43,7 +43,7 @@ The runner creates the restricted token, spawns the wrapped argv under it with t
|
||||
|
||||
Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them):
|
||||
- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection.
|
||||
- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL).
|
||||
- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL).
|
||||
|
||||
Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note).
|
||||
|
||||
@@ -83,6 +83,7 @@ None directly; the denial surface belongs to the tool layer.
|
||||
- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove.
|
||||
- **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them.
|
||||
- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path.
|
||||
- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes takes the kernel's PUBLIC default SD template (owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only) — NOT the token default DACL — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined.
|
||||
- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow.
|
||||
- **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this.
|
||||
- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement.
|
||||
|
||||
@@ -43,7 +43,7 @@ runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的
|
||||
|
||||
模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃):
|
||||
- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。
|
||||
- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。
|
||||
- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。
|
||||
|
||||
Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。
|
||||
|
||||
@@ -83,6 +83,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头
|
||||
- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。
|
||||
- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。
|
||||
- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。
|
||||
- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读)——**不是**令牌默认 DACL——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。
|
||||
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。
|
||||
- **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。
|
||||
- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface Win32Bindings {
|
||||
copySid(length: number, destination: NativePtr, source: NativePtr): number
|
||||
// ---- token information ---------------------------------------------------
|
||||
getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number
|
||||
setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number
|
||||
// ---- restricted token ----------------------------------------------------
|
||||
createRestrictedToken(
|
||||
existing: NativePtr, flags: number,
|
||||
@@ -392,6 +393,7 @@ function bindings(): Win32Bindings {
|
||||
getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]),
|
||||
copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]),
|
||||
getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]),
|
||||
setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']),
|
||||
createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]),
|
||||
setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]),
|
||||
setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]),
|
||||
|
||||
@@ -47,7 +47,7 @@ import { Win32Error } from './errors.ts'
|
||||
import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts'
|
||||
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts'
|
||||
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
export { quoteArg } from './spawn.ts'
|
||||
@@ -61,8 +61,9 @@ export interface AclSandboxOptions {
|
||||
writableDirs: readonly string[]
|
||||
/**
|
||||
* Temp directory to also grant; defaults to GetTempPathW() at init time.
|
||||
* Pass null for read-only confinement: NO temp grant (strict zero write
|
||||
* allowance — not even the NUL device is writable, see README).
|
||||
* Pass null for read-only confinement: NO temp grant (strict zero grant on
|
||||
* the filesystem; the NUL device stays ambient-writable via Everyone — see
|
||||
* README).
|
||||
*/
|
||||
tempDir?: string | null
|
||||
/**
|
||||
@@ -232,6 +233,16 @@ export class AclSandbox {
|
||||
{ world: worldSid },
|
||||
this.mode,
|
||||
)
|
||||
// The restricted token's default DACL still names only the user's
|
||||
// ambient SIDs — none of the restricting SIDs. Every NEW object the
|
||||
// confined process creates (anonymous stdio pipes, sync objects) takes
|
||||
// its DACL from that default, so the write pass-2 check would deny
|
||||
// pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every
|
||||
// piped-stdio grandchild spawn. Merge a full-access ACE for a
|
||||
// restricting SID (the write SID under workspace-write, Everyone under
|
||||
// read-only): new-object creation stays gated by the parent object's
|
||||
// DACL, while the new object's own DACL passes pass-2.
|
||||
setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid)
|
||||
this.token = restricted
|
||||
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
|
||||
this.api = api
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import { buildExplicitAccess } from './acl.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
/**
|
||||
@@ -92,6 +93,57 @@ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr {
|
||||
return sid
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one full-access allow ACE for `sidPtr` into the token's DEFAULT DACL
|
||||
* — the DACL every NEW object the token holder creates (without an explicit
|
||||
* security descriptor) takes. The restricted token inherits the user's
|
||||
* default DACL verbatim, which names no restricting SID: a new anonymous pipe
|
||||
* (child stdio) therefore fails the write pass-2 check at creation
|
||||
* (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every
|
||||
* piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the
|
||||
* write SID under workspace-write, Everyone under read-only), so each new
|
||||
* object's own DACL passes pass-2 while object creation itself stays gated by
|
||||
* the parent container's DACL (files outside the granted trees remain
|
||||
* uncreatable). Fails closed: any Win32 failure throws before the spawn.
|
||||
* @param api - the binding table.
|
||||
* @param token - the restricted token to adjust (requires TOKEN_ADJUST_DEFAULT).
|
||||
* @param sidPtr - the restricting SID whose full-access ACE joins the default DACL.
|
||||
*/
|
||||
export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, sidPtr: NativePtr): void {
|
||||
const neededSlot = allocUint32()
|
||||
api.getTokenInformation(token, abi.TokenDefaultDacl, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER
|
||||
const needed = decodeUint32(neededSlot)
|
||||
if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl size query')
|
||||
const buffer = Buffer.alloc(needed)
|
||||
if (api.getTokenInformation(token, abi.TokenDefaultDacl, buffer, buffer.length, neededSlot) === 0) {
|
||||
throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl')
|
||||
}
|
||||
const currentDacl = decodePtrAt(buffer, 0)
|
||||
if (currentDacl === null) {
|
||||
throw new Error('setTokenDefaultDaclGrant: the token carries no default DACL to extend')
|
||||
}
|
||||
const newDaclSlot = allocPtrSlot()
|
||||
const result = api.setEntriesInAclW(
|
||||
1,
|
||||
buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.FILE_ALL_ACCESS),
|
||||
currentDacl,
|
||||
newDaclSlot,
|
||||
)
|
||||
if (result !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', result, 'default DACL merge')
|
||||
const newDacl = decodePtr(newDaclSlot)
|
||||
if (newDacl === null) throwWin32(api, 'SetEntriesInAclW', result, 'null merged default DACL')
|
||||
// TOKEN_DEFAULT_DACL { PACL DefaultDacl; } — the struct is exactly the
|
||||
// pointer; SetTokenInformation copies the ACL before returning.
|
||||
const info = Buffer.alloc(8)
|
||||
info.writeBigUInt64LE(newDacl, 0)
|
||||
if (api.setTokenInformation(token, abi.TokenDefaultDacl, info, info.length) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.localFree(newDacl)
|
||||
throwWin32(api, 'SetTokenInformation', win32Code, 'TokenDefaultDacl')
|
||||
}
|
||||
api.localFree(newDacl)
|
||||
}
|
||||
|
||||
/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */
|
||||
function buildRestrictingSids(sids: readonly NativePtr[]): Buffer {
|
||||
const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length)
|
||||
|
||||
@@ -70,6 +70,15 @@ export const FILE_DELETE_CHILD = 0x0040
|
||||
*/
|
||||
export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156
|
||||
|
||||
/**
|
||||
* FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE
|
||||
* | 0x1FF): full file-object access. The mask of the ACE merged into the
|
||||
* restricted token's DEFAULT DACL — the token holder must keep full access to
|
||||
* every NEW object it creates (pipes included), and the ACE must name a
|
||||
* restricting SID so the write pass-2 check passes at creation.
|
||||
*/
|
||||
export const FILE_ALL_ACCESS = 0x1F01FF
|
||||
|
||||
// CreateRestrictedToken flags (winnt.h lines ~4284)
|
||||
/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */
|
||||
export const DISABLE_MAX_PRIVILEGE = 0x1
|
||||
@@ -85,6 +94,8 @@ export const WinWorldSid = 1
|
||||
// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2)
|
||||
/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */
|
||||
export const TokenGroups = 2
|
||||
/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */
|
||||
export const TokenDefaultDacl = 6
|
||||
|
||||
// SECURITY_INFORMATION (winnt.h line ~4293)
|
||||
/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */
|
||||
|
||||
@@ -189,6 +189,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => {
|
||||
// Two-layer pin of the grandchild-spawn boundary:
|
||||
// - the token default DACL carries a restricting-SID ACE (set in init),
|
||||
// so ANONYMOUS pipe creation (CreatePipe — the token-default-DACL
|
||||
// consumer) works and inherited/ignored stdio spawns succeed;
|
||||
// - libuv's pipe-stdio uses NAMED pipes, whose default security
|
||||
// descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins
|
||||
// full, Everyone read-only) — NOT the token default DACL — so the
|
||||
// client-end open requests write access no restricting SID is
|
||||
// granted: ERROR_ACCESS_DENIED, surfaced as spawn EPERM. That is the
|
||||
// POC-documented "no output redirection" boundary of WRITE_RESTRICTED
|
||||
// tokens; piped capture cannot work and is pinned as DENIED.
|
||||
const probe = [
|
||||
"const { spawnSync } = require('child_process');",
|
||||
"const t = (name, opts) => { const s = spawnSync(process.execPath, ['-e', '1'], { encoding: 'utf8', ...opts }); console.log(name + ':' + (s.status === 0 ? 'OK' : 'DENIED')); };",
|
||||
"t('inherit', { stdio: 'inherit' });",
|
||||
"t('ignore', { stdio: 'ignore' });",
|
||||
"t('pipe', { stdio: 'pipe' });",
|
||||
].join('')
|
||||
for (const mode of ['workspace-write', 'read-only'] as const) {
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode,
|
||||
'--', 'node', '-e', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('inherit:OK')
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('ignore:OK')
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('pipe:DENIED')
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => {
|
||||
// The reported defect: a session that materialized its grant in
|
||||
// workspace-write keeps the ACE standing for the server lifetime. After
|
||||
|
||||
Reference in New Issue
Block a user