diff --git a/src/extensions/SafeClipboard.hostileThrow.test.ts b/src/extensions/SafeClipboard.hostileThrow.test.ts new file mode 100644 index 00000000..6c9ea33f --- /dev/null +++ b/src/extensions/SafeClipboard.hostileThrow.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + isClipboardSanitizationError, + sanitizeRichClipboardHtml, + type ClipboardConfig, +} from './SafeClipboard.js'; + +/** + * Exercise the direct sanitizer boundary with a hostile configuration failure. + * Unknown thrown values must be normalized without prototype inspection. + */ +describe('SafeClipboard sanitizer hostile thrown-value containment', () => { + it('rejects primitive values without consulting the WeakSet', () => { + expect(isClipboardSanitizationError('private primitive sentinel')).toBe(false); + expect(isClipboardSanitizationError(1)).toBe(false); + expect(isClipboardSanitizationError(null)).toBe(false); + }); + + it('normalizes hostile configuration failures without prototype inspection', () => { + const privateSentinel = new Error('private sanitizer prototype sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy(Object.create(null) as object, { + getPrototypeOf, + }); + const hostileConfig = new Proxy(Object.create(null) as ClipboardConfig, { + ownKeys() { + throw hostileThrownValue; + }, + }); + + let observed: unknown; + try { + sanitizeRichClipboardHtml('

private source

', hostileConfig, document); + } catch (error) { + observed = error; + } + + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(observed).toEqual( + expect.objectContaining({ + name: 'ClipboardSanitizationError', + code: 'invalid_configuration', + message: 'Rich clipboard configuration is invalid.', + }), + ); + }); +}); diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index a3eaef4c..5e4aef8c 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -58,6 +58,8 @@ const ERROR_MESSAGES: Readonly> = invalid_html: 'Rich clipboard HTML could not be sanitized.', }); +const CLIPBOARD_SANITIZATION_ERRORS = new WeakSet(); + /** Error whose stable code and message never disclose clipboard content. */ export class ClipboardSanitizationError extends Error { /** Machine-readable rejection category safe for host telemetry. */ @@ -68,9 +70,19 @@ export class ClipboardSanitizationError extends Error { super(ERROR_MESSAGES[code]); this.name = 'ClipboardSanitizationError'; this.code = code; + CLIPBOARD_SANITIZATION_ERRORS.add(this); } } +/** Return whether an unknown value is a genuine module-created sanitizer error. */ +export function isClipboardSanitizationError( + value: unknown, +): value is ClipboardSanitizationError { + return ( + (typeof value === 'object' && value !== null) || typeof value === 'function' + ) && CLIPBOARD_SANITIZATION_ERRORS.has(value as object); +} + interface ResolvedClipboardConfig { readonly maxHtmlBytes: number; readonly maxNodes: number; @@ -241,7 +253,7 @@ function resolveClipboardConfig( }); } catch (error) { if ( - error instanceof ClipboardSanitizationError && + isClipboardSanitizationError(error) && error.code === 'invalid_configuration' ) { throw error; @@ -577,7 +589,7 @@ export function sanitizeRichClipboardHtml( } return outputContainer.innerHTML; } catch (error) { - if (error instanceof ClipboardSanitizationError) throw error; + if (isClipboardSanitizationError(error)) throw error; throw new ClipboardSanitizationError('invalid_html'); } } @@ -620,10 +632,9 @@ export const SafeClipboard = Extension.create({ : this.options.config; return sanitizeRichClipboardHtml(html, config, this.options.document); } catch (error) { - const clipboardError = - error instanceof ClipboardSanitizationError - ? error - : new ClipboardSanitizationError('invalid_html'); + const clipboardError = isClipboardSanitizationError(error) + ? error + : new ClipboardSanitizationError('invalid_html'); try { this.options.onError?.(clipboardError); } catch { diff --git a/src/extensions/SafeClipboardExtension.hostileThrow.test.ts b/src/extensions/SafeClipboardExtension.hostileThrow.test.ts new file mode 100644 index 00000000..6624c620 --- /dev/null +++ b/src/extensions/SafeClipboardExtension.hostileThrow.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_CLIPBOARD_HTML_BYTES, + DEFAULT_CLIPBOARD_MAX_DEPTH, + DEFAULT_CLIPBOARD_MAX_NODES, + type ClipboardSanitizationError, +} from './SafeClipboard.js'; +import { + SafeClipboard, + type SafeClipboardOptions, +} from './SafeClipboardExtension.js'; + +/** + * Exercise the real ProseMirror paste transform with hostile values thrown by + * host option access. Unknown thrown values must never escape Inkspan. + */ +describe('SafeClipboard hostile thrown-value containment', () => { + it('fails closed without prototype inspection when a config getter throws a proxy', () => { + const privateSentinel = new Error('private prototype sentinel'); + const hostileThrownValue = new Proxy(Object.create(null) as object, { + getPrototypeOf() { + throw privateSentinel; + }, + }); + const onError = vi.fn((_error: ClipboardSanitizationError) => undefined); + const hostileOptions = { + get config(): never { + throw hostileThrownValue; + }, + maxHtmlBytes: DEFAULT_CLIPBOARD_HTML_BYTES, + maxNodes: DEFAULT_CLIPBOARD_MAX_NODES, + maxDepth: DEFAULT_CLIPBOARD_MAX_DEPTH, + onError, + document, + } as SafeClipboardOptions; + + const addPlugins = SafeClipboard.config.addProseMirrorPlugins; + if (!addPlugins) throw new Error('SafeClipboard plugin factory is unavailable'); + const plugins = addPlugins.call({ options: hostileOptions } as never); + const plugin = plugins[0]; + const transform = plugin?.props.transformPastedHTML; + if (!plugin || !transform) { + throw new Error('SafeClipboard paste transform is unavailable'); + } + + let transformed: string | undefined; + expect(() => { + transformed = transform.call(plugin, '

private source

', {} as never); + }).not.toThrow(); + + expect(transformed).toBe(''); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'invalid_html', + message: 'Rich clipboard HTML could not be sanitized.', + }), + ); + }); + + it('fails closed when a config getter throws a primitive value', () => { + const onError = vi.fn((_error: ClipboardSanitizationError) => undefined); + const hostileOptions = { + get config(): never { + throw 'private primitive sentinel'; + }, + maxHtmlBytes: DEFAULT_CLIPBOARD_HTML_BYTES, + maxNodes: DEFAULT_CLIPBOARD_MAX_NODES, + maxDepth: DEFAULT_CLIPBOARD_MAX_DEPTH, + onError, + document, + } as SafeClipboardOptions; + + const addPlugins = SafeClipboard.config.addProseMirrorPlugins; + if (!addPlugins) throw new Error('SafeClipboard plugin factory is unavailable'); + const plugins = addPlugins.call({ options: hostileOptions } as never); + const plugin = plugins[0]; + const transform = plugin?.props.transformPastedHTML; + if (!plugin || !transform) { + throw new Error('SafeClipboard paste transform is unavailable'); + } + + let transformed: string | undefined; + expect(() => { + transformed = transform.call(plugin, '

private source

', {} as never); + }).not.toThrow(); + + expect(transformed).toBe(''); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'invalid_html', + message: 'Rich clipboard HTML could not be sanitized.', + }), + ); + }); +}); diff --git a/src/extensions/SafeClipboardExtension.ts b/src/extensions/SafeClipboardExtension.ts index 4778313a..71d8302a 100644 --- a/src/extensions/SafeClipboardExtension.ts +++ b/src/extensions/SafeClipboardExtension.ts @@ -9,6 +9,7 @@ import { DEFAULT_CLIPBOARD_HTML_BYTES, DEFAULT_CLIPBOARD_MAX_DEPTH, DEFAULT_CLIPBOARD_MAX_NODES, + isClipboardSanitizationError, sanitizeRichClipboardHtml, type ClipboardConfig, } from './SafeClipboard.js'; @@ -55,10 +56,9 @@ function transformPastedClipboardHtml( : options.config; return sanitizeRichClipboardHtml(html, config, options.document); } catch (error) { - const clipboardError = - error instanceof ClipboardSanitizationError - ? error - : new ClipboardSanitizationError('invalid_html'); + const clipboardError = isClipboardSanitizationError(error) + ? error + : new ClipboardSanitizationError('invalid_html'); try { options.onError?.(clipboardError); } catch {