fix(sandbox): revoke post-apply grant failures, free init SID allocations, and name the Windows runner in SANDBOX_UNAVAILABLE

This commit is contained in:
Huanqi Cao
2026-08-08 17:39:25 +08:00
parent 43d53d339d
commit 07c7e2f0bd
6 changed files with 45 additions and 14 deletions

View File

@@ -57,14 +57,17 @@ export class AclWriteGrant {
/** /**
* Grant the write ACE on one directory (idempotent: an already-standing * Grant the write ACE on one directory (idempotent: an already-standing
* exact ACE skips the eager full-tree re-propagation — see * exact ACE skips the eager full-tree re-propagation — see
* {@link grantWrite}) and record the path for {@link dispose}. Callers * {@link grantWrite}) and record the path for {@link dispose}. The path is
* treat a throw as a failed materialization and dispose the instance to * recorded BEFORE the grant: a post-apply throw (a LocalFree failure after
* revoke the paths granted so far. * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an
* ungranted path is a no-op merge. Callers treat a throw as a failed
* materialization and dispose the instance to revoke the paths granted so
* far.
* @param path - the directory whose DACL gains the grant. * @param path - the directory whose DACL gains the grant.
*/ */
add(path: string): void { add(path: string): void {
grantWrite(this.api, path, this.sidPtr)
this.grantedPaths.push(path) this.grantedPaths.push(path)
grantWrite(this.api, path, this.sidPtr)
} }
/** Every directory currently carrying the grant, in grant order. */ /** Every directory currently carrying the grant, in grant order. */

View File

@@ -130,6 +130,8 @@ export class AclSandbox {
private api: Win32Bindings | undefined private api: Win32Bindings | undefined
private token: NativePtr | undefined private token: NativePtr | undefined
private writeSidPtr: NativePtr | undefined private writeSidPtr: NativePtr | undefined
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */
private sidAllocations: NativePtr[] = []
private grantedPaths: string[] = [] private grantedPaths: string[] = []
constructor(options: AclSandboxOptions) { constructor(options: AclSandboxOptions) {
@@ -182,16 +184,24 @@ export class AclSandbox {
// remove any (its dispose() must not revoke the caller's standing grant). // remove any (its dispose() must not revoke the caller's standing grant).
if (this.manageDacls) { if (this.manageDacls) {
for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) {
grantWrite(api, path, writeSidPtr) // Record BEFORE granting: grantWrite can throw after a successful
// apply (a LocalFree failure), and the fail-closed catch must still
// revoke that path (revoking an ungranted path is a no-op merge).
this.grantedPaths.push(path) this.grantedPaths.push(path)
grantWrite(api, path, writeSidPtr)
} }
} }
const logonSid = findLogonSid(api, currentToken) const logonSid = findLogonSid(api, currentToken)
this.sidAllocations.push(logonSid)
const worldSid = makeWellKnownSid(api, abi.WinWorldSid)
this.sidAllocations.push(worldSid)
const authUserSid = makeWellKnownSid(api, abi.WinAuthenticatedUserSid)
this.sidAllocations.push(authUserSid)
const restricted = createRestrictedToken( const restricted = createRestrictedToken(
api, currentToken, logonSid, writeSidPtr, api, currentToken, logonSid, writeSidPtr,
{ {
world: makeWellKnownSid(api, abi.WinWorldSid), world: worldSid,
authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), authUser: authUserSid,
}, },
this.mode, this.mode,
) )
@@ -201,7 +211,8 @@ export class AclSandbox {
} catch (error) { } catch (error) {
// Best-effort close on the failure path (last error already captured in `error`). // Best-effort close on the failure path (last error already captured in `error`).
api.closeHandle(currentToken) api.closeHandle(currentToken)
// Fail-closed cleanup: never leave standing grants behind a failed init. // Fail-closed cleanup: never leave standing grants or SID allocations
// behind a failed init.
const cleanupFailures: unknown[] = [] const cleanupFailures: unknown[] = []
const writeSidPtr = this.writeSidPtr const writeSidPtr = this.writeSidPtr
if (writeSidPtr !== undefined) { if (writeSidPtr !== undefined) {
@@ -213,6 +224,14 @@ export class AclSandbox {
} }
} }
} }
for (const sidPtr of this.sidAllocations.splice(0)) {
try {
const freed = api.localFree(sidPtr)
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation')
} catch (cleanupError) {
cleanupFailures.push(cleanupError)
}
}
if (cleanupFailures.length > 0) { if (cleanupFailures.length > 0) {
throw new AggregateError( throw new AggregateError(
[error, ...cleanupFailures], [error, ...cleanupFailures],
@@ -304,6 +323,14 @@ export class AclSandbox {
failures.push(error) failures.push(error)
} }
} }
for (const sidPtr of this.sidAllocations.splice(0)) {
try {
const freed = api.localFree(sidPtr)
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation')
} catch (error) {
failures.push(error)
}
}
this.api = undefined this.api = undefined
this.token = undefined this.token = undefined
this.writeSidPtr = undefined this.writeSidPtr = undefined

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md # pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md
README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085 README.md: d6bf873d559fe0933e7e7ee7d966e2f27053ca05
README.zh.md: adccd4421a74ef073ad3ffc3a23bccb0354d99aa README.zh.md: a618fe435d0c9d862c8dab4ded390de45e1e5d74

View File

@@ -23,7 +23,7 @@ Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-b
##### Exact error ##### Exact error
```markdown ```markdown
sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access.
``` ```
#### Token effect #### Token effect

View File

@@ -23,7 +23,7 @@
##### 精确错误 ##### 精确错误
```markdown ```markdown
sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access.
``` ```
#### Token 影响 #### Token 影响

View File

@@ -131,8 +131,9 @@ export class SandboxUnavailableError extends HarnessError {
super( super(
`sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; ` `sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; `
+ 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing ' + 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing '
+ 'kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement ' + 'kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL '
+ 'backend yet — or switch the consumer to danger-full-access.' + 'restricted-token runner can start (Windows) — otherwise switch the consumer to '
+ 'danger-full-access.'
+ (detail === undefined ? '' : ` Runner failure: ${detail}`), + (detail === undefined ? '' : ` Runner failure: ${detail}`),
SANDBOX_UNAVAILABLE, SANDBOX_UNAVAILABLE,
) )