Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
41044b7
test(a11y): require alt intent for pasted and dropped images
seonghobae Aug 10, 2026
a2e318d
fix(a11y): require alt intent for file ingress
seonghobae Aug 10, 2026
87e9a52
test(a11y): make decorative image intent explicit
seonghobae Aug 10, 2026
7aa53b0
test(a11y): stub file-ingress author intent
seonghobae Aug 10, 2026
37b02f9
test(readonly): reject image file ingress
seonghobae Aug 10, 2026
eafce89
fix(readonly): reject image file ingress
seonghobae Aug 10, 2026
4705a25
test(a11y): cover read-only transition during image ingress
seonghobae Aug 10, 2026
0e98077
fix(a11y): recheck editability after image conversion
seonghobae Aug 10, 2026
efa2806
test(image): preserve multi-file ingress order
seonghobae Aug 10, 2026
38f38b9
test(image): type controlled binary file
seonghobae Aug 10, 2026
138e955
fix(image): preserve ingress order across async conversion
seonghobae Aug 10, 2026
108eb5a
fix(image): map ordered drop insertion positions
seonghobae Aug 10, 2026
6b8d78c
test(reliability): prove hostile image conversion throw escapes
seonghobae Aug 16, 2026
11db0f6
fix(reliability): contain hostile image conversion throw values
seonghobae Aug 16, 2026
356a65d
fix(reliability): preserve safe image ingress diagnostics without ref…
seonghobae Aug 16, 2026
60a1225
test(reliability): reject malformed image processing limits
seonghobae Aug 16, 2026
04301f1
fix(reliability): validate image processing limits
seonghobae Aug 16, 2026
e4425c4
test(reliability): expose image error observer escape
seonghobae Aug 16, 2026
52f7a1c
fix(reliability): contain image error observer failures
seonghobae Aug 16, 2026
1c945a6
merge: synchronize image ingress reliability lane with protected main
seonghobae Aug 17, 2026
cdbf4a3
chore: synchronize image ingress accessibility with protected main
seonghobae Aug 28, 2026
a7ee3a3
Merge remote-tracking branch 'origin/main' into codex/pr155-restack
seonghobae Sep 4, 2026
7e606f2
test(ci): cover event-specific Python matrix
seonghobae Sep 4, 2026
e7dcda5
test(ci): bind Python matrix to event
seonghobae Sep 4, 2026
6a74f2e
revert(ci): restore Office contract owner
seonghobae Sep 4, 2026
15b9b11
test(image): reject read-only changes during alt prompting
seonghobae Sep 6, 2026
da315e7
fix(image): recheck editor state after alt prompting
seonghobae Sep 6, 2026
4a53c08
test(image): cover editor destruction during alt prompting
seonghobae Sep 6, 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
106 changes: 106 additions & 0 deletions src/extensions/Base64Image.hostileError.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<p>hello</p>',
): 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,
'<p>before</p><img src="https://example.invalid/private.png"><p>after</p>',
);
}).not.toThrow();

