diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index 0705e34..479adf2 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -55,6 +55,7 @@ */ const objectFreeze = Object.freeze; const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; const objectHasOwn = Object.hasOwn; const arrayIsArray = Array.isArray; const numberIsInteger = Number.isInteger; @@ -77,14 +78,29 @@ export function containsValue(list: readonly string[], value: unknown): boolean return false; } -/** Append by defining an own element, bypassing inherited index setters. */ +/** + * Append by defining an own element, bypassing inherited index setters. + * + * The descriptor is given a `null` prototype through the captured + * `Object.setPrototypeOf` before the captured `Object.defineProperty` consumes + * it. A hostile getter or Proxy trap read earlier during evaluation may have + * installed `Object.prototype.get`/`.set`; an ordinary `{...}` descriptor would + * inherit those, and `ToPropertyDescriptor` — which walks the prototype chain — + * would then observe inherited accessor keys beside the own `value`/`writable` + * keys, reject the mixed descriptor, and throw. Every append runs on a + * never-throws authority path (validated lists, `invalidFields` reporting, and + * the imported permit-id builder), so the descriptor is insulated. Descriptor + * flags, index semantics, and ordering are unchanged. + */ export function append(list: T[], value: T): void { - objectDefineProperty(list, list.length, { + const descriptor: PropertyDescriptor = { value, writable: true, enumerable: true, configurable: true, - }); + }; + objectSetPrototypeOf(descriptor, null); + objectDefineProperty(list, list.length, descriptor); } /** diff --git a/tests/domain/execution-permit.test.ts b/tests/domain/execution-permit.test.ts index 9333c61..010df14 100644 --- a/tests/domain/execution-permit.test.ts +++ b/tests/domain/execution-permit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { authorizeJobOperation, @@ -414,6 +414,102 @@ describe('permit identity cannot be collided by operand content', () => { }); }); +describe('C1-RJ-F1: permit issuance survives prototype poisoning via the shared append', () => { + // issueExecutionPermit builds its permitId through the imported repair-job + // `append`. Before the repair, ambient Object.prototype.get/.set poison made + // that helper throw, so an otherwise-authorized ALLOW_ONCE mint threw instead + // of returning a permit. This proves the single repair-job change closes the + // consumer without any edit to execution-permit.ts itself. + const captureSetPrototypeOf = Object.setPrototypeOf; + function withPrototypePoison(keys: readonly ('get' | 'set')[], body: () => T): T { + const saved: Record = {}; + for (const key of keys) { + saved[key] = Object.getOwnPropertyDescriptor(Object.prototype, key); + // Null-prototype the installer's own descriptor so installing `set` while + // `get` is present does not reproduce the very bug under test. + const descriptor: PropertyDescriptor = { + value: function () {}, + configurable: true, + writable: true, + }; + captureSetPrototypeOf(descriptor, null); + Object.defineProperty(Object.prototype, key, descriptor); + } + try { + return body(); + } finally { + for (const key of keys) { + const descriptor = saved[key]; + if (descriptor === undefined) { + Reflect.deleteProperty(Object.prototype, key); + } else { + captureSetPrototypeOf(descriptor, null); + Object.defineProperty(Object.prototype, key, descriptor); + } + } + } + } + + // The exact permit a clean realm mints, for field-by-field comparison. + const clean = issue(); + + // Mint under poison WITHOUT calling `expect` inside the poisoned region: the + // assertion library itself builds prototype-inheriting descriptors, so an + // `expect` under poison would throw from the harness rather than the product. + // The authorization runs inside the poison; every assertion runs after the + // realm is restored. + const issueUnderPoison = (keys: readonly ('get' | 'set')[]): ExecutionPermit => { + const permit = withPrototypePoison(keys, () => { + const decision = authorizeJobOperation(buildJob(), buildEdit()); + if (decision.decision !== JOB_AUTHORIZATION.ALLOW_ONCE) { + throw new Error(`expected ALLOW_ONCE, got ${decision.decision}`); + } + if (decision.permit === null) { + throw new Error('expected a permit under poison'); + } + return decision.permit; + }); + return permit; + }; + + const expectSamePermit = (permit: ExecutionPermit): void => { + // Every field equals the clean-realm permit — poison widens nothing and + // changes no permitId component ordering. + expect(permit.permitId).toBe(clean.permitId); + expect(permit.operation).toBe(clean.operation); + expect(permit.operands).toEqual(clean.operands); + expect(permit.singleUse).toBe(true); + expect(permit.scope).toBe('exactly-one-execution'); + expect(Object.keys(permit).sort()).toEqual(Object.keys(clean).sort()); + expect(permitAuthorizes(permit, buildJob(), buildEdit())).toBe(true); + }; + + it('issues an unchanged permit under Object.prototype.get poison', () => { + expectSamePermit(issueUnderPoison(['get'])); + }); + + it('issues an unchanged permit under Object.prototype.set poison', () => { + expectSamePermit(issueUnderPoison(['set'])); + }); + + it('issues an unchanged permit under get + set poison', () => { + expectSamePermit(issueUnderPoison(['get', 'set'])); + }); + + it('still refuses an out-of-scope operation under poison, minting no permit', () => { + const decision = withPrototypePoison(['get', 'set'], () => + authorizeJobOperation(buildJob(), buildEdit({ path: UNAUTHORIZED_PATH })), + ); + expect(decision.decision).not.toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(decision.permit).toBeNull(); + }); + + afterEach(() => { + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'get')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'set')).toBeUndefined(); + }); +}); + describe('the merge target is captured before the candidate is read', () => { const buildTarget = (): MergeTarget => ({ repositoryId: REPO_A, diff --git a/tests/domain/repair-job-invariants.test.ts b/tests/domain/repair-job-invariants.test.ts new file mode 100644 index 0000000..14e138d --- /dev/null +++ b/tests/domain/repair-job-invariants.test.ts @@ -0,0 +1,241 @@ +/** + * C1-RJ-F1 — repair-job append descriptor isolation. + * + * The exported `append` builds an own indexed element through the captured + * `Object.defineProperty`. Before this repair it handed that call an ordinary + * `Object.prototype`-inheriting descriptor literal, so a hostile getter that had + * installed `Object.prototype.get`/`.set` earlier in the same evaluation caused + * `ToPropertyDescriptor` to see inherited accessor keys beside the own + * `value`/`writable` keys and throw `TypeError`, breaking the module's + * documented never-throws / fail-closed contract on every append path. + * + * These tests pin that the repaired helper never throws under ambient or + * mid-evaluation prototype poisoning, that the normal-realm semantics (descriptor + * flags, element order, refusal reporting) are unchanged, and that the realm is + * left exactly as found even when a test body throws. + */ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + append, + findInvalidRepairJobFields, + readRepairJobAuthorization, + REPAIR_JOB_FIELD_ORDER, + type RepairJobAuthorization, +} from '../../src/domain/repair-job.js'; +import { buildJob } from './repair-job-fixtures.js'; + +/** + * Install `value`-shaped poison on `Object.prototype` for the duration of `body`, + * then restore the original descriptors exactly — including when `body` throws. + * + * The installer must not itself reproduce the bug under repair: installing `set` + * while `get` is already present would hand `Object.defineProperty` a descriptor + * that inherits the just-installed `Object.prototype.get`. Each descriptor is + * therefore null-prototyped through the captured intrinsic before use, so the + * harness stays neutral no matter which keys are installed together. + */ +const captureSetPrototypeOf = Object.setPrototypeOf; +function insulatedDescriptor(descriptor: PropertyDescriptor): PropertyDescriptor { + captureSetPrototypeOf(descriptor, null); + return descriptor; +} + +function withPrototypePoison(keys: readonly ('get' | 'set')[], body: () => T): T { + const saved: Record = {}; + for (const key of keys) { + saved[key] = Object.getOwnPropertyDescriptor(Object.prototype, key); + Object.defineProperty( + Object.prototype, + key, + insulatedDescriptor({ value: function () {}, configurable: true, writable: true }), + ); + } + try { + return body(); + } finally { + for (const key of keys) { + const descriptor = saved[key]; + if (descriptor === undefined) { + Reflect.deleteProperty(Object.prototype, key); + } else { + Object.defineProperty(Object.prototype, key, insulatedDescriptor(descriptor)); + } + } + } +} + +/** + * An otherwise-valid job whose first-read field installs the poison through a + * getter, so the prototype is polluted *after* validation has begun but *before* + * the later `readList` append executes — the mid-evaluation shape the module's + * intrinsic-capture defense exists to cover. + */ +function jobInstallingPoisonMidRead(kind: 'get' | 'set'): RepairJobAuthorization { + const hostile: Record = { ...buildJob() }; + Object.defineProperty(hostile, 'jobId', { + enumerable: true, + configurable: true, + get() { + Object.defineProperty( + Object.prototype, + kind, + insulatedDescriptor({ value: function () {}, configurable: true, writable: true }), + ); + return 'job-0001'; + }, + }); + return hostile as unknown as RepairJobAuthorization; +} + +afterEach(() => { + // No test may leak poison, whatever it did or however it failed. + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'get')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'set')).toBeUndefined(); +}); + +describe('C1-RJ-F1: append survives prototype poisoning on the validated-list path', () => { + const expectValidSnapshot = (result: ReturnType): void => { + expect(result.invalidFields).toEqual([]); + expect(result.snapshot).not.toBeNull(); + // The non-empty lists are the append path; they must round-trip intact. + expect(result.snapshot?.authorizedPaths).toEqual([ + 'src/domain/policy-gate.ts', + 'tests/domain/policy-gate.test.ts', + ]); + expect(result.snapshot?.authorizedCommandClasses).toEqual(['test', 'lint', 'typecheck']); + }; + + it('returns a normal snapshot for a valid job under Object.prototype.get poison', () => { + const result = withPrototypePoison(['get'], () => readRepairJobAuthorization(buildJob())); + expectValidSnapshot(result); + }); + + it('returns a normal snapshot for a valid job under Object.prototype.set poison', () => { + const result = withPrototypePoison(['set'], () => readRepairJobAuthorization(buildJob())); + expectValidSnapshot(result); + }); + + it('returns a normal snapshot for a valid job under get + set poison', () => { + const result = withPrototypePoison(['get', 'set'], () => + readRepairJobAuthorization(buildJob()), + ); + expectValidSnapshot(result); + }); +}); + +describe('C1-RJ-F1: append survives prototype poisoning on the invalidFields path', () => { + // An empty object fails every field, so every `invalidFields` append fires. + const empty = {} as RepairJobAuthorization; + + it('refuses (never throws) under Object.prototype.get poison, order preserved', () => { + const invalid = withPrototypePoison(['get'], () => findInvalidRepairJobFields(empty)); + expect(invalid).toEqual(REPAIR_JOB_FIELD_ORDER); + }); + + it('refuses (never throws) under Object.prototype.set poison, order preserved', () => { + const invalid = withPrototypePoison(['set'], () => findInvalidRepairJobFields(empty)); + expect(invalid).toEqual(REPAIR_JOB_FIELD_ORDER); + }); + + it('reports invalid fields in declaration order for a partially valid job', () => { + // jobId + repositoryId invalid; the rest valid. Order must follow + // REPAIR_JOB_FIELD_ORDER, proving append preserves append order under poison. + const job = buildJob({ jobId: '', repositoryId: '' }); + const invalid = withPrototypePoison(['get', 'set'], () => findInvalidRepairJobFields(job)); + expect(invalid).toEqual(['jobId', 'repositoryId']); + }); +}); + +describe('C1-RJ-F1: append survives poison installed mid-evaluation', () => { + // Reads the hostile job, then guarantees the poison it planted is removed + // before any assertion (which the assertion library would otherwise trip on). + function readWithMidEvalPoison( + kind: 'get' | 'set', + ): ReturnType { + try { + return readRepairJobAuthorization(jobInstallingPoisonMidRead(kind)); + } finally { + Reflect.deleteProperty(Object.prototype, kind); + } + } + + it('never throws when a getter installs get poison before a later append', () => { + const result = readWithMidEvalPoison('get'); + expect(result.invalidFields).toEqual([]); + expect(result.snapshot).not.toBeNull(); + expect(result.snapshot?.jobId).toBe('job-0001'); + expect(result.snapshot?.authorizedPaths.length).toBe(2); + }); + + it('never throws when a getter installs set poison before a later append', () => { + const result = readWithMidEvalPoison('set'); + expect(result.invalidFields).toEqual([]); + expect(result.snapshot).not.toBeNull(); + expect(result.snapshot?.authorizedCommandClasses.length).toBe(3); + }); +}); + +describe('C1-RJ-F1: the exported append helper itself', () => { + it('appends normally under Object.prototype.get poison', () => { + const list: string[] = []; + withPrototypePoison(['get'], () => { + append(list, 'a'); + }); + expect(list).toEqual(['a']); + }); + + it('appends normally under Object.prototype.set poison', () => { + const list: string[] = []; + withPrototypePoison(['set'], () => { + append(list, 'a'); + }); + expect(list).toEqual(['a']); + }); + + it('appends normally under get + set poison', () => { + const list: string[] = []; + withPrototypePoison(['get', 'set'], () => { + append(list, 'a'); + append(list, 'b'); + }); + expect(list).toEqual(['a', 'b']); + }); + + it('defines an own data element with the exact descriptor flags', () => { + const list: number[] = []; + append(list, 7); + const descriptor = Object.getOwnPropertyDescriptor(list, 0); + expect(descriptor).toEqual({ + value: 7, + writable: true, + enumerable: true, + configurable: true, + }); + // A data property, never an accessor: no get/set leaked in from the fix. + expect(descriptor && 'get' in descriptor).toBe(false); + expect(descriptor && 'set' in descriptor).toBe(false); + expect(list.length).toBe(1); + }); + + it('preserves element order across successive appends', () => { + const list: string[] = []; + for (const value of ['x', 'y', 'z']) { + append(list, value); + } + expect(list).toEqual(['x', 'y', 'z']); + expect(Object.keys(list)).toEqual(['0', '1', '2']); + }); + + it('restores the realm even when the poisoned body throws', () => { + expect(() => + withPrototypePoison(['get', 'set'], () => { + throw new Error('boom'); + }), + ).toThrow('boom'); + // The afterEach hook independently asserts get/set are gone; assert here too + // so this test fails at its own site if restoration regressed. + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'get')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'set')).toBeUndefined(); + }); +});