457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
/**
|
|
* 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')
|
|
})
|
|
})
|