expect(onError).toHaveBeenCalledOnce();
expect(editor?.getHTML()).not.toContain('<img');
expect(editor?.getText()).toContain('before');
expect(editor?.getText()).toContain('after');
});
});
3 changes: 3 additions & 0 deletions src/extensions/Base64Image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ describe('Base64Image paste handler', () => {
});

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 },
Expand All @@ -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,
Expand All @@ -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(
Expand Down
156 changes: 134 additions & 22 deletions src/extensions/Base64Image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<object>();
const INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE =
'Image processing options must use bounded numeric values.';

/** Reject a malformed runtime byte or pixel limit before consuming input. */
function assertNonNegativeSafeInteger(value: number): void {
if (!Number.isSafeInteger(value) || value < 0) {
throw new RangeError(INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE);
}
}

/** Reject a malformed runtime image quality before browser image processing. */
function assertImageQuality(value: number): void {
if (!Number.isFinite(value) || value < 0 || value > 1) {
throw new RangeError(INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE);
}
}

/** Read Blob byte length from its platform internal slot, ignoring own accessors. */
function intrinsicBlobSize(blob: Blob): number {
return Reflect.apply(blobSizeGetter, blob, []) as number;
}

/** Mark an Inkspan-created ingress error without exposing a forgeable property. */
function markSafeImageIngressError<T extends Error>(error: T): T {
safeImageIngressErrors.add(error);
return error;
}

/** Normalize an untrusted caught value without inspecting or coercing it. */
function normalizeUntrustedImageIngressError(error: unknown): Error {
return safeImageIngressErrors.has(error as object)
? (error as Error)
: new Error('Image processing failed.');
}

/**
* Notify the host about an image rejection without granting observer failures
* authority over parser, transaction-filter, or asynchronous ingress results.
*/
function reportImageError(
observer: Base64ImageOptions['onError'],
error: Error,
): void {
if (!observer) return;
try {
observer(error);
} catch {
// Host presentation/telemetry is best-effort and cannot change rejection.
}
}

/**
* Validate a generated source and privately brand any deterministic policy
* failure so the async ingress boundary may preserve its safe diagnostic.
*/
function validateGeneratedInlineImageSource(
source: string,
maxSizeBytes: number,
): string {
try {
return validateInlineImageSource(source, maxSizeBytes);
} catch (error) {
if (typeof error === 'object' && error !== null) {
safeImageIngressErrors.add(error);
}
throw error;
}
}

/**
* Downscale an image data URI using an offscreen canvas when it exceeds
* `maxDimension`. Returns the original URI unchanged when no DOM is available
Expand All @@ -56,10 +130,12 @@ export async function downscaleDataUri(
maxDimension: number,
quality: number,
): Promise<string> {
assertNonNegativeSafeInteger(maxDimension);
assertImageQuality(quality);
if (
typeof document === 'undefined' ||
typeof globalThis.Image === 'undefined' ||
maxDimension <= 0
maxDimension === 0
) {
return dataUri;
}
Expand Down Expand Up @@ -100,24 +176,39 @@ export async function imageFileToInlineDataUri(
file: Blob,
options: Pick<Base64ImageOptions, 'maxSizeBytes' | 'maxDimension' | 'quality'>,
): Promise<string> {
assertNonNegativeSafeInteger(options.maxSizeBytes);
if (options.maxDimension !== undefined) {
assertNonNegativeSafeInteger(options.maxDimension);
}
assertImageQuality(options.quality);

if (options.maxSizeBytes > 0) {
const sourceBytes = intrinsicBlobSize(file);
if (sourceBytes > options.maxSizeBytes) {
throw markSafeImageIngressError(
new Base64SizeError(sourceBytes, options.maxSizeBytes),
);
}
}

const dataUri = await blobToDataUri(file, {
maxBytes: options.maxSizeBytes > 0 ? options.maxSizeBytes : undefined,
});
validateInlineImageSource(dataUri, options.maxSizeBytes);
if (options.maxDimension && options.maxDimension > 0) {
validateGeneratedInlineImageSource(dataUri, options.maxSizeBytes);
if (options.maxDimension !== undefined && options.maxDimension > 0) {
const scaled = await downscaleDataUri(
dataUri,
options.maxDimension,
options.quality,
);
return validateInlineImageSource(scaled, options.maxSizeBytes);
return validateGeneratedInlineImageSource(scaled, options.maxSizeBytes);
}
return dataUri;
}

/** Normalize a caught value to the Error contract exposed to hosts. */
/** Normalize a caught value from Inkspan-controlled validation paths. */
function normalizeImageError(error: unknown): Error {
/* v8 ignore next -- all shipped validation and conversion paths throw Error. */
/* v8 ignore next -- all shipped validation paths throw Error. */
return error instanceof Error ? error : new Error('Image processing failed.');
}

Expand Down Expand Up @@ -155,7 +246,7 @@ export const Base64Image = Image.extend<Base64ImageOptions>({
title: element.getAttribute('title'),
};
} catch (error) {
this.options.onError?.(normalizeImageError(error));
reportImageError(this.options.onError, normalizeImageError(error));
return false;
}
},
Expand Down Expand Up @@ -190,24 +281,45 @@ export const Base64Image = Image.extend<Base64ImageOptions>({
const editor = this.editor;

const insertFiles = (files: File[], at?: number) => {
if (!editor.isEditable) return false;
const images = files.filter((file) => file.type.startsWith('image/'));
if (images.length === 0) return false;
for (const file of images) {
imageFileToInlineDataUri(file, options)
.then((src) => {
if (editor.isDestroyed) return;
// New images are explicitly decorative until an author supplies
// meaningful replacement text through the toolbar.
const node = editor.schema.nodes.image.create({ src, alt: '' });

const insertInSourceOrder = async () => {
let insertionPosition = at;
for (const file of images) {
try {
const src = await imageFileToInlineDataUri(file, options);
if (editor.isDestroyed || !editor.isEditable) return;
const alternativeText = window.prompt(
'Image alternative text. Leave empty only if this image is decorative.',
'',
);
if (editor.isDestroyed || !editor.isEditable) return;
if (alternativeText === null) continue;
const node = editor.schema.nodes.image.create({
src,
alt: alternativeText,
});
const pos =
typeof at === 'number' ? at : editor.state.selection.from;
typeof insertionPosition === 'number'
? insertionPosition
: editor.state.selection.from;
const transaction = editor.state.tr.insert(pos, node);
if (typeof insertionPosition === 'number') {
insertionPosition = transaction.mapping.map(pos, 1);
}
editor.view.dispatch(transaction);
})
.catch((error: unknown) => {
options.onError?.(normalizeImageError(error));
});
}
} catch (error) {
reportImageError(
options.onError,
normalizeUntrustedImageIngressError(error),
);
}
}
};

void insertInSourceOrder();
return true;
};

Expand All @@ -232,7 +344,7 @@ export const Base64Image = Image.extend<Base64ImageOptions>({
}
});
if (!rejection) return true;
options.onError?.(rejection);
reportImageError(options.onError, rejection);
return false;
},
props: {
Expand Down
10 changes: 7 additions & 3 deletions src/extensions/Base64ImageAlt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ afterEach(() => {
vi.restoreAllMocks();
});

describe('Base64Image accessible insertion defaults', () => {
it('adds explicit empty alt text to pasted images', async () => {
describe('Base64Image explicit decorative insertion intent', () => {
it('adds explicit empty alt text to pasted images only after decorative intent', async () => {
const editor = createEditor();
const prompt = vi.spyOn(window, 'prompt').mockReturnValue('');
const plugin = base64ImagePluginKey.get(editor.state)!;
const preventDefault = vi.fn();
const handled = (
Expand All @@ -45,13 +46,15 @@ describe('Base64Image accessible insertion defaults', () => {
expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalledOnce();
await waitFor(() => {
expect(prompt).toHaveBeenCalledOnce();
expect(editor.getHTML()).toContain('data:image/png;base64');
expect(editor.getHTML()).toContain('alt=""');
});
});

it('adds explicit empty alt text to dropped images', async () => {
it('adds explicit empty alt text to dropped images only after decorative intent', async () => {
const editor = createEditor();
const prompt = vi.spyOn(window, 'prompt').mockReturnValue('');
const plugin = base64ImagePluginKey.get(editor.state)!;
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 0, inside: -1 });
const preventDefault = vi.fn();
Expand All @@ -67,6 +70,7 @@ describe('Base64Image accessible insertion defaults', () => {
expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalledOnce();
await waitFor(() => {
expect(prompt).toHaveBeenCalledOnce();
expect(editor.getHTML()).toContain('data:image/png;base64');
expect(editor.getHTML()).toContain('alt=""');
});
Expand Down
Loading
Loading