Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/extensions/SafeClipboard.hostileThrow.test.ts
Original file line number Diff line number Diff line change
@@ -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('<p>private source</p>', 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.',
}),
);
});
});
23 changes: 17 additions & 6 deletions src/extensions/SafeClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const ERROR_MESSAGES: Readonly<Record<ClipboardSanitizationErrorCode, string>> =
invalid_html: 'Rich clipboard HTML could not be sanitized.',
});

const CLIPBOARD_SANITIZATION_ERRORS = new WeakSet<object>();

/** Error whose stable code and message never disclose clipboard content. */
export class ClipboardSanitizationError extends Error {
/** Machine-readable rejection category safe for host telemetry. */
Expand All @@ -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;
Expand Down Expand Up @@ -241,7 +253,7 @@ function resolveClipboardConfig(
});
} catch (error) {
if (
error instanceof ClipboardSanitizationError &&
isClipboardSanitizationError(error) &&
error.code === 'invalid_configuration'
) {
throw error;
Expand Down Expand Up @@ -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');
}
}
Expand Down Expand Up @@ -620,10 +632,9 @@ export const SafeClipboard = Extension.create<SafeClipboardOptions>({
: 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 {
Expand Down
97 changes: 97 additions & 0 deletions src/extensions/SafeClipboardExtension.hostileThrow.test.ts
Original file line number Diff line number Diff line change
@@ -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, '<p>private source</p>', {} 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, '<p>private source</p>', {} 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.',
}),
);
});
});
8 changes: 4 additions & 4 deletions src/extensions/SafeClipboardExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DEFAULT_CLIPBOARD_HTML_BYTES,
DEFAULT_CLIPBOARD_MAX_DEPTH,
DEFAULT_CLIPBOARD_MAX_NODES,
isClipboardSanitizationError,
sanitizeRichClipboardHtml,
type ClipboardConfig,
} from './SafeClipboard.js';
Expand Down Expand Up @@ -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 {
Expand Down
Loading