From 758c36c05f104be0cc6b608acd5c753ea5b9b709 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 03:32:05 +0900 Subject: [PATCH 01/14] test(security): reproduce hostile clipboard throw escape --- ...afeClipboardExtension.hostileThrow.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/extensions/SafeClipboardExtension.hostileThrow.test.ts diff --git a/src/extensions/SafeClipboardExtension.hostileThrow.test.ts b/src/extensions/SafeClipboardExtension.hostileThrow.test.ts new file mode 100644 index 00000000..2b40b39c --- /dev/null +++ b/src/extensions/SafeClipboardExtension.hostileThrow.test.ts @@ -0,0 +1,60 @@ +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 a hostile value thrown by + * host option access. The thrown proxy must never be inspected by 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.', + }), + ); + }); +}); From ce8223071aa658d70edfde6b70df936eb7e77e77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:33:17 +0900 Subject: [PATCH 02/14] fix(security): brand clipboard sanitizer errors safely --- src/extensions/SafeClipboard.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index a3eaef4c..2c6e5743 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,17 @@ 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 CLIPBOARD_SANITIZATION_ERRORS.has(value as object); +} + interface ResolvedClipboardConfig { readonly maxHtmlBytes: number; readonly maxNodes: number; @@ -241,7 +251,7 @@ function resolveClipboardConfig( }); } catch (error) { if ( - error instanceof ClipboardSanitizationError && + isClipboardSanitizationError(error) && error.code === 'invalid_configuration' ) { throw error; @@ -577,7 +587,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 +630,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 { @@ -634,4 +643,4 @@ export const SafeClipboard = Extension.create({ }, }); -export default SafeClipboard; +export default SafeClipboard; \ No newline at end of file From 3f963bfe0cb0e96366ad3eb42ed4546c197cfeaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:33:46 +0900 Subject: [PATCH 03/14] fix(security): avoid inspecting hostile clipboard throw values --- src/extensions/SafeClipboardExtension.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 { From 71f19ae626caab72d051a794e1c0548bd6e6b57f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:33:11 +0900 Subject: [PATCH 04/14] test(security): reject bidi controls in link targets --- src/extensions/SafeLink.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extensions/SafeLink.test.ts b/src/extensions/SafeLink.test.ts index 845d723a..b744d3e2 100644 --- a/src/extensions/SafeLink.test.ts +++ b/src/extensions/SafeLink.test.ts @@ -68,6 +68,8 @@ describe('validateSafeLinkHref', () => { 'https://', 'https://user:secret@example.com/path', 'http://user@example.com/path', + 'docs/visible\u202Ehidden', + 'https://example.com/visible\u2066hidden', ])('rejects unsafe link target %s', (href) => { expect(() => validateSafeLinkHref(href)).toThrow(SafeLinkHrefError); expect(isSafeLinkHref(href)).toBe(false); From 2041bb7a4cf7d1003bd355d1e6736a3d05b4fbe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:33:56 +0900 Subject: [PATCH 05/14] fix(security): reject bidirectional link controls --- src/policy/safeLinkPolicy.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/policy/safeLinkPolicy.ts b/src/policy/safeLinkPolicy.ts index 82ea5570..533ffc32 100644 --- a/src/policy/safeLinkPolicy.ts +++ b/src/policy/safeLinkPolicy.ts @@ -4,11 +4,13 @@ * The policy permits HTTPS/HTTP, mailto, tel, document-relative, query-only, * and fragment links while rejecting protocol-relative targets, executable or * local schemes, embedded credentials, backslashes, literal whitespace/control - * characters, malformed absolute URLs, and unknown schemes. + * characters, bidirectional formatting controls, malformed absolute URLs, and + * unknown schemes. */ const SAFE_ABSOLUTE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']); const URI_SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i; -const FORBIDDEN_LINK_CHARACTER_PATTERN = /[\u0000-\u0020\u007f-\u009f\\]/u; +const FORBIDDEN_LINK_CHARACTER_PATTERN = + /[\u0000-\u0020\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\\]/u; /** Return a bounded, secret-free category for an untrusted hyperlink target. */ function redactLinkHref(href: unknown): string { @@ -52,8 +54,9 @@ function validateWebHref(href: string): void { * * Allowed targets are absolute HTTP(S), non-empty mailto/tel, and ordinary * document-relative/query/fragment references. Literal whitespace, control - * characters, and backslashes are rejected rather than canonicalized so an - * obfuscated executable scheme cannot acquire a different browser meaning. + * characters, bidirectional formatting controls, and backslashes are rejected + * rather than canonicalized so an obfuscated or deceptively rendered target + * cannot acquire a different browser or reviewer-visible meaning. */ export function validateSafeLinkHref(href: unknown): string { if ( From 01100dea10befc5ab72ecc363313b3264be7e72b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:34:57 +0900 Subject: [PATCH 06/14] refactor(security): keep SafeLink policy dependency explicit --- src/extensions/SafeLink.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/extensions/SafeLink.ts b/src/extensions/SafeLink.ts index 2af0cbfd..571b05f4 100644 --- a/src/extensions/SafeLink.ts +++ b/src/extensions/SafeLink.ts @@ -5,17 +5,14 @@ * * Inkspan permits HTTPS/HTTP, mailto, tel, document-relative, query-only, and * fragment links. Protocol-relative URLs, executable/local schemes, embedded - * credentials, backslashes, literal whitespace/control characters, malformed - * absolute URLs, and unknown schemes are rejected. + * credentials, backslashes, literal whitespace/control characters, + * bidirectional formatting controls, malformed absolute URLs, and unknown + * schemes are rejected. */ import Link from '@tiptap/extension-link'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { Plugin, PluginKey } from '@tiptap/pm/state'; -import { - SafeLinkHrefError, - isSafeLinkHref, - validateSafeLinkHref, -} from '../policy/safeLinkPolicy.js'; +import { isSafeLinkHref } from '../policy/safeLinkPolicy.js'; export { SafeLinkHrefError, @@ -62,9 +59,4 @@ export const SafeLink = Link.extend({ }, }); -// Keep the named error reachable from this long-standing extension module for -// source-compatible consumers while the implementation lives in the pure policy. -void SafeLinkHrefError; -void validateSafeLinkHref; - export default SafeLink; From f2262e36e85f4ade92e4d3a4c17097f98613a85a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:02:04 +0900 Subject: [PATCH 07/14] test(security): reproduce Unicode whitespace link bypass --- src/extensions/SafeLink.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extensions/SafeLink.test.ts b/src/extensions/SafeLink.test.ts index b744d3e2..84ff042f 100644 --- a/src/extensions/SafeLink.test.ts +++ b/src/extensions/SafeLink.test.ts @@ -70,6 +70,9 @@ describe('validateSafeLinkHref', () => { 'http://user@example.com/path', 'docs/visible\u202Ehidden', 'https://example.com/visible\u2066hidden', + 'docs/visible\u00A0hidden', + 'docs/visible\u1680hidden', + 'docs/visible\u3000hidden', ])('rejects unsafe link target %s', (href) => { expect(() => validateSafeLinkHref(href)).toThrow(SafeLinkHrefError); expect(isSafeLinkHref(href)).toBe(false); From 9889496543e9230dba29b1e4b5a32d6c31344af3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:04:25 +0900 Subject: [PATCH 08/14] fix(security): reject Unicode whitespace in link targets --- src/policy/safeLinkPolicy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/policy/safeLinkPolicy.ts b/src/policy/safeLinkPolicy.ts index 533ffc32..5a84c98f 100644 --- a/src/policy/safeLinkPolicy.ts +++ b/src/policy/safeLinkPolicy.ts @@ -10,7 +10,7 @@ const SAFE_ABSOLUTE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']); const URI_SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i; const FORBIDDEN_LINK_CHARACTER_PATTERN = - /[\u0000-\u0020\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\\]/u; + /[\u0000-\u001f\s\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\\]/u; /** Return a bounded, secret-free category for an untrusted hyperlink target. */ function redactLinkHref(href: unknown): string { From 690277d48e58354cf13516279bb6b980669da1ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:08:01 +0900 Subject: [PATCH 09/14] fix(scope): remove unrelated SafeLink changes --- src/extensions/SafeLink.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/extensions/SafeLink.ts b/src/extensions/SafeLink.ts index 571b05f4..2af0cbfd 100644 --- a/src/extensions/SafeLink.ts +++ b/src/extensions/SafeLink.ts @@ -5,14 +5,17 @@ * * Inkspan permits HTTPS/HTTP, mailto, tel, document-relative, query-only, and * fragment links. Protocol-relative URLs, executable/local schemes, embedded - * credentials, backslashes, literal whitespace/control characters, - * bidirectional formatting controls, malformed absolute URLs, and unknown - * schemes are rejected. + * credentials, backslashes, literal whitespace/control characters, malformed + * absolute URLs, and unknown schemes are rejected. */ import Link from '@tiptap/extension-link'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { Plugin, PluginKey } from '@tiptap/pm/state'; -import { isSafeLinkHref } from '../policy/safeLinkPolicy.js'; +import { + SafeLinkHrefError, + isSafeLinkHref, + validateSafeLinkHref, +} from '../policy/safeLinkPolicy.js'; export { SafeLinkHrefError, @@ -59,4 +62,9 @@ export const SafeLink = Link.extend({ }, }); +// Keep the named error reachable from this long-standing extension module for +// source-compatible consumers while the implementation lives in the pure policy. +void SafeLinkHrefError; +void validateSafeLinkHref; + export default SafeLink; From c0983b78db750baa89822e330df73a18ae0f37af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:08:15 +0900 Subject: [PATCH 10/14] fix(scope): restore SafeLink policy ownership --- src/policy/safeLinkPolicy.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/policy/safeLinkPolicy.ts b/src/policy/safeLinkPolicy.ts index 5a84c98f..82ea5570 100644 --- a/src/policy/safeLinkPolicy.ts +++ b/src/policy/safeLinkPolicy.ts @@ -4,13 +4,11 @@ * The policy permits HTTPS/HTTP, mailto, tel, document-relative, query-only, * and fragment links while rejecting protocol-relative targets, executable or * local schemes, embedded credentials, backslashes, literal whitespace/control - * characters, bidirectional formatting controls, malformed absolute URLs, and - * unknown schemes. + * characters, malformed absolute URLs, and unknown schemes. */ const SAFE_ABSOLUTE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']); const URI_SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i; -const FORBIDDEN_LINK_CHARACTER_PATTERN = - /[\u0000-\u001f\s\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\\]/u; +const FORBIDDEN_LINK_CHARACTER_PATTERN = /[\u0000-\u0020\u007f-\u009f\\]/u; /** Return a bounded, secret-free category for an untrusted hyperlink target. */ function redactLinkHref(href: unknown): string { @@ -54,9 +52,8 @@ function validateWebHref(href: string): void { * * Allowed targets are absolute HTTP(S), non-empty mailto/tel, and ordinary * document-relative/query/fragment references. Literal whitespace, control - * characters, bidirectional formatting controls, and backslashes are rejected - * rather than canonicalized so an obfuscated or deceptively rendered target - * cannot acquire a different browser or reviewer-visible meaning. + * characters, and backslashes are rejected rather than canonicalized so an + * obfuscated executable scheme cannot acquire a different browser meaning. */ export function validateSafeLinkHref(href: unknown): string { if ( From 1518a5684c37dbe02c23e758823d501629d50bb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:08:36 +0900 Subject: [PATCH 11/14] fix(scope): drop unrelated SafeLink regressions --- src/extensions/SafeLink.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/extensions/SafeLink.test.ts b/src/extensions/SafeLink.test.ts index 84ff042f..845d723a 100644 --- a/src/extensions/SafeLink.test.ts +++ b/src/extensions/SafeLink.test.ts @@ -68,11 +68,6 @@ describe('validateSafeLinkHref', () => { 'https://', 'https://user:secret@example.com/path', 'http://user@example.com/path', - 'docs/visible\u202Ehidden', - 'https://example.com/visible\u2066hidden', - 'docs/visible\u00A0hidden', - 'docs/visible\u1680hidden', - 'docs/visible\u3000hidden', ])('rejects unsafe link target %s', (href) => { expect(() => validateSafeLinkHref(href)).toThrow(SafeLinkHrefError); expect(isSafeLinkHref(href)).toBe(false); From e54c7769feecffe62734a7e18bd5a3f440d98358 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:03:01 +0900 Subject: [PATCH 12/14] test(security): preserve sanitizer hostile-config regression --- .../SafeClipboard.hostileThrow.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/extensions/SafeClipboard.hostileThrow.test.ts diff --git a/src/extensions/SafeClipboard.hostileThrow.test.ts b/src/extensions/SafeClipboard.hostileThrow.test.ts new file mode 100644 index 00000000..e04a8448 --- /dev/null +++ b/src/extensions/SafeClipboard.hostileThrow.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + 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('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.', + }), + ); + }); +}); From 3460f2d4233177a29607dd9b181ff4fad3d363bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:06:12 +0900 Subject: [PATCH 13/14] test(security): cover primitive clipboard throw containment --- ...afeClipboardExtension.hostileThrow.test.ts | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/extensions/SafeClipboardExtension.hostileThrow.test.ts b/src/extensions/SafeClipboardExtension.hostileThrow.test.ts index 2b40b39c..6624c620 100644 --- a/src/extensions/SafeClipboardExtension.hostileThrow.test.ts +++ b/src/extensions/SafeClipboardExtension.hostileThrow.test.ts @@ -11,8 +11,8 @@ import { } from './SafeClipboardExtension.js'; /** - * Exercise the real ProseMirror paste transform with a hostile value thrown by - * host option access. The thrown proxy must never be inspected by Inkspan. + * 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', () => { @@ -57,4 +57,41 @@ describe('SafeClipboard hostile thrown-value containment', () => { }), ); }); + + 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.', + }), + ); + }); }); From 9529b81e4091a88dac3b2e22f54faf5b213accf7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:24:18 +0900 Subject: [PATCH 14/14] fix(security): guard primitive clipboard errors --- src/extensions/SafeClipboard.hostileThrow.test.ts | 7 +++++++ src/extensions/SafeClipboard.ts | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/extensions/SafeClipboard.hostileThrow.test.ts b/src/extensions/SafeClipboard.hostileThrow.test.ts index e04a8448..6c9ea33f 100644 --- a/src/extensions/SafeClipboard.hostileThrow.test.ts +++ b/src/extensions/SafeClipboard.hostileThrow.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { + isClipboardSanitizationError, sanitizeRichClipboardHtml, type ClipboardConfig, } from './SafeClipboard.js'; @@ -10,6 +11,12 @@ import { * 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(() => { diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index 2c6e5743..5e4aef8c 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -78,7 +78,9 @@ export class ClipboardSanitizationError extends Error { export function isClipboardSanitizationError( value: unknown, ): value is ClipboardSanitizationError { - return CLIPBOARD_SANITIZATION_ERRORS.has(value as object); + return ( + (typeof value === 'object' && value !== null) || typeof value === 'function' + ) && CLIPBOARD_SANITIZATION_ERRORS.has(value as object); } interface ResolvedClipboardConfig { @@ -643,4 +645,4 @@ export const SafeClipboard = Extension.create({ }, }); -export default SafeClipboard; \ No newline at end of file +export default SafeClipboard;