fix: sanitize quoted and signature HTML in re-opened drafts (GHSA-g35q-3gpc-x2rr)

This commit is contained in:
Linus Rath
2026-10-24 22:13:19 +02:00
parent 89e93e330e
commit a49ab079ec
4 changed files with 90 additions and 5 deletions
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from 'vitest';
import { Editor } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import type { Editor as ReactEditor } from '@tiptap/react';
import { QuotedHtml, serializeEditorContent } from '../quoted-html';
import { SignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block';
// A re-opened draft hands its stored HTML to the editor as is, and the quote
// and signature islands copy their inner HTML verbatim into the app document.
// A draft written by another client, or by another member of a shared
// mailbox, could carry event handlers that then ran in the webmail origin.
const PAYLOAD = '<img src="x" onerror="window.__xss = 1"><a href="javascript:alert(1)">l</a><script>window.__xss = 2</script><p>Quoted original</p>';
function mount(html: string): Editor {
return new Editor({ extensions: [Document, Paragraph, Text, QuotedHtml, SignatureBlock], content: html });
}
function islandHtml(editor: Editor, selector: string): string {
const host = editor.view.dom.querySelector(selector) as HTMLElement;
return host.shadowRoot!.innerHTML;
}
function expectInert(html: string) {
expect(html).not.toMatch(/onerror/i);
expect(html).not.toMatch(/javascript:/i);
expect(html).not.toMatch(/<script/i);
}
let editor: Editor | null = null;
afterEach(() => {
editor?.destroy();
editor = null;
});
describe('composer islands sanitize what they parse', () => {
it('strips script vectors from a quoted original in a re-opened draft', () => {
editor = mount(`<p>Hi</p><div data-quoted-html>${PAYLOAD}</div>`);
const rendered = islandHtml(editor, '.quoted-html-island');
expectInert(rendered);
expect(rendered).toContain('Quoted original');
expectInert(serializeEditorContent(editor as unknown as ReactEditor));
});
it('strips script vectors from a signature in a re-opened draft', () => {
editor = mount(`<p>Hi</p><div ${SIGNATURE_BLOCK_MARKER}>${PAYLOAD}</div>`);
const rendered = islandHtml(editor, `[${SIGNATURE_BLOCK_MARKER}]`);
expectInert(rendered);
expect(rendered).toContain('Quoted original');
expectInert(serializeEditorContent(editor as unknown as ReactEditor));
});
it('keeps the composer markers the islands depend on', () => {
editor = mount('<div data-quoted-html><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-cid="logo@x"></div>'
+ `<div ${SIGNATURE_BLOCK_MARKER}><div data-signature-block="start"></div>Jane<div data-signature-block="end"></div></div>`);
const out = serializeEditorContent(editor as unknown as ReactEditor);
expect(out).toContain('data-cid="logo@x"');
expect(out).toContain('data-signature-block="start"');
expect(out).toContain('data-signature-block="end"');
});
});
+4 -3
View File
@@ -7,7 +7,7 @@ import type { Editor } from "@tiptap/react";
import { addTrailingParagraphBreaks } from "@/components/email/paragraph-breaks";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { parkRemoteResources, restoreRemoteResources } from "@/lib/email-sanitization";
import { parkRemoteResources, restoreRemoteResources, sanitizeComposerIslandHtml } from "@/lib/email-sanitization";
import { useSettingsStore } from "@/stores/settings-store";
// Marker attribute that identifies the quoted-original wrapper in serialized
@@ -65,8 +65,9 @@ export const QuotedHtml = TiptapNode.create({
default: "",
// Capture the verbatim inner HTML when parsing. Because the node is
// an atom, ProseMirror does NOT descend into the children, so they
// never hit the schema.
parseHTML: (el) => el.innerHTML,
// never hit the schema - nor its attribute allowlist, so sanitize
// here: a re-opened draft hands this wrapper in unsanitized.
parseHTML: (el) => sanitizeComposerIslandHtml(el.innerHTML),
// Not rendered as an attribute - the real content round-trips via the
// custom serializer. renderHTML below only needs the wrapper.
renderHTML: () => ({}),
+3 -2
View File
@@ -3,7 +3,7 @@
import { Node as TiptapNode, mergeAttributes, type Editor } from "@tiptap/core";
import { TextSelection } from "@tiptap/pm/state";
import { DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model";
import { parseHtmlSafely } from "@/lib/email-sanitization";
import { parseHtmlSafely, sanitizeComposerIslandHtml } from "@/lib/email-sanitization";
// Marker attribute that identifies the signature wrapper in serialized HTML,
// so parseHTML can recognise it on the way back in (initial content, drafts).
@@ -80,7 +80,8 @@ export const SignatureBlock = TiptapNode.create({
// Capture the verbatim inner HTML when parsing. Because the node is an
// atom, ProseMirror does NOT descend into the children, so the rich
// signature markup never hits (and is never mangled by) the schema.
parseHTML: (el) => el.innerHTML,
// Sanitized here, since a re-opened draft hands it in unsanitized.
parseHTML: (el) => sanitizeComposerIslandHtml(el.innerHTML),
// The real content round-trips via the custom serializer
// (serializeEditorContent); renderHTML below only needs the wrapper.
renderHTML: () => ({}),
+19
View File
@@ -57,6 +57,25 @@ export function sanitizeEmailHtml(html: string): string {
return sanitizeWithDataUriGuard(html, EMAIL_SANITIZE_CONFIG);
}
const COMPOSER_ISLAND_SANITIZE_CONFIG = {
...EMAIL_SANITIZE_CONFIG,
// The composer's own markers (data-cid on inline images, the signature's
// data-signature-block brackets) must survive; data-* is inert.
ALLOW_DATA_ATTR: true,
};
/**
* Sanitize the verbatim HTML a composer island (quoted original, signature)
* picks up when the editor parses its wrapper. The islands render into the
* app document, not the sandboxed viewer frame, and their wrapper reaches
* the editor from places that never ran a sanitizer: a re-opened draft
* written by another client or another member of a shared mailbox, a paste.
*/
export function sanitizeComposerIslandHtml(html: string): string {
if (!html) return '';
return sanitizeWithDataUriGuard(html, COMPOSER_ISLAND_SANITIZE_CONFIG);
}
/**
* Sanitize config for emails rendered inside a sandboxed iframe.
* Allows <style> tags because CSS is scoped to the iframe document and