diff --git a/src/extensions/Base64Image.hostileError.test.ts b/src/extensions/Base64Image.hostileError.test.ts
new file mode 100644
index 00000000..67a1e425
--- /dev/null
+++ b/src/extensions/Base64Image.hostileError.test.ts
@@ -0,0 +1,106 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { waitFor } from '@testing-library/react';
+import { Editor } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import { Base64Image, base64ImagePluginKey } from './Base64Image.js';
+
+const PNG_BYTES = new Uint8Array([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
+ 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
+ 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89,
+]);
+
+const openEditors: Editor[] = [];
+
+afterEach(() => {
+ for (const editor of openEditors.splice(0)) {
+ if (!editor.isDestroyed) editor.destroy();
+ }
+ vi.restoreAllMocks();
+});
+
+function makeEditor(
+ onError: (error: Error) => void,
+ content = '
hello
',
+): Editor {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+ const editor = new Editor({
+ element,
+ content,
+ extensions: [
+ StarterKit,
+ Base64Image.configure({
+ maxSizeBytes: 1024 * 1024,
+ maxDimension: 0,
+ quality: 0.85,
+ onError,
+ }),
+ ],
+ });
+ openEditors.push(editor);
+ return editor;
+}
+
+function paste(editor: Editor, event: unknown): boolean {
+ const plugin = base64ImagePluginKey.get(editor.state)!;
+ return (plugin.props.handlePaste as (view: unknown, event: unknown) => boolean)(
+ editor.view,
+ event,
+ );
+}
+
+describe('Base64Image hostile conversion failure containment', () => {
+ it('does not inspect a hostile thrown value before reporting a redacted error', async () => {
+ const privateSentinel = new Error('private image conversion sentinel');
+ const getPrototypeOf = vi.fn(() => {
+ throw privateSentinel;
+ });
+ const hostileThrownValue = new Proxy({}, { getPrototypeOf });
+ const file = new File([PNG_BYTES], 'hostile.png', { type: 'image/png' });
+ Object.defineProperty(file, 'arrayBuffer', {
+ configurable: true,
+ value: vi.fn().mockRejectedValue(hostileThrownValue),
+ });
+
+ const onError = vi.fn<(error: Error) => void>();
+ const editor = makeEditor(onError);
+ const preventDefault = vi.fn();
+
+ expect(
+ paste(editor, {
+ clipboardData: {
+ items: [{ kind: 'file', getAsFile: () => file }],
+ },
+ preventDefault,
+ }),
+ ).toBe(true);
+ expect(preventDefault).toHaveBeenCalledOnce();
+
+ await waitFor(() => expect(onError).toHaveBeenCalledOnce());
+ expect(getPrototypeOf).not.toHaveBeenCalled();
+ expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
+ expect(onError.mock.calls[0][0].message).toBe('Image processing failed.');
+ expect(editor.getHTML()).not.toContain('data:image');
+ });
+
+ it('contains host error-observer failures while rejecting unsafe parsed images', () => {
+ const privateSentinel = new Error('private image observer sentinel');
+ const onError = vi.fn<(error: Error) => void>(() => {
+ throw privateSentinel;
+ });
+
+ let editor: Editor | undefined;
+ expect(() => {
+ editor = makeEditor(
+ onError,
+ 'before

after
',
+ );
+ }).not.toThrow();
+
+ expect(onError).toHaveBeenCalledOnce();
+ expect(editor?.getHTML()).not.toContain('
{
});
it('embeds a pasted image file as inline base64', async () => {
+ vi.spyOn(window, 'prompt').mockReturnValue('');
const editor = track(makeEditor());
const items = [
{ kind: 'file', getAsFile: () => null },
@@ -293,6 +294,7 @@ describe('Base64Image drop handler', () => {
});
it('embeds a dropped image at the resolved drop coordinates', async () => {
+ vi.spyOn(window, 'prompt').mockReturnValue('');
const editor = track(makeEditor());
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({
pos: 1,
@@ -314,6 +316,7 @@ describe('Base64Image drop handler', () => {
});
it('falls back to the current selection when coords resolve to nothing', async () => {
+ vi.spyOn(window, 'prompt').mockReturnValue('');
const editor = track(makeEditor());
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue(null);
expect(
diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts
index e506d02c..42fc9ae2 100644
--- a/src/extensions/Base64Image.ts
+++ b/src/extensions/Base64Image.ts
@@ -9,7 +9,7 @@
*/
import Image from '@tiptap/extension-image';
import { Plugin, PluginKey } from '@tiptap/pm/state';
-import { blobToDataUri } from '../converter/base64.js';
+import { Base64SizeError, blobToDataUri } from '../converter/base64.js';
import {
Base64ImageSourceError,
validateInlineImageSource,
@@ -46,6 +46,80 @@ export interface Base64ImageOptions {
export const base64ImagePluginKey = new PluginKey('cwlBase64Image');
+const blobSizeGetter = Object.getOwnPropertyDescriptor(
+ globalThis.Blob.prototype,
+ 'size',
+)!.get!;
+const safeImageIngressErrors = new WeakSet