mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: sanitize quoted and signature HTML in re-opened drafts (GHSA-g35q-3gpc-x2rr)
This commit is contained in:
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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,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: () => ({}),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user