fix: keep remote images in a quoted reply from loading in the composer

The quoted original in a reply or forward renders in the app document
(a shadow root), where the viewer's iframe CSP does not apply. Opening
a reply therefore loaded every remote image and background of the
original, firing the trackers the viewer had just held back.

The quote's node view now parks remote references in
`data-bulwark-remote-*` attributes for display and restores them when
edits are synced back, so the sent reply still quotes the original as
written. Remote content shows when the policy is `allow` or the sender
is trusted.
This commit is contained in:
Linus Rath
2026-09-26 14:51:14 +02:00
parent c20527cc29
commit 3ac2ab1d97
4 changed files with 187 additions and 8 deletions
@@ -0,0 +1,88 @@
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, buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html';
import { parkRemoteResources, restoreRemoteResources } from '@/lib/email-sanitization';
import { useSettingsStore } from '@/stores/settings-store';
// The quoted original in a reply renders in the app document (a shadow
// root), where the viewer's iframe CSP does not reach, so opening a reply
// loaded every tracker the viewer had held back.
const QUOTE = '<p>Hi</p><img src="https://t.example/pixel.gif" alt="logo"><table background="https://t.example/bg.gif"><tr><td style="background:url(https://t.example/cell.gif)">x</td></tr></table>';
function mount(html: string): Editor {
return new Editor({ extensions: [Document, Paragraph, Text, QuotedHtml], content: html });
}
function shadowInner(editor: Editor): HTMLElement {
const host = editor.view.dom.querySelector('.quoted-html-island') as HTMLElement;
return host.shadowRoot!.firstElementChild as HTMLElement;
}
let editor: Editor | null = null;
afterEach(() => {
editor?.destroy();
editor = null;
useSettingsStore.setState({ externalContentPolicy: 'ask' });
});
describe('parkRemoteResources', () => {
it('parks every external reference and restores it exactly', () => {
const { html, parked } = parkRemoteResources(QUOTE);
expect(parked).toBe(true);
expect(html).not.toMatch(/\s(src|background)="https:/);
expect(html).not.toMatch(/ style="[^"]*t\.example/);
const restored = restoreRemoteResources(html);
expect(restored).toContain('src="https://t.example/pixel.gif"');
expect(restored).toContain('background="https://t.example/bg.gif"');
expect(restored).toContain('url(https://t.example/cell.gif)');
expect(restored).not.toContain('data-bulwark-remote');
});
it('leaves HTML without remote references untouched', () => {
const html = '<p>plain <img src="data:image/png;base64,AAAA"></p>';
expect(parkRemoteResources(html)).toEqual({ html, parked: false });
});
});
describe('QuotedHtml node view', () => {
it('shows the quote without remote resources but sends it as written', () => {
editor = mount(buildQuotedHtmlBlock(QUOTE));
const inner = shadowInner(editor);
expect(inner.innerHTML).not.toMatch(/\s(src|background)="https:/);
expect(inner.innerHTML).not.toMatch(/ style="[^"]*t\.example/);
const sent = serializeEditorContent(editor as unknown as ReactEditor);
expect(sent).toContain('src="https://t.example/pixel.gif"');
expect(sent).not.toContain('data-bulwark-remote');
expect(sent).not.toContain('data-quoted-remote');
});
it('keeps the parked references when the user edits inside the quote', async () => {
editor = mount(buildQuotedHtmlBlock(QUOTE));
const inner = shadowInner(editor);
inner.querySelector('p')!.textContent = 'Hi (redacted)';
inner.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
const sent = serializeEditorContent(editor as unknown as ReactEditor);
expect(sent).toContain('Hi (redacted)');
expect(sent).toContain('src="https://t.example/pixel.gif"');
expect(sent).not.toContain('data-bulwark-remote');
});
it('shows remote resources for a trusted sender or under the allow policy', () => {
editor = mount(buildQuotedHtmlBlock(QUOTE, { remoteAllowed: true }));
expect(shadowInner(editor).innerHTML).toContain('src="https://t.example/pixel.gif"');
editor.destroy();
useSettingsStore.setState({ externalContentPolicy: 'allow' });
editor = mount(buildQuotedHtmlBlock(QUOTE));
expect(shadowInner(editor).innerHTML).toContain('src="https://t.example/pixel.gif"');
});
});
+9 -2
View File
@@ -470,6 +470,11 @@ export function EmailComposer({
separator: signatureSeparatorEnabled,
});
// Whether the quoted original may show its remote images in the editor:
// the viewer's rule, minus the per-message "load" click it doesn't know.
const { externalContentPolicy, isSenderTrusted } = useSettingsStore.getState();
const quoteRemoteAllowed = externalContentPolicy === 'allow' || (!!from?.email && isSenderTrusted(from.email));
// Plugin override (resolved at composer open via onBuildQuoteHeader).
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
if (replyTo.htmlBody) {
@@ -477,7 +482,8 @@ export function EmailComposer({
// nested tables / MJML survive 1:1 (sanitize strips scripts/styles
// first; cid rewrite runs after so its data-cid markers survive).
const island = buildQuotedHtmlBlock(
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
{ remoteAllowed: quoteRemoteAllowed },
);
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${island}`;
}
@@ -503,7 +509,8 @@ export function EmailComposer({
// scripts/styles/head; cid rewrite afterwards so data-cid markers
// aren't dropped by the sanitizer's ALLOW_DATA_ATTR:false.
const island = buildQuotedHtmlBlock(
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
{ remoteAllowed: quoteRemoteAllowed },
);
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div>${island}`;
}
+37 -6
View File
@@ -5,11 +5,18 @@ import { DOMSerializer } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { parkRemoteResources, restoreRemoteResources } from "@/lib/email-sanitization";
import { useSettingsStore } from "@/stores/settings-store";
// Marker attribute that identifies the quoted-original wrapper in serialized
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
// Set on the wrapper the composer builds when the quoted message may load
// remote content (policy "allow", or a trusted sender). Display hint only:
// serializeEditorContent never emits it.
const QUOTED_REMOTE_ALLOWED_ATTR = "data-quoted-remote";
// Reusable style for the quote bar when quoting email text (like in a reply).
const QUOTE_BAR_STYLE =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
@@ -52,6 +59,11 @@ export const QuotedHtml = TiptapNode.create({
// custom serializer. renderHTML below only needs the wrapper.
renderHTML: () => ({}),
},
remoteAllowed: {
default: false,
parseHTML: (el) => el.getAttribute(QUOTED_REMOTE_ALLOWED_ATTR) === "allowed",
renderHTML: () => ({}),
},
};
},
@@ -85,7 +97,21 @@ export const QuotedHtml = TiptapNode.create({
const inner = document.createElement("div");
inner.contentEditable = "true";
inner.style.cssText = "outline:none;";
inner.innerHTML = node.attrs.html || "";
// The quote renders in the app document, where the viewer's iframe
// CSP does not reach: unless the quoted message may load remote
// content, its images and backgrounds are parked so opening a reply
// doesn't fire the trackers the viewer held back. Parking is
// display-only - the html attribute (what gets sent) keeps them.
const blockRemote =
!node.attrs.remoteAllowed && useSettingsStore.getState().externalContentPolicy !== "allow";
let renderedHtml = "";
const render = (html: string) => {
renderedHtml = html;
inner.innerHTML = blockRemote ? parkRemoteResources(html).html : html;
};
const readBack = () => (blockRemote ? restoreRemoteResources(inner.innerHTML) : inner.innerHTML);
render(node.attrs.html || "");
shadow.appendChild(inner);
// Track focus via focusin/focusout: inside a shadow root,
@@ -110,8 +136,9 @@ export const QuotedHtml = TiptapNode.create({
if (typeof getPos !== "function") return;
const pos = getPos();
if (pos == null) return;
const current = inner.innerHTML;
const current = readBack();
if (current === node.attrs.html) return;
renderedHtml = current;
editor.view.dispatch(
editor.view.state.tr
.setNodeAttribute(pos, "html", current)
@@ -138,8 +165,8 @@ export const QuotedHtml = TiptapNode.create({
update: (updatedNode) => {
if (updatedNode.type.name !== "quotedHtml") return false;
// Don't clobber the caret while the user is redacting inside.
if (!focused && inner.innerHTML !== updatedNode.attrs.html) {
inner.innerHTML = updatedNode.attrs.html || "";
if (!focused && (updatedNode.attrs.html || "") !== renderedHtml) {
render(updatedNode.attrs.html || "");
}
return true;
},
@@ -194,6 +221,10 @@ export function serializeEditorContent(editor: Editor): string {
* The `data-quoted-html` marker is what parseHTML keys on, so this exact form
* must be what serializeEditorContent emits too (round-trip consistency).
*/
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
export function buildQuotedHtmlBlock(
sanitizedInnerHtml: string,
opts: { remoteAllowed?: boolean } = {},
): string {
const remote = opts.remoteAllowed ? ` ${QUOTED_REMOTE_ALLOWED_ATTR}="allowed"` : "";
return `<div ${QUOTED_HTML_MARKER}${remote} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
}
+53
View File
@@ -645,6 +645,59 @@ export function blockExternalResourcesOnNode(node: Element): boolean {
return blocked;
}
const PARKED_ATTR_PREFIX = 'data-bulwark-remote-';
const PARKABLE_URL_ATTRS = ['src', 'poster', 'background'] as const;
/**
* Display-only counterpart of {@link blockExternalResourcesOnNode} for HTML
* that is rendered in the app document itself (the quoted original in the
* composer) and still has to be sent as written. Every external resource
* reference is parked in a `data-bulwark-remote-*` attribute so nothing
* loads, and {@link restoreRemoteResources} puts it back exactly. Parsing
* happens in an inert DOMParser document, which fetches nothing.
*/
export function parkRemoteResources(html: string): { html: string; parked: boolean } {
const doc = parseHtmlSafely(`<body>${html}</body>`);
let parked = false;
for (const el of Array.from(doc.body.querySelectorAll('*'))) {
for (const attr of PARKABLE_URL_ATTRS) {
const value = el.getAttribute(attr);
if (isExternalResourceUrl(value)) {
el.setAttribute(PARKED_ATTR_PREFIX + attr, value!);
el.removeAttribute(attr);
parked = true;
}
}
const srcset = el.getAttribute('srcset');
if (srcset && srcsetHasExternalUrl(srcset)) {
el.setAttribute(PARKED_ATTR_PREFIX + 'srcset', srcset);
el.removeAttribute('srcset');
parked = true;
}
const style = el.getAttribute('style');
if (style && styleHasExternalUrl(style)) {
el.setAttribute(PARKED_ATTR_PREFIX + 'style', style);
el.setAttribute('style', stripExternalCssUrls(style));
parked = true;
}
}
return { html: parked ? doc.body.innerHTML : html, parked };
}
/** Undo {@link parkRemoteResources} so the HTML carries its original references again. */
export function restoreRemoteResources(html: string): string {
if (!html.includes(PARKED_ATTR_PREFIX)) return html;
const doc = parseHtmlSafely(`<body>${html}</body>`);
for (const el of Array.from(doc.body.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
if (!attr.name.startsWith(PARKED_ATTR_PREFIX)) continue;
el.setAttribute(attr.name.slice(PARKED_ATTR_PREFIX.length), attr.value);
el.removeAttribute(attr.name);
}
}
return doc.body.innerHTML;
}
/**
* Safe HTML parsing without execution
* Use instead of innerHTML for detection/parsing