diff --git a/src/autosave/package.ts b/src/autosave/package.ts index 64fb4436..3e971ae4 100644 --- a/src/autosave/package.ts +++ b/src/autosave/package.ts @@ -240,9 +240,11 @@ function createInvalidQueueOptionsError(): InternalDocumentAutosaveQueueError { /** * Read exact queue option values without invoking accessors or retaining extras. * - * The optional observer must be an enumerable data property when present. - * Unknown keys, symbols, accessors, non-enumerable fields, and reflection - * failures are rejected before any host callback can execute. + * The required save callback is validated before global key enumeration so an + * already-invalid capability cannot trigger unrelated caller-controlled Proxy + * key reflection. The optional observer must be an enumerable data property + * when present. Unknown keys, symbols, accessors, non-enumerable fields, and + * reflection failures are rejected before any host callback can execute. */ function readDocumentAutosaveQueueOptions( options: DocumentAutosaveQueueOptions, @@ -254,6 +256,15 @@ function readDocumentAutosaveQueueOptions( if (typeof options !== 'object' || options === null) { throw createInvalidQueueOptionsError(); } + const saveDescriptor = Object.getOwnPropertyDescriptor(options, 'save'); + if ( + saveDescriptor === undefined || + !saveDescriptor.enumerable || + !Object.prototype.hasOwnProperty.call(saveDescriptor, 'value') || + typeof saveDescriptor.value !== 'function' + ) { + throw createInvalidQueueOptionsError(); + } const optionKeys = Reflect.ownKeys(options); if ( optionKeys.length < 1 || @@ -268,20 +279,15 @@ function readDocumentAutosaveQueueOptions( ) { throw createInvalidQueueOptionsError(); } - const saveDescriptor = Object.getOwnPropertyDescriptor(options, 'save'); const observerDescriptor = Object.getOwnPropertyDescriptor( options, 'onSnapshotChange', ); if ( - saveDescriptor === undefined || - !saveDescriptor.enumerable || - !Object.prototype.hasOwnProperty.call(saveDescriptor, 'value') || - typeof saveDescriptor.value !== 'function' || - (observerDescriptor !== undefined && - (!observerDescriptor.enumerable || - !Object.prototype.hasOwnProperty.call(observerDescriptor, 'value') || - typeof observerDescriptor.value !== 'function')) + observerDescriptor !== undefined && + (!observerDescriptor.enumerable || + !Object.prototype.hasOwnProperty.call(observerDescriptor, 'value') || + typeof observerDescriptor.value !== 'function') ) { throw createInvalidQueueOptionsError(); } diff --git a/src/autosave/packageOptionPreflight.test.ts b/src/autosave/packageOptionPreflight.test.ts new file mode 100644 index 00000000..02b66eb7 --- /dev/null +++ b/src/autosave/packageOptionPreflight.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { createDocumentAutosaveQueue } from './package.js'; + +describe('framework-free autosave option preflight', () => { + it('rejects an invalid required save descriptor before key enumeration', () => { + let ownKeysCalls = 0; + const options = new Proxy( + { save: 1 }, + { + ownKeys() { + ownKeysCalls += 1; + throw new Error('private option keys'); + }, + }, + ); + + expect(() => createDocumentAutosaveQueue(options as never)).toThrowError( + expect.objectContaining({ + code: 'invalid_options', + message: 'Document autosave queue options are invalid.', + }), + ); + expect(ownKeysCalls).toBe(0); + }); +});