Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
758c36c
test(security): reproduce hostile clipboard throw escape
seonghobae Aug 15, 2026
ce82230
fix(security): brand clipboard sanitizer errors safely
seonghobae Aug 15, 2026
3f963bf
fix(security): avoid inspecting hostile clipboard throw values
seonghobae Aug 15, 2026
71f19ae
test(security): reject bidi controls in link targets
seonghobae Aug 15, 2026
2041bb7
fix(security): reject bidirectional link controls
seonghobae Aug 15, 2026
01100de
refactor(security): keep SafeLink policy dependency explicit
seonghobae Aug 15, 2026
f2262e3
test(security): reproduce Unicode whitespace link bypass
seonghobae Aug 16, 2026
9889496
fix(security): reject Unicode whitespace in link targets
seonghobae Aug 16, 2026
690277d
fix(scope): remove unrelated SafeLink changes
seonghobae Aug 16, 2026
c0983b7
fix(scope): restore SafeLink policy ownership
seonghobae Aug 16, 2026
1518a56
fix(scope): drop unrelated SafeLink regressions
seonghobae Aug 16, 2026
e54c776
test(security): preserve sanitizer hostile-config regression
seonghobae Aug 16, 2026
3460f2d
test(security): cover primitive clipboard throw containment
seonghobae Aug 16, 2026
9529b81
fix(security): guard primitive clipboard errors
seonghobae Aug 20, 2026
61936f2
chore: synchronize clipboard repair with protected main
seonghobae Aug 21, 2026
6e5a56f
chore: synchronize hostile clipboard containment with protected main
seonghobae Aug 25, 2026
8881f2a
chore: synchronize hostile clipboard containment with protected main
seonghobae Aug 28, 2026
a31e4d4
Merge remote-tracking branch 'origin/main' into fix/clipboard-hostile…
seonghobae Sep 4, 2026
baea226
test(ci): cover event-specific Python matrix
seonghobae Sep 4, 2026
d7f83fc
test(ci): bind Python matrix to event
seonghobae Sep 4, 2026
cff066f
revert(ci): restore Office contract owner
seonghobae Sep 4, 2026
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