diff --git a/src/extensions/buildExtensionsRuntimeBoundary.test.ts b/src/extensions/buildExtensionsRuntimeBoundary.test.ts new file mode 100644 index 00000000..275d975e --- /dev/null +++ b/src/extensions/buildExtensionsRuntimeBoundary.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { buildExtensions, type BuildExtensionsOptions } from './kit.js'; + +const INVALID_BUILD_EXTENSIONS_CONFIGURATION = + 'Build extensions configuration is invalid.'; + +function expectInvalidBuildExtensionsOptions(options: unknown): void { + expect(() => + buildExtensions(options as BuildExtensionsOptions), + ).toThrowError(new RangeError(INVALID_BUILD_EXTENSIONS_CONFIGURATION)); +} + +describe('buildExtensions runtime configuration boundary', () => { + it.each([null, [], 'invalid', 0, false])( + 'rejects malformed top-level option container %p through one stable error', + (options) => { + expectInvalidBuildExtensionsOptions(options); + }, + ); + + it('redacts revoked top-level proxy shape failures', () => { + const { proxy: options, revoke } = Proxy.revocable({}, {}); + revoke(); + + expectInvalidBuildExtensionsOptions(options); + }); + + it('redacts revoked image proxy shape failures', () => { + const { proxy: image, revoke } = Proxy.revocable({}, {}); + revoke(); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + + it('rejects accessor-backed options without evaluating the accessor', () => { + let reads = 0; + const options = {} as BuildExtensionsOptions; + Object.defineProperty(options, 'image', { + enumerable: true, + get() { + reads += 1; + return { maxSizeBytes: 1_024 }; + }, + }); + + expectInvalidBuildExtensionsOptions(options); + expect(reads).toBe(0); + }); + + it('rejects non-enumerable top-level option properties', () => { + const options = {} as BuildExtensionsOptions; + Object.defineProperty(options, 'disableHistory', { + enumerable: false, + value: true, + }); + + expectInvalidBuildExtensionsOptions(options); + }); + + it('rejects unknown and symbol option keys instead of silently ignoring them', () => { + expectInvalidBuildExtensionsOptions({ maxSzieBytes: 1_024 }); + + const symbolKey = Symbol('private-build-options'); + const options: Record = {}; + options[symbolKey] = true; + expectInvalidBuildExtensionsOptions(options); + }); + + it('redacts hostile own-key reflection failures', () => { + const options = new Proxy( + {}, + { + ownKeys() { + throw new Error('private build-options own-key detail'); + }, + }, + ); + + expectInvalidBuildExtensionsOptions(options); + }); + + it('redacts hostile property-descriptor reflection failures', () => { + const options = new Proxy( + {}, + { + ownKeys() { + return ['image']; + }, + getOwnPropertyDescriptor() { + throw new Error('private build-options descriptor detail'); + }, + }, + ); + + expectInvalidBuildExtensionsOptions(options); + }); + + it('rejects a reported option key without an own descriptor', () => { + const options = new Proxy( + {}, + { + ownKeys() { + return ['image']; + }, + getOwnPropertyDescriptor() { + return undefined; + }, + }, + ); + + expectInvalidBuildExtensionsOptions(options); + }); +}); diff --git a/src/extensions/imageConfigRuntime.test.ts b/src/extensions/imageConfigRuntime.test.ts new file mode 100644 index 00000000..d5acf8eb --- /dev/null +++ b/src/extensions/imageConfigRuntime.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { buildExtensions } from './kit.js'; + +describe('runtime image configuration', () => { + it.each([ + ['maxSizeBytes', Number.NaN], + ['maxSizeBytes', -1], + ['maxSizeBytes', 1.5], + ['maxDimension', Number.POSITIVE_INFINITY], + ['maxDimension', -1], + ['maxDimension', 1.5], + ] as const)('rejects invalid %s values before extension setup', (key, value) => { + expect(() => + buildExtensions({ + image: { [key]: value } as never, + }), + ).toThrowError(new RangeError(`Image ${key} configuration is invalid.`)); + }); + + it.each([Number.NaN, Number.NEGATIVE_INFINITY, -0.1, 1.1])( + 'rejects invalid quality %s before extension setup', + (quality) => { + expect(() => + buildExtensions({ image: { quality } }), + ).toThrowError(new RangeError('Image quality configuration is invalid.')); + }, + ); + + it.each([null, [], 'invalid', 0, false])( + 'rejects malformed image configuration containers without coercion', + (image) => { + expect(() => buildExtensions({ image: image as never })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }, + ); + + it('rejects accessor-backed image configuration without evaluating the accessor', () => { + let reads = 0; + const image = {}; + Object.defineProperty(image, 'maxSizeBytes', { + enumerable: true, + get() { + reads += 1; + return 1024; + }, + }); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + expect(reads).toBe(0); + }); + + it('rejects non-enumerable image configuration data properties', () => { + const image = {}; + Object.defineProperty(image, 'quality', { + enumerable: false, + value: 0.8, + }); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + + it('rejects unknown runtime configuration keys instead of silently weakening policy', () => { + const image = { + maxSizeBytes: 1024, + maxSzieBytes: 16, + } as never; + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + + it('rejects own symbol configuration keys without reflecting their identity', () => { + const privatePolicyKey = Symbol('private-policy-key'); + const image: Record = { maxSizeBytes: 1024 }; + image[privatePolicyKey] = 16; + + expect(() => buildExtensions({ image: image as never })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + + it('redacts own-key reflection failures at the image configuration boundary', () => { + const image = new Proxy( + {}, + { + ownKeys() { + throw new Error('private own-key reflection detail'); + }, + }, + ); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + + it('redacts reflection failures at the image configuration boundary', () => { + const image = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error('private reflection detail'); + }, + }, + ); + + expect(() => buildExtensions({ image })).toThrowError( + new RangeError('Image configuration is invalid.'), + ); + }); + + it('preserves valid disabled and boundary configuration', () => { + const image = buildExtensions({ + image: { maxSizeBytes: 0, maxDimension: 0, quality: 1 }, + }).find((extension) => extension.name === 'image'); + + expect(image?.options.maxSizeBytes).toBe(0); + expect(image?.options.maxDimension).toBe(0); + expect(image?.options.quality).toBe(1); + }); +}); diff --git a/src/extensions/kit.ts b/src/extensions/kit.ts index 71554bc2..8577f287 100644 --- a/src/extensions/kit.ts +++ b/src/extensions/kit.ts @@ -18,6 +18,16 @@ import { SafeClipboard } from './SafeClipboardExtension.js'; import { SafeLink, isSafeLinkHref } from './SafeLink.js'; import type { ImageConfig } from '../types.js'; +const BUILD_EXTENSIONS_OPTION_KEYS = [ + 'placeholder', + 'image', + 'clipboard', + 'onImageError', + 'onClipboardError', + 'disableHistory', + 'additionalExtensions', +] as const; + /** Options for constructing the shared Inkspan extension collection. */ export interface BuildExtensionsOptions { /** Static or lazily resolved visual empty-editor guidance. */ @@ -37,12 +47,192 @@ export interface BuildExtensionsOptions { additionalExtensions?: Extensions; } +/** Fail closed without reflecting caller-controlled top-level configuration. */ +function invalidBuildExtensionsConfiguration(): never { + throw new RangeError('Build extensions configuration is invalid.'); +} + +/** Classify top-level option arrays without leaking hostile proxy failures. */ +function isBuildExtensionsOptionsArray(value: object): boolean { + try { + return Array.isArray(value); + } catch { + invalidBuildExtensionsConfiguration(); + } +} + +/** + * Copy only exact own data properties from the public runtime options object. + * + * TypeScript callers normally satisfy this shape at compile time, but JavaScript, + * deserialized, or otherwise untyped hosts can still pass arbitrary values. The + * detached copy prevents accessors, inherited values, symbols, and misspelled + * options from changing extension configuration implicitly. + */ +function resolveRuntimeBuildExtensionsOptions( + value: unknown, +): BuildExtensionsOptions { + if ( + typeof value !== 'object' || + value === null || + isBuildExtensionsOptionsArray(value) + ) { + invalidBuildExtensionsConfiguration(); + } + + let keys: PropertyKey[]; + try { + keys = Reflect.ownKeys(value); + } catch { + invalidBuildExtensionsConfiguration(); + } + + const resolved: BuildExtensionsOptions = {}; + for (const key of keys) { + if ( + typeof key !== 'string' || + !BUILD_EXTENSIONS_OPTION_KEYS.includes( + key as (typeof BUILD_EXTENSIONS_OPTION_KEYS)[number], + ) + ) { + invalidBuildExtensionsConfiguration(); + } + + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + invalidBuildExtensionsConfiguration(); + } + if ( + descriptor === undefined || + !descriptor.enumerable || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalidBuildExtensionsConfiguration(); + } + + Object.defineProperty(resolved, key, { + value: descriptor.value, + enumerable: true, + configurable: true, + writable: true, + }); + } + return resolved; +} + +/** Fail closed without reflecting caller-controlled image configuration. */ +function invalidImageConfiguration(): never { + throw new RangeError('Image configuration is invalid.'); +} + +/** Classify image configuration arrays without leaking hostile proxy failures. */ +function isImageConfigurationArray(value: object): boolean { + try { + return Array.isArray(value); + } catch { + invalidImageConfiguration(); + } +} + +/** Reject unknown own keys without evaluating any configuration property. */ +function validateImageConfigurationKeys(image: object): void { + let keys: PropertyKey[]; + try { + keys = Reflect.ownKeys(image); + } catch { + invalidImageConfiguration(); + } + + for (const key of keys) { + if ( + key !== 'maxSizeBytes' && + key !== 'maxDimension' && + key !== 'quality' + ) { + invalidImageConfiguration(); + } + } +} + +/** Read one own enumerable data property without invoking accessors. */ +function readImageConfigurationProperty( + image: object, + key: keyof ImageConfig, +): unknown { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(image, key); + } catch { + invalidImageConfiguration(); + } + + if (descriptor === undefined) { + return undefined; + } + if (!descriptor.enumerable || !('value' in descriptor)) { + invalidImageConfiguration(); + } + return descriptor.value; +} + +/** Reject malformed runtime image configuration containers before property reads. */ +function resolveRuntimeImageConfiguration(value: unknown): ImageConfig { + if (value === undefined) { + return {}; + } + if ( + typeof value !== 'object' || + value === null || + isImageConfigurationArray(value) + ) { + invalidImageConfiguration(); + } + + validateImageConfigurationKeys(value); + const maxSizeBytes = readImageConfigurationProperty(value, 'maxSizeBytes'); + const maxDimension = readImageConfigurationProperty(value, 'maxDimension'); + const quality = readImageConfigurationProperty(value, 'quality'); + + validateImageNonNegativeSafeInteger('maxSizeBytes', maxSizeBytes); + validateImageNonNegativeSafeInteger('maxDimension', maxDimension); + validateImageQuality(quality); + + return { + maxSizeBytes: maxSizeBytes as number | undefined, + maxDimension: maxDimension as number | undefined, + quality: quality as number | undefined, + }; +} + +/** Reject invalid runtime size/dimension configuration before extension setup. */ +function validateImageNonNegativeSafeInteger( + key: 'maxSizeBytes' | 'maxDimension', + value: unknown, +): void { + if (value !== undefined && (!Number.isSafeInteger(value) || (value as number) < 0)) { + throw new RangeError(`Image ${key} configuration is invalid.`); + } +} + +/** Reject non-finite or out-of-range runtime image quality configuration. */ +function validateImageQuality(value: unknown): void { + if ( + value !== undefined && + (!Number.isFinite(value) || (value as number) < 0 || (value as number) > 1) + ) { + throw new RangeError('Image quality configuration is invalid.'); + } +} + /** Build the full extension list for an Inkspan editor surface. */ export function buildExtensions( options: BuildExtensionsOptions = {}, ): Extensions { - const image = options.image ?? {}; - const historyConfiguration = options.disableHistory + const resolvedOptions = resolveRuntimeBuildExtensionsOptions(options); + const image = resolveRuntimeImageConfiguration(resolvedOptions.image); + const historyConfiguration = resolvedOptions.disableHistory ? { history: false as const } : {}; @@ -62,11 +252,11 @@ export function buildExtensions( HTMLAttributes: { rel: 'noopener noreferrer nofollow' }, }), SafeClipboard.configure({ - config: options.clipboard, - onError: options.onClipboardError, + config: resolvedOptions.clipboard, + onError: resolvedOptions.onClipboardError, }), Placeholder.configure({ - placeholder: options.placeholder ?? 'Start writing…', + placeholder: resolvedOptions.placeholder ?? 'Start writing…', }), Table.configure({ resizable: true }), TableRow, @@ -76,8 +266,8 @@ export function buildExtensions( maxSizeBytes: image.maxSizeBytes ?? 10 * 1024 * 1024, maxDimension: image.maxDimension ?? 1600, quality: image.quality ?? 0.85, - onError: options.onImageError, + onError: resolvedOptions.onImageError, }), - ...(options.additionalExtensions ?? []), + ...(resolvedOptions.additionalExtensions ?? []), ]; }