Merge remote-tracking branch 'origin/master' into worktree/fix-minimal-preset-prompt
This commit is contained in:
@@ -168,12 +168,14 @@ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', {
|
||||
dwThreadId: 'uint32',
|
||||
})
|
||||
|
||||
/* v8 ignore start -- layout-mismatch guards fire only on ABI breakage; verify/abi-probe.cpp pins both sizes. */
|
||||
if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) {
|
||||
throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`)
|
||||
}
|
||||
if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) {
|
||||
throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/**
|
||||
* Allocate one pointer-sized slot (for `T **` out-parameters).
|
||||
|
||||
@@ -249,8 +249,12 @@ export class AclSandbox {
|
||||
} catch (error) {
|
||||
// Best-effort close on the failure path (last error already captured in `error`).
|
||||
api.closeHandle(currentToken)
|
||||
// Fail-closed cleanup: never leave a revocable (temp) grant or SID
|
||||
// allocation behind a failed init. Standing workspace ACEs are NOT
|
||||
// FIXME(windows-acl): a failure after createRestrictedToken leaks the restricted
|
||||
// token handle and the parsed write SID — this.api stays undefined, so dispose()
|
||||
// early-returns and cannot clean them up. Close the token and free the write SID
|
||||
// here (the hardening-followup rework already does both).
|
||||
// Fail-closed cleanup: revoke the revocable (temp) grants and free the init SID
|
||||
// allocations a failed init left behind. Standing workspace ACEs are NOT
|
||||
// revoked — they are the intended end state (the reuse cache), not an
|
||||
// error artifact.
|
||||
const cleanupFailures: unknown[] = []
|
||||
@@ -360,6 +364,8 @@ export class AclSandbox {
|
||||
}
|
||||
}
|
||||
const token = this.token
|
||||
/* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always
|
||||
has its token; the guard mirrors the write-SID guard. */
|
||||
if (token !== undefined) {
|
||||
try {
|
||||
if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token')
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* ACL failure-path tests with stub binding tables (the failure-paths.spec.ts
|
||||
* pattern): every checked Win32 call in the lock, read-merge-write, and
|
||||
* grant-skip sequence has a failing counterpart, and each failure closes the
|
||||
* handles it created before throwing. The exact-ACE skip and the DACL-walk
|
||||
* defenses are driven through crafted in-memory ACL/SID buffers. Pure
|
||||
* stubs — no real Win32 calls, so these run on every platform; the
|
||||
* real-FFI round-trip lives in acl.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts'
|
||||
import { allocBytes, ptrAddress } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
/** The stub the grant/revoke happy path needs; every call succeeds until a field is overridden per test. */
|
||||
function aclApi(overrides: Partial<Win32Bindings> = {}): Win32Bindings {
|
||||
return {
|
||||
getTempPathW: vi.fn((_length: number, buffer: Buffer) => {
|
||||
const temp = tmpdir().replace(/[\\/]$/u, '')
|
||||
buffer.write(temp, 'utf16le')
|
||||
return temp.length
|
||||
}),
|
||||
createFileW: vi.fn(() => 7n),
|
||||
lockFileEx: vi.fn(() => 1),
|
||||
unlockFileEx: vi.fn(() => 1),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one
|
||||
koffi.encode(descriptor, PVOID, 0n)
|
||||
return 0
|
||||
}),
|
||||
setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
|
||||
koffi.encode(newAcl, PVOID, 9n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW: vi.fn(() => 0),
|
||||
localFree: vi.fn(() => 0n as NativePtr),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
...overrides,
|
||||
} as unknown as Win32Bindings
|
||||
}
|
||||
|
||||
/** One SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes), subauthorities@8. */
|
||||
function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 5]): NativePtr {
|
||||
const sid = allocBytes(8)
|
||||
koffi.encode(sid, 'uint8', revision)
|
||||
koffi.encode(sid, 1, 'uint8', count)
|
||||
authority.forEach((byte, index) => {
|
||||
koffi.encode(sid, 2 + index, 'uint8', byte)
|
||||
})
|
||||
return sid
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-memory ACL carrying the exact grant ACE the skip checks for:
|
||||
* header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE
|
||||
* (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects
|
||||
* whether the inline SID bytes equal `sid`.
|
||||
*/
|
||||
function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr {
|
||||
const acl = allocBytes(32)
|
||||
koffi.encode(acl, 'uint8', 2) // AclRevision
|
||||
koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE
|
||||
koffi.encode(acl, 4, 'uint16', 1) // AceCount
|
||||
const ace = 8
|
||||
koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE)
|
||||
koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT)
|
||||
koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID
|
||||
koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK)
|
||||
const inlineSid = ace + 8
|
||||
for (let offset = 0; offset < 8; offset++) {
|
||||
koffi.encode(acl, inlineSid + offset, 'uint8', match
|
||||
? koffi.decode(sid, offset, 'uint8') as number
|
||||
: offset === 0 ? 9 : 0)
|
||||
}
|
||||
return acl
|
||||
}
|
||||
|
||||
describe('withPathLock failure paths', () => {
|
||||
it('fails closed when CreateFileW returns an invalid handle', () => {
|
||||
const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) })
|
||||
let caught: unknown
|
||||
try {
|
||||
withPathLock(api, 'C:\\locked', () => {})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateFileW')
|
||||
})
|
||||
|
||||
it('closes the handle and reports when LockFileEx fails', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = aclApi({ lockFileEx: vi.fn(() => 0), closeHandle })
|
||||
let caught: unknown
|
||||
try {
|
||||
withPathLock(api, 'C:\\locked', () => {})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LockFileEx')
|
||||
expect(closeHandle).toHaveBeenCalledWith(7n)
|
||||
})
|
||||
|
||||
it('closes the handle and reports when UnlockFileEx fails', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = aclApi({ unlockFileEx: vi.fn(() => 0), closeHandle })
|
||||
let caught: unknown
|
||||
try {
|
||||
withPathLock(api, 'C:\\locked', () => {})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('UnlockFileEx')
|
||||
expect(closeHandle).toHaveBeenCalledWith(7n)
|
||||
})
|
||||
|
||||
it('reports a failed CloseHandle after a successful action', () => {
|
||||
const api = aclApi({ closeHandle: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
withPathLock(api, 'C:\\locked', () => {})
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CloseHandle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeAndApply failure paths', () => {
|
||||
it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => {
|
||||
const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
})
|
||||
|
||||
it('reports a NULL merged ACL when there is no descriptor to free', () => {
|
||||
const api = aclApi({ setEntriesInAclW: vi.fn(() => 0) }) // no out slot write, no descriptor
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
})
|
||||
|
||||
it('frees the descriptor and reports when SetEntriesInAclW fails', () => {
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL
|
||||
return 0
|
||||
}),
|
||||
setEntriesInAclW: vi.fn(() => 5),
|
||||
localFree,
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
expect(localFree).toHaveBeenCalledWith(6n)
|
||||
})
|
||||
|
||||
it('frees the descriptor and reports a NULL merged ACL', () => {
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot
|
||||
localFree,
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
expect(localFree).toHaveBeenCalledWith(6n)
|
||||
})
|
||||
|
||||
it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => {
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree })
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW')
|
||||
expect(localFree).toHaveBeenCalledWith(9n)
|
||||
})
|
||||
|
||||
it('reports a failed descriptor LocalFree after a successful apply', () => {
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LocalFree')
|
||||
})
|
||||
|
||||
it('reports a failed merged-ACL LocalFree after a successful apply', () => {
|
||||
// No existing descriptor (the default stub): the merge's only LocalFree
|
||||
// is the merged ACL's, which "fails" and is checked after the apply.
|
||||
const api = aclApi({ localFree: vi.fn(() => 1n as NativePtr) })
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LocalFree')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the exact-ACE skip and DACL-walk defenses', () => {
|
||||
it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
localFree,
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(localFree).toHaveBeenCalledWith(6n)
|
||||
})
|
||||
|
||||
it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
|
||||
koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor
|
||||
return 0
|
||||
}),
|
||||
localFree,
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(localFree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
localFree: vi.fn(() => 1n as NativePtr),
|
||||
})
|
||||
let caught: unknown
|
||||
try {
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LocalFree')
|
||||
})
|
||||
|
||||
it('falls back to the merge path when the standing ACE names a different SID', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false)))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats an implausibly small ACL size as no exact grant', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const acl = allocBytes(32)
|
||||
koffi.encode(acl, 'uint8', 2)
|
||||
koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header
|
||||
koffi.encode(acl, 4, 'uint16', 1)
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(acl))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats an ACE that would overrun the ACL as no exact grant', () => {
|
||||
const sid = craftSid(1, 0)
|
||||
const acl = allocBytes(32)
|
||||
koffi.encode(acl, 'uint8', 2)
|
||||
koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE
|
||||
koffi.encode(acl, 4, 'uint16', 1)
|
||||
koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, ptrAddress(acl))
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW,
|
||||
})
|
||||
grantWrite(api, 'C:\\granted', sid)
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revokeWrite no-DACL path', () => {
|
||||
it('reports nothing to revoke when the read yields neither DACL nor descriptor', () => {
|
||||
// The default stub encodes a NULL DACL and a NULL descriptor.
|
||||
const api = aclApi()
|
||||
const sid = craftSid(1, 0)
|
||||
expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false)
|
||||
})
|
||||
|
||||
it('frees a descriptor that carries no DACL and reports nothing to revoke', () => {
|
||||
const localFree = vi.fn(() => 0n as NativePtr)
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL
|
||||
return 0
|
||||
}),
|
||||
localFree,
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false)
|
||||
expect(localFree).toHaveBeenCalledWith(6n)
|
||||
})
|
||||
|
||||
it('reports a failed descriptor LocalFree on the no-DACL path', () => {
|
||||
const api = aclApi({
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 6n)
|
||||
return 0
|
||||
}),
|
||||
localFree: vi.fn(() => 1n as NativePtr),
|
||||
})
|
||||
const sid = craftSid(1, 0)
|
||||
let caught: unknown
|
||||
try {
|
||||
revokeWrite(api, 'C:\\granted', sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('LocalFree')
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,8 @@ import koffi from 'koffi'
|
||||
import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts'
|
||||
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from '../src/spawn.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
@@ -136,3 +137,318 @@ describe('getTempPath buffer defense', () => {
|
||||
expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u)
|
||||
})
|
||||
})
|
||||
|
||||
/** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */
|
||||
function pipeOkApi(overrides: Partial<Win32Bindings> = {}): {
|
||||
api: Win32Bindings
|
||||
closed: bigint[]
|
||||
closeHandle: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const closed: bigint[] = []
|
||||
let next = 1n
|
||||
const closeHandle = vi.fn((handle: NativePtr) => {
|
||||
closed.push(handle)
|
||||
return 1
|
||||
})
|
||||
const api = {
|
||||
createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
|
||||
koffi.encode(readSlot, PVOID, next++)
|
||||
koffi.encode(writeSlot, PVOID, next++)
|
||||
return 1
|
||||
}),
|
||||
setHandleInformation: vi.fn(() => 1),
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
...overrides,
|
||||
} as unknown as Win32Bindings
|
||||
return { api, closed, closeHandle }
|
||||
}
|
||||
|
||||
describe('spawn pipe failures close their handles', () => {
|
||||
const token = 1n as NativePtr
|
||||
|
||||
it('spawnSandboxed reports a CreatePipe failure', () => {
|
||||
const api = { createPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreatePipe')
|
||||
})
|
||||
|
||||
it('spawnSandboxed reports a NULL pipe handle after CreatePipe succeeds', () => {
|
||||
const api = { createPipe: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreatePipe')
|
||||
})
|
||||
|
||||
it('spawnSandboxed reports a SetHandleInformation failure', () => {
|
||||
const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetHandleInformation')
|
||||
})
|
||||
|
||||
it('spawnSandboxed rejects NULL process/thread handles after a successful spawn', () => {
|
||||
const { api } = pipeOkApi({
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
})
|
||||
expect(() => spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }))
|
||||
.toThrow(/null process\/thread handles/u)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnSandboxedInherited failure paths', () => {
|
||||
const token = 1n as NativePtr
|
||||
|
||||
/** The stub the inherited-happy path needs; overrides flip one call per test. */
|
||||
function inheritedApi(overrides: Partial<Win32Bindings> = {}): {
|
||||
api: Win32Bindings
|
||||
closed: bigint[]
|
||||
closeHandle: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const closed: bigint[] = []
|
||||
let std = 50n
|
||||
const closeHandle = vi.fn((handle: NativePtr) => {
|
||||
closed.push(handle)
|
||||
return 1
|
||||
})
|
||||
const api = {
|
||||
createJobObjectW: vi.fn(() => 100n),
|
||||
setInformationJobObject: vi.fn(() => 1),
|
||||
getStdHandle: vi.fn(() => std++),
|
||||
setHandleInformation: vi.fn(() => 1),
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
assignProcessToJobObject: vi.fn(() => 1),
|
||||
resumeThread: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
...overrides,
|
||||
} as unknown as Win32Bindings
|
||||
return { api, closed, closeHandle }
|
||||
}
|
||||
|
||||
it('closes the job and reports when GetStdHandle yields a NULL handle', () => {
|
||||
const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetStdHandle')
|
||||
expect(closeHandle).toHaveBeenCalledWith(100n)
|
||||
})
|
||||
|
||||
it('reports a SetHandleInformation failure while enabling stdio inheritance', () => {
|
||||
const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetHandleInformation')
|
||||
})
|
||||
|
||||
it('closes the job and reports when CreateProcessAsUserW fails', () => {
|
||||
const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateProcessAsUserW')
|
||||
expect(closeHandle).toHaveBeenCalledWith(100n)
|
||||
})
|
||||
|
||||
it('closes the job and rejects NULL process/thread handles after a successful spawn', () => {
|
||||
const { api, closeHandle } = inheritedApi({
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
})
|
||||
expect(() => spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }))
|
||||
.toThrow(/null process\/thread handles/u)
|
||||
expect(closeHandle).toHaveBeenCalledWith(100n)
|
||||
})
|
||||
|
||||
it('closes the job and reports when SetInformationJobObject fails', () => {
|
||||
const { api, closeHandle } = inheritedApi({ setInformationJobObject: vi.fn(() => 0) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetInformationJobObject')
|
||||
expect(closeHandle).toHaveBeenCalledWith(100n)
|
||||
})
|
||||
|
||||
it('closes the job and reports a NULL job object', () => {
|
||||
const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) })
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateJobObjectW')
|
||||
})
|
||||
|
||||
it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => {
|
||||
const { api, closeHandle } = inheritedApi()
|
||||
const spawned = spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
expect(spawned.pid).toBe(1234)
|
||||
expect(spawned.process).toBe(200n)
|
||||
expect(spawned.job).toBe(100n)
|
||||
// thread handle closed by the spawn; process and job handles stay with the caller.
|
||||
expect(closeHandle).toHaveBeenCalledWith(201n)
|
||||
expect(closeHandle).not.toHaveBeenCalledWith(200n)
|
||||
expect(closeHandle).not.toHaveBeenCalledWith(100n)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drainPipe', () => {
|
||||
it('stops at ERROR_NO_DATA and closes the read end', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = {
|
||||
peekNamedPipe: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => abi.ERROR_NO_DATA),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return drainPipe(api, 30n as NativePtr).then((buffer) => {
|
||||
expect(buffer.length).toBe(0)
|
||||
expect(closeHandle).toHaveBeenCalledWith(30n)
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a PeekNamedPipe failure that is not a clean EOF', () => {
|
||||
const api = {
|
||||
peekNamedPipe: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' })
|
||||
})
|
||||
|
||||
it('reports a ReadFile failure after data was reported available', () => {
|
||||
const api = {
|
||||
peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => {
|
||||
koffi.encode(totalAvail, 'uint32', 4)
|
||||
return 1
|
||||
}),
|
||||
readFile: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' })
|
||||
})
|
||||
|
||||
it('drains one chunk and stops at ERROR_BROKEN_PIPE', () => {
|
||||
let peeks = 0
|
||||
const api = {
|
||||
peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => {
|
||||
peeks++
|
||||
if (peeks > 1) return 0
|
||||
koffi.encode(totalAvail, 'uint32', 4)
|
||||
return 1
|
||||
}),
|
||||
readFile: vi.fn((_file: unknown, chunk: Buffer, _count: unknown, read: NativePtr) => {
|
||||
chunk.write('ab', 0, 'utf8')
|
||||
koffi.encode(read, 'uint32', 2)
|
||||
return 1
|
||||
}),
|
||||
getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return drainPipe(api, 30n as NativePtr).then((buffer) => {
|
||||
expect(buffer.toString('utf8')).toBe('ab')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForExit', () => {
|
||||
it('reports a WaitForSingleObject failure', () => {
|
||||
const api = {
|
||||
waitForSingleObject: vi.fn(() => 0xFFFFFFFF),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error)
|
||||
})
|
||||
|
||||
it('reports a GetExitCodeProcess failure', () => {
|
||||
const api = {
|
||||
waitForSingleObject: vi.fn(() => 0),
|
||||
getExitCodeProcess: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error)
|
||||
})
|
||||
|
||||
it('returns the exit code and closes the process handle', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = {
|
||||
waitForSingleObject: vi.fn(() => 0),
|
||||
getExitCodeProcess: vi.fn((_process: unknown, slot: NativePtr) => {
|
||||
koffi.encode(slot, 'uint32', 42)
|
||||
return 1
|
||||
}),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
expect(waitForExit(api, 200n as NativePtr)).toBe(42)
|
||||
expect(closeHandle).toHaveBeenCalledWith(200n)
|
||||
})
|
||||
})
|
||||
|
||||
202
packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts
Normal file
202
packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* FFI helper tests with stub binding tables (the failure-paths.spec.ts
|
||||
* pattern): error formatting and temp-path decoding defenses, the
|
||||
* last-error throwers' detail fallback, pointer decode NULL handling, and
|
||||
* the bounded SID comparison's early exits. Pure stubs — no real Win32
|
||||
* calls, so these run on every platform; the real-FFI round-trip lives in
|
||||
* acl.spec.ts and probe.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import {
|
||||
allocBytes, decodePtr, decodePtrAt, errorText, getTempPath,
|
||||
isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32,
|
||||
} from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */
|
||||
function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType<typeof vi.fn> } {
|
||||
const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => {
|
||||
const text = 'access denied'
|
||||
buffer.write(text, 'utf16le')
|
||||
return text.length
|
||||
})
|
||||
const api = {
|
||||
formatMessageW,
|
||||
getLastError: vi.fn(() => 5),
|
||||
} as unknown as Win32Bindings
|
||||
return { api, formatMessageW }
|
||||
}
|
||||
|
||||
/** A minimal SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2, subauthorities@8. */
|
||||
function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 0], subs: number[] = []): NativePtr {
|
||||
const sid = allocBytes(8 + subs.length * 4)
|
||||
koffi.encode(sid, 'uint8', revision)
|
||||
koffi.encode(sid, 1, 'uint8', count)
|
||||
authority.forEach((byte, index) => {
|
||||
koffi.encode(sid, 2 + index, 'uint8', byte)
|
||||
})
|
||||
subs.forEach((sub, index) => {
|
||||
koffi.encode(sid, 8 + index * 4, 'uint32', sub)
|
||||
})
|
||||
return sid
|
||||
}
|
||||
|
||||
describe('errorText', () => {
|
||||
it('decodes the formatted UTF-16 message and trims it', () => {
|
||||
const { api } = formatApi()
|
||||
expect(errorText(api, 5)).toBe('access denied')
|
||||
})
|
||||
|
||||
it('returns an empty string when FormatMessageW formats nothing', () => {
|
||||
const api = { formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
|
||||
expect(errorText(api, 5)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTempPath', () => {
|
||||
it('decodes the NUL-terminated temp path GetTempPathW wrote', () => {
|
||||
const api = {
|
||||
getTempPathW: vi.fn((_length: number, buffer: Buffer) => {
|
||||
buffer.write('C:\\TEMP', 'utf16le')
|
||||
return 7
|
||||
}),
|
||||
} as unknown as Win32Bindings
|
||||
expect(getTempPath(api)).toBe('C:\\TEMP')
|
||||
})
|
||||
|
||||
it('reports the Win32 failure when GetTempPathW writes nothing', () => {
|
||||
const { api } = formatApi()
|
||||
const failing = { ...api, getTempPathW: vi.fn(() => 0) } as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
getTempPath(failing)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTempPathW')
|
||||
})
|
||||
})
|
||||
|
||||
describe('throwLastError and throwWin32', () => {
|
||||
it('throwLastError formats the system message when no detail is given', () => {
|
||||
const { api } = formatApi()
|
||||
let caught: unknown
|
||||
try {
|
||||
throwLastError(api, 'Probe')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied')
|
||||
})
|
||||
|
||||
it('throwWin32 formats the system message when no detail is given', () => {
|
||||
const { api } = formatApi()
|
||||
let caught: unknown
|
||||
try {
|
||||
throwWin32(api, 'Probe', 5)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied')
|
||||
})
|
||||
|
||||
it('Win32Error appends the detail when one is given', () => {
|
||||
const error = new Win32Error('Probe', 5, 'the lock file path')
|
||||
expect(error.name).toBe('Win32Error')
|
||||
expect(error.api).toBe('Probe')
|
||||
expect(error.win32Code).toBe(5)
|
||||
expect(error.message).toBe('Probe failed (Win32 5): the lock file path')
|
||||
})
|
||||
|
||||
it('Win32Error omits the detail suffix when none is given', () => {
|
||||
const error = new Win32Error('Probe', 5)
|
||||
expect(error.message).toBe('Probe failed (Win32 5)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointer NULL handling', () => {
|
||||
it('isNullPtr accepts null, undefined, and the zero pointer', () => {
|
||||
expect(isNullPtr(null)).toBe(true)
|
||||
expect(isNullPtr(undefined)).toBe(true)
|
||||
expect(isNullPtr(0n as NativePtr)).toBe(true)
|
||||
expect(isNullPtr(42n as NativePtr)).toBe(false)
|
||||
})
|
||||
|
||||
it('isInvalidHandle treats NULL as failure', () => {
|
||||
expect(isInvalidHandle(null)).toBe(true)
|
||||
expect(isInvalidHandle(undefined)).toBe(true)
|
||||
expect(isInvalidHandle(0n as NativePtr)).toBe(true)
|
||||
expect(isInvalidHandle(42n as NativePtr)).toBe(false)
|
||||
})
|
||||
|
||||
it('decodePtrAt returns null for a NULL pointer stored in a buffer', () => {
|
||||
const buffer = Buffer.alloc(8)
|
||||
buffer.writeBigUInt64LE(0n, 0)
|
||||
expect(decodePtrAt(buffer, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('decodePtrAt returns the stored pointer value', () => {
|
||||
const buffer = Buffer.alloc(8)
|
||||
buffer.writeBigUInt64LE(42n, 0)
|
||||
expect(decodePtrAt(buffer, 0)).toBe(42n)
|
||||
})
|
||||
|
||||
it('decodePtr returns null for an unset out-parameter slot', () => {
|
||||
const slot = koffi.alloc(PVOID, 1) as unknown as NativePtr
|
||||
expect(decodePtr(slot)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameSidAt bounded comparison', () => {
|
||||
it('rejects a revision mismatch before comparing anything else', () => {
|
||||
const left = craftSid(1, 0)
|
||||
const right = craftSid(2, 0)
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a subauthority-count mismatch', () => {
|
||||
const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42])
|
||||
const right = craftSid(1, 2, [0, 0, 0, 0, 0, 5], [42, 43])
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an implausible subauthority count', () => {
|
||||
const left = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1)
|
||||
const right = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1)
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a differing identifier authority byte', () => {
|
||||
const left = craftSid(1, 0, [0, 0, 0, 0, 0, 5])
|
||||
const right = craftSid(1, 0, [0, 0, 0, 0, 0, 6])
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts identical SIDs at nonzero offsets over differing leading bytes', () => {
|
||||
const sid = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42])
|
||||
// Embed the same SID bytes at offset 4 of two buffers whose first four
|
||||
// bytes differ: an offset-ignoring comparison reads the differing
|
||||
// prefixes and must reject.
|
||||
const left = allocBytes(4 + 12)
|
||||
const right = allocBytes(4 + 12)
|
||||
koffi.encode(left, 0, 'uint32', 0x11111111)
|
||||
koffi.encode(right, 0, 'uint32', 0x22222222)
|
||||
for (let offset = 0; offset < 12; offset++) {
|
||||
const byte = koffi.decode(sid, offset, 'uint8') as number
|
||||
koffi.encode(left, 4 + offset, 'uint8', byte)
|
||||
koffi.encode(right, 4 + offset, 'uint8', byte)
|
||||
}
|
||||
expect(sameSidAt(left, 4, right, 4)).toBe(true)
|
||||
expect(sameSidAt(left, 0, right, 0)).toBe(false) // the differing prefixes are not a matching SID
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* AclSandbox orchestration failure-path tests: the win32 resolver is mocked
|
||||
* to hand each test a stub binding table, so every checked Win32 call in
|
||||
* init/spawn/dispose has a failing counterpart without opening real token or
|
||||
* ACL handles. Constructor validation, the fail-closed init cleanup, and the
|
||||
* dispose aggregation use the same stubs. Pure stubs — no real Win32 calls,
|
||||
* so these run on every platform; the real-FFI round-trip lives in
|
||||
* acl.spec.ts and runner.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { PROCESS_INFORMATION } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import { AclSandbox } from '../src/index.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
type MockFn = ReturnType<typeof vi.fn>
|
||||
|
||||
/** The stub binding table plus the mocks the assertions inspect directly. */
|
||||
interface HappyStubs {
|
||||
api: Win32Bindings
|
||||
setNamedSecurityInfoW: MockFn
|
||||
convertStringSidToSidW: MockFn
|
||||
closeHandle: MockFn
|
||||
localFree: MockFn
|
||||
createRestrictedToken: MockFn
|
||||
createJobObjectW: MockFn
|
||||
getNamedSecurityInfoW: MockFn
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined }))
|
||||
|
||||
vi.mock('../src/ffi.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/ffi.ts')>()
|
||||
return {
|
||||
...actual,
|
||||
win32: () => Promise.resolve(state.stubs?.api as Win32Bindings),
|
||||
win32Sync: () => state.stubs?.api as Win32Bindings,
|
||||
}
|
||||
})
|
||||
|
||||
const scratchDirs: string[] = []
|
||||
afterAll(() => {
|
||||
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function scratch(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-index-'))
|
||||
scratchDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* The stub the whole happy pipeline needs: token opening, write-SID parse,
|
||||
* workspace+temp grants, logon-SID scan, well-known SID, restricted token,
|
||||
* default-DACL merge, piped/inherited spawns, drains, and exit waits all
|
||||
* succeed. Every test flips one call per branch.
|
||||
*/
|
||||
function happyStubs(): HappyStubs {
|
||||
let next = 0n
|
||||
const fresh = () => ++next
|
||||
|
||||
const openProcess = vi.fn(() => fresh())
|
||||
const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, fresh())
|
||||
return 1
|
||||
})
|
||||
const convertStringSidToSidW = vi.fn((_sid: string, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, fresh())
|
||||
return 1
|
||||
})
|
||||
const getTempPathW = vi.fn((_length: number, buffer: Buffer) => {
|
||||
const temp = tmpdir().replace(/[\\/]$/u, '')
|
||||
buffer.write(temp, 'utf16le')
|
||||
return temp.length
|
||||
})
|
||||
const createFileW = vi.fn(() => fresh())
|
||||
const getNamedSecurityInfoW = vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 0n)
|
||||
return 0
|
||||
})
|
||||
const setEntriesInAclW = vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
|
||||
koffi.encode(newAcl, PVOID, fresh())
|
||||
return 0
|
||||
})
|
||||
const setNamedSecurityInfoW = vi.fn(() => 0)
|
||||
const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
|
||||
if (info === null) {
|
||||
koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8)
|
||||
return 0 // the size probe is expected to "fail"
|
||||
}
|
||||
if (cls === abi.TokenGroups) {
|
||||
info.writeUInt32LE(1, 0)
|
||||
info.writeBigUInt64LE(77n, abi.TOKEN_GROUPS_OFFSET)
|
||||
info.writeUInt32LE(abi.SE_GROUP_LOGON_ID, abi.TOKEN_GROUPS_OFFSET + 8)
|
||||
} else {
|
||||
info.writeBigUInt64LE(88n, 0) // the token's current default DACL
|
||||
}
|
||||
return 1
|
||||
})
|
||||
const getLengthSid = vi.fn(() => 12)
|
||||
const copySid = vi.fn(() => 1)
|
||||
const createWellKnownSid = vi.fn(() => 1)
|
||||
const isValidSid = vi.fn(() => 1)
|
||||
const createRestrictedToken = vi.fn((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
_rc: unknown, _rs: unknown, slot: NativePtr,
|
||||
) => {
|
||||
koffi.encode(slot, PVOID, fresh())
|
||||
return 1
|
||||
})
|
||||
const setTokenInformation = vi.fn(() => 1)
|
||||
const createPipe = vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
|
||||
koffi.encode(readSlot, PVOID, fresh())
|
||||
koffi.encode(writeSlot, PVOID, fresh())
|
||||
return 1
|
||||
})
|
||||
const setHandleInformation = vi.fn(() => 1)
|
||||
const createProcessAsUserW = vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
})
|
||||
const peekNamedPipe = vi.fn(() => 0)
|
||||
const readFile = vi.fn(() => 1)
|
||||
const waitForSingleObject = vi.fn(() => 0)
|
||||
const getExitCodeProcess = vi.fn((_process: unknown, slot: NativePtr) => {
|
||||
koffi.encode(slot, 'uint32', 42)
|
||||
return 1
|
||||
})
|
||||
const createJobObjectW = vi.fn(() => fresh())
|
||||
const setInformationJobObject = vi.fn(() => 1)
|
||||
const assignProcessToJobObject = vi.fn(() => 1)
|
||||
const resumeThread = vi.fn(() => 0)
|
||||
const getStdHandle = vi.fn(() => fresh())
|
||||
const localFree = vi.fn(() => 0n)
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF
|
||||
const formatMessageW = vi.fn(() => 0)
|
||||
|
||||
const api = {
|
||||
openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW,
|
||||
lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1),
|
||||
getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation,
|
||||
getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken,
|
||||
setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW,
|
||||
peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW,
|
||||
setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle,
|
||||
localFree, closeHandle, getLastError, formatMessageW,
|
||||
} as unknown as Win32Bindings
|
||||
return {
|
||||
api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree,
|
||||
createRestrictedToken, createJobObjectW, getNamedSecurityInfoW,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.stubs = happyStubs()
|
||||
})
|
||||
|
||||
describe('AclSandbox constructor validation', () => {
|
||||
it('rejects a writable directory that does not exist', () => {
|
||||
const missing = join(scratch(), 'missing')
|
||||
expect(() => new AclSandbox({ writableDirs: [missing], tempDir: null, mode: 'read-only' }))
|
||||
.toThrow(/writable dir does not exist/u)
|
||||
})
|
||||
|
||||
it('resolves relative writable directories to absolute paths', () => {
|
||||
const dir = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' })
|
||||
expect(sandbox.writableDirs).toEqual([resolve(dir)])
|
||||
expect(sandbox.mode).toBe('read-only')
|
||||
expect(sandbox.tempDir).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AclSandbox init', () => {
|
||||
it('completes the happy workspace-write pipeline: workspace and temp grants, restricted token, resolved temp dir', async () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
expect(sandbox.tempDir).toBe(resolve(temp))
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('defaults the temp dir to GetTempPathW when no tempDir option is given', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
expect(sandbox.tempDir).toBe(tmpdir().replace(/[\\/]$/u, ''))
|
||||
})
|
||||
|
||||
it('applies no grants when the temp dir option is null', async () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only
|
||||
})
|
||||
|
||||
it('rejects a temp dir that does not exist', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), writeSid: 'S-1-4-9000-4', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u)
|
||||
})
|
||||
|
||||
it('builds a read-only token without parsing a write SID or applying grants', async () => {
|
||||
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' })
|
||||
await sandbox.init()
|
||||
expect(convertStringSidToSidW).not.toHaveBeenCalled()
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free
|
||||
})
|
||||
|
||||
it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false })
|
||||
await sandbox.init()
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke
|
||||
})
|
||||
|
||||
it('refuses a second init on the same instance', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
await expect(sandbox.init()).rejects.toThrow(/already initialized/u)
|
||||
})
|
||||
|
||||
it('reports a ConvertStringSidToSidW failure before granting anything', async () => {
|
||||
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
convertStringSidToSidW.mockReturnValue(0)
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' })
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => {
|
||||
const { convertStringSidToSidW } = state.stubs as HappyStubs
|
||||
convertStringSidToSidW.mockImplementation(() => 1) // no out slot write
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error)
|
||||
})
|
||||
|
||||
it('reports a failed close of the current process token', async () => {
|
||||
const { closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' })
|
||||
// fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the
|
||||
// token-layer close of 1n succeeds and init's close of 2n fails.
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1))
|
||||
// The failure lands after this.token is stored but before this.api is
|
||||
// assigned; the catch drains the SID allocations and rethrows the
|
||||
// original error. (The stored restricted token and parsed write SID leak
|
||||
// until process exit — see the FIXME in init's catch.)
|
||||
await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' })
|
||||
})
|
||||
|
||||
it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => {
|
||||
const { createRestrictedToken, localFree, getNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
let inCleanup = false
|
||||
createRestrictedToken.mockImplementation(() => {
|
||||
inCleanup = true // the grants already landed: every later call is the cleanup's
|
||||
return 0
|
||||
})
|
||||
localFree.mockImplementation(() => (inCleanup ? 1n : 0n))
|
||||
getNamedSecurityInfoW.mockImplementation((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
if (inCleanup) return 2 // the cleanup's revocation read fails too
|
||||
koffi.encode(dacl, PVOID, 0n)
|
||||
koffi.encode(descriptor, PVOID, 0n)
|
||||
return 0
|
||||
})
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-10', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toThrow(/3 grant revocation\(s\) also failed/u)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AclSandbox spawn', () => {
|
||||
it('refuses to spawn before init', () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' })
|
||||
expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u)
|
||||
})
|
||||
|
||||
it('pipe spawn drains empty pipes and settles with the child exit code', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace })
|
||||
expect(child.pid).toBe(1234)
|
||||
const expected = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }
|
||||
await expect(child.wait()).resolves.toEqual(expected)
|
||||
// The second wait reuses the settled exit-code promise instead of re-waiting.
|
||||
await expect(child.wait()).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => {
|
||||
const { closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
|
||||
await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 })
|
||||
expect(closeHandle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inherit spawn reports a failed close of the kill-on-close job', async () => {
|
||||
const { closeHandle, createJobObjectW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
let jobHandle = 0n
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1))
|
||||
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
|
||||
jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr
|
||||
await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AclSandbox dispose', () => {
|
||||
it('is a no-op before init', () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' })
|
||||
expect(() => { sandbox.dispose() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('aggregates a failing temp revocation into an AggregateError', async () => {
|
||||
const { getNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-16', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
getNamedSecurityInfoW.mockReturnValue(2)
|
||||
expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u)
|
||||
})
|
||||
|
||||
it('aggregates SID and token cleanup failures into an AggregateError', async () => {
|
||||
const { localFree } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
localFree.mockReturnValue(1n)
|
||||
expect(() => { sandbox.dispose() }).toThrow(AggregateError)
|
||||
})
|
||||
|
||||
it('reports a failed close of the restricted token', async () => {
|
||||
const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' })
|
||||
let restrictedToken = 0n
|
||||
createRestrictedToken.mockImplementation((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
_rc: unknown, _rs: unknown, slot: NativePtr,
|
||||
) => {
|
||||
restrictedToken = 99n
|
||||
koffi.encode(slot, PVOID, restrictedToken)
|
||||
return 1
|
||||
})
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === restrictedToken ? 0 : 1))
|
||||
await sandbox.init()
|
||||
expect(() => { sandbox.dispose() }).toThrow(AggregateError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Restricted-token failure-path tests with stub binding tables (the
|
||||
* failure-paths.spec.ts pattern): every checked Win32 call in the token
|
||||
* pipeline — open, logon-SID scan, well-known SID creation, default-DACL
|
||||
* merge, restricted-token creation — has a failing counterpart, and each
|
||||
* failure closes or frees what it created before throwing. Pure stubs — no
|
||||
* real Win32 calls, so these run on every platform; the real-FFI round-trip
|
||||
* lives in acl.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { allocBytes, isNullPtr } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import {
|
||||
createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant,
|
||||
} from '../src/token.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
describe('openCurrentProcessToken failure paths', () => {
|
||||
it('reports when OpenProcess yields no handle', () => {
|
||||
const api = {
|
||||
openProcess: vi.fn(() => 0n),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
openCurrentProcessToken(api)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('OpenProcess')
|
||||
})
|
||||
|
||||
it('closes the process handle and reports when OpenProcessToken fails', () => {
|
||||
const closeHandle = vi.fn(() => 1)
|
||||
const api = {
|
||||
openProcess: vi.fn(() => 7n),
|
||||
openProcessToken: vi.fn(() => 0),
|
||||
closeHandle,
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
openCurrentProcessToken(api)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('OpenProcessToken')
|
||||
expect(closeHandle).toHaveBeenCalledWith(7n)
|
||||
})
|
||||
|
||||
it('reports a failed CloseHandle of the process handle', () => {
|
||||
const api = {
|
||||
openProcess: vi.fn(() => 7n),
|
||||
openProcessToken: vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, 9n)
|
||||
return 1
|
||||
}),
|
||||
closeHandle: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
openCurrentProcessToken(api)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CloseHandle')
|
||||
})
|
||||
|
||||
it('rejects a NULL token handle after a successful OpenProcessToken', () => {
|
||||
const api = {
|
||||
openProcess: vi.fn(() => 7n),
|
||||
openProcessToken: vi.fn(() => 1), // succeeds without writing the out slot
|
||||
closeHandle: vi.fn(() => 1),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
openCurrentProcessToken(api)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('OpenProcessToken')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The stub the logon-SID scan needs: the size probe writes `needed`, the
|
||||
* second call fills a TOKEN_GROUPS buffer (GroupCount@0, SID pointer@8,
|
||||
* attributes@16) with the state's one group. The CopySid mock comes back
|
||||
* beside the table for the one test that asserts on its arguments.
|
||||
*/
|
||||
function logonApi(state: {
|
||||
needed: number
|
||||
groupCount: number
|
||||
sidPtr: bigint
|
||||
logon: boolean
|
||||
secondOk?: boolean
|
||||
sidLength?: number
|
||||
copyOk?: boolean
|
||||
}): { api: Win32Bindings; copySid: ReturnType<typeof vi.fn> } {
|
||||
const copySid = vi.fn(() => (state.copyOk === false ? 0 : 1))
|
||||
const api = {
|
||||
getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
|
||||
if (cls !== abi.TokenGroups) throw new Error(`unexpected token information class ${cls}`)
|
||||
if (info === null) {
|
||||
koffi.encode(needed, 'uint32', state.needed)
|
||||
return 0 // the size probe is expected to "fail"
|
||||
}
|
||||
if (state.secondOk === false) return 0
|
||||
info.writeUInt32LE(state.groupCount, 0)
|
||||
if (state.groupCount > 0) {
|
||||
info.writeBigUInt64LE(state.sidPtr, abi.TOKEN_GROUPS_OFFSET)
|
||||
info.writeUInt32LE(state.logon ? abi.SE_GROUP_LOGON_ID : 0, abi.TOKEN_GROUPS_OFFSET + 8)
|
||||
}
|
||||
return 1
|
||||
}),
|
||||
getLengthSid: vi.fn(() => state.sidLength ?? 12),
|
||||
copySid,
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return { api, copySid }
|
||||
}
|
||||
|
||||
describe('findLogonSid failure paths', () => {
|
||||
const token = 9n as NativePtr
|
||||
|
||||
it('reports a size probe that wrote nothing', () => {
|
||||
const { api } = logonApi({ needed: 0, groupCount: 0, sidPtr: 0n, logon: false })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('rejects an implausibly small TokenGroups size', () => {
|
||||
const { api } = logonApi({ needed: 4, groupCount: 0, sidPtr: 0n, logon: false })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('reports a failed TokenGroups read', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, secondOk: false })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('skips a NULL group SID pointer and throws when no logon SID remains', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 0n, logon: true })
|
||||
expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u)
|
||||
})
|
||||
|
||||
it('skips a non-logon group and throws when no logon SID remains', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: false })
|
||||
expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u)
|
||||
})
|
||||
|
||||
it('reports a zero logon-SID length', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, sidLength: 0 })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetLengthSid')
|
||||
})
|
||||
|
||||
it('reports a failed CopySid of the logon SID', () => {
|
||||
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, copyOk: false })
|
||||
let caught: unknown
|
||||
try {
|
||||
findLogonSid(api, token)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CopySid')
|
||||
})
|
||||
|
||||
it('copies the logon SID and returns the new allocation', () => {
|
||||
const { api, copySid } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true })
|
||||
const copy = findLogonSid(api, token)
|
||||
expect(isNullPtr(copy)).toBe(false)
|
||||
expect(copySid).toHaveBeenCalledWith(12, copy, 77n)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeWellKnownSid failure paths', () => {
|
||||
it('reports when CreateWellKnownSid fails', () => {
|
||||
const api = {
|
||||
createWellKnownSid: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
makeWellKnownSid(api, abi.WinWorldSid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateWellKnownSid')
|
||||
})
|
||||
|
||||
it('reports when the created well-known SID is invalid', () => {
|
||||
const api = {
|
||||
createWellKnownSid: vi.fn(() => 1),
|
||||
isValidSid: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
let caught: unknown
|
||||
try {
|
||||
makeWellKnownSid(api, abi.WinWorldSid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('IsValidSid')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The stub the default-DACL merge needs: the size probe writes `needed`, the
|
||||
* second call fills the DACL pointer slot, and the merge/apply calls follow
|
||||
* the state's results.
|
||||
*/
|
||||
function daclApi(state: {
|
||||
needed: number
|
||||
currentDacl: bigint
|
||||
secondOk?: boolean
|
||||
mergeResult?: number
|
||||
newDacl: bigint
|
||||
setTokenInfo?: number
|
||||
}): Win32Bindings {
|
||||
const api = {
|
||||
getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
|
||||
if (cls !== abi.TokenDefaultDacl) throw new Error(`unexpected token information class ${cls}`)
|
||||
if (info === null) {
|
||||
koffi.encode(needed, 'uint32', state.needed)
|
||||
return 0 // the size probe is expected to "fail"
|
||||
}
|
||||
if (state.secondOk === false) return 0
|
||||
info.writeBigUInt64LE(state.currentDacl, 0)
|
||||
return 1
|
||||
}),
|
||||
setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
|
||||
if (state.mergeResult !== undefined && state.mergeResult !== 0) return state.mergeResult
|
||||
koffi.encode(newAcl, PVOID, state.newDacl)
|
||||
return 0
|
||||
}),
|
||||
setTokenInformation: vi.fn(() => state.setTokenInfo ?? 1),
|
||||
localFree: vi.fn(() => 0n),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return api
|
||||
}
|
||||
|
||||
describe('setTokenDefaultDaclGrant failure paths', () => {
|
||||
const token = 9n as NativePtr
|
||||
const sid = 77n as NativePtr
|
||||
|
||||
it('reports a size probe that wrote nothing', () => {
|
||||
const api = daclApi({ needed: 0, currentDacl: 0n, newDacl: 0n })
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('reports a failed default-DACL read', () => {
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, secondOk: false, newDacl: 0n })
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('GetTokenInformation')
|
||||
})
|
||||
|
||||
it('rejects a token that carries no default DACL', () => {
|
||||
const api = daclApi({ needed: 8, currentDacl: 0n, newDacl: 0n })
|
||||
expect(() => { setTokenDefaultDaclGrant(api, token, sid) }).toThrow(/no default DACL/u)
|
||||
})
|
||||
|
||||
it('reports a failed SetEntriesInAclW merge', () => {
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, mergeResult: 5, newDacl: 0n })
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
})
|
||||
|
||||
it('rejects a NULL merged default DACL', () => {
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 0n })
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
|
||||
})
|
||||
|
||||
it('frees the merged DACL and reports when SetTokenInformation fails', () => {
|
||||
const localFree = vi.fn(() => 0n)
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n, setTokenInfo: 0 })
|
||||
;(api.localFree as unknown as ReturnType<typeof vi.fn>).mockImplementation(localFree)
|
||||
let caught: unknown
|
||||
try {
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('SetTokenInformation')
|
||||
expect(localFree).toHaveBeenCalledWith(99n)
|
||||
})
|
||||
|
||||
it('frees the merged DACL after a successful apply', () => {
|
||||
const localFree = vi.fn(() => 0n)
|
||||
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n })
|
||||
;(api.localFree as unknown as ReturnType<typeof vi.fn>).mockImplementation(localFree)
|
||||
setTokenDefaultDaclGrant(api, token, sid)
|
||||
expect(localFree).toHaveBeenCalledWith(99n)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createRestrictedToken failure paths', () => {
|
||||
it('builds the read-only restricting list without a write SID', () => {
|
||||
const create = vi.fn((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
count: number, _sids: unknown, slot: NativePtr,
|
||||
) => {
|
||||
koffi.encode(slot, PVOID, 9n)
|
||||
expect(count).toBe(2)
|
||||
return 1
|
||||
})
|
||||
const api = { createRestrictedToken: create } as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
expect(createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')).toBe(9n)
|
||||
})
|
||||
|
||||
it('builds the workspace-write restricting list with the write SID', () => {
|
||||
const create = vi.fn((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
count: number, _sids: unknown, slot: NativePtr,
|
||||
) => {
|
||||
koffi.encode(slot, PVOID, 9n)
|
||||
expect(count).toBe(3)
|
||||
return 1
|
||||
})
|
||||
const api = { createRestrictedToken: create } as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
expect(createRestrictedToken(api, 1n as NativePtr, logon, 3n as NativePtr, { world: 2n as NativePtr }, 'workspace-write')).toBe(9n)
|
||||
})
|
||||
|
||||
it('reports when CreateRestrictedToken fails', () => {
|
||||
const api = {
|
||||
createRestrictedToken: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
let caught: unknown
|
||||
try {
|
||||
createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateRestrictedToken')
|
||||
})
|
||||
|
||||
it('rejects a NULL token handle after a successful CreateRestrictedToken', () => {
|
||||
const api = {
|
||||
createRestrictedToken: vi.fn(() => 1), // succeeds without writing the out slot
|
||||
getLastError: vi.fn(() => 5),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
let caught: unknown
|
||||
try {
|
||||
createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateRestrictedToken')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user