fix: stop replies and forwards loading the remote images the viewer blocked

The composer's editor page had no Content-Security-Policy, so quoting a
message loaded every remote image and tracking pixel in it, even when
the viewer had shown that message with remote content blocked.

The editor page now carries a CSP that mirrors the viewer's: nothing
loads but our inline script, styles and images, and remote images and
fonts are blocked whenever the quoted original is one the viewer would
block (untrusted sender and a policy other than "allow"). A reopened
draft that carries a quote counts as untrusted. Inline and pasted
images (`data:`, `blob:`) keep working, and the sent HTML is unchanged.
This commit is contained in:
Linus Rath
2026-09-24 19:10:44 +02:00
parent 92b42e4a4f
commit 235f4ea77f
4 changed files with 138 additions and 4 deletions
+4 -2
View File
@@ -68,6 +68,8 @@ export interface RichTextEditorHandle {
interface Props {
initialHtml?: string;
placeholder?: string;
/** Keep remote images and fonts from loading (read once, on mount). */
blockRemoteImages?: boolean;
onChange?: (html: string) => void;
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
@@ -75,7 +77,7 @@ interface Props {
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
ref,
) {
const c = useColors();
@@ -94,7 +96,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c }),
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
+65 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildEditorHtml } from '../editor-html';
import { buildEditorCsp, buildEditorHtml, shouldBlockEditorRemoteImages } from '../editor-html';
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
function extractScript(html: string): string {
@@ -56,3 +56,67 @@ describe('buildEditorHtml', () => {
expect(script).toContain("post('htmlSnapshot', { id: id, html: editor.innerHTML })");
});
});
describe('editor CSP', () => {
const base = { initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS };
function cspOf(html: string): string {
const m = html.match(/<meta http-equiv="Content-Security-Policy" content="([^"]*)"/);
expect(m, 'page must carry a CSP meta').toBeTruthy();
return m![1];
}
it('puts the policy in the head before any content', () => {
const html = buildEditorHtml(base);
expect(html.indexOf('Content-Security-Policy')).toBeLessThan(html.indexOf('<body>'));
expect(cspOf(html)).toContain("default-src 'none'");
expect(cspOf(html)).toContain("script-src 'unsafe-inline'");
});
it('blocks remote images and fonts but keeps data: and blob: images', () => {
const csp = cspOf(buildEditorHtml({ ...base, blockRemoteImages: true }));
expect(csp).toContain('img-src data: blob:;');
expect(csp).not.toMatch(/https?:/);
expect(csp).toContain('font-src data:;');
});
it('allows remote images when not blocking', () => {
expect(buildEditorCsp(false)).toContain('img-src data: blob: https: http:');
expect(cspOf(buildEditorHtml(base))).toBe(buildEditorCsp(false));
});
});
describe('shouldBlockEditorRemoteImages', () => {
const quote = '<p>hi</p><img src="https://tracker.example/p.gif">';
it('blocks a quoted original from an untrusted sender', () => {
expect(shouldBlockEditorRemoteImages({
seedHtml: quote, isDraft: false, externalContentPolicy: 'ask', senderTrusted: false,
})).toBe(true);
expect(shouldBlockEditorRemoteImages({
seedHtml: quote, isDraft: false, externalContentPolicy: 'block', senderTrusted: false,
})).toBe(true);
});
it('allows it for a trusted sender or the allow policy', () => {
expect(shouldBlockEditorRemoteImages({
seedHtml: quote, isDraft: false, externalContentPolicy: 'ask', senderTrusted: true,
})).toBe(false);
expect(shouldBlockEditorRemoteImages({
seedHtml: quote, isDraft: false, externalContentPolicy: 'allow', senderTrusted: false,
})).toBe(false);
});
it('allows a new message, which quotes nothing', () => {
expect(shouldBlockEditorRemoteImages({
seedHtml: undefined, isDraft: false, externalContentPolicy: 'block', senderTrusted: false,
})).toBe(false);
});
it('blocks a reopened draft only when it carries a quote', () => {
const opts = { isDraft: true, externalContentPolicy: 'ask' as const, senderTrusted: true };
expect(shouldBlockEditorRemoteImages({ ...opts, seedHtml: '<p>Hi</p><div data-quoted-html="true">x</div>' })).toBe(true);
expect(shouldBlockEditorRemoteImages({ ...opts, seedHtml: '<p>Hi</p><blockquote>x</blockquote>' })).toBe(true);
expect(shouldBlockEditorRemoteImages({ ...opts, seedHtml: '<p>Hi</p><img src="https://me.example/logo.png">' })).toBe(false);
});
});
+49 -1
View File
@@ -15,12 +15,58 @@ import type { ThemePalette } from '../theme/tokens';
// user types, and the parent ScrollView handles overflow.
export const MIN_EDITOR_HEIGHT = 220;
/**
* The editor page's Content-Security-Policy, mirroring the viewer's
* (`src/lib/email-html.ts`). Nothing but images, styles and our own inline
* script may load. With `blockRemoteImages` images and fonts are limited to
* what the composer produces itself - `data:` (inline and hydrated `cid:`
* images) and `blob:` (pasted images) - so a quoted original's tracking
* pixels and remote images don't fire just because it is replied to.
*/
export function buildEditorCsp(blockRemoteImages: boolean): string {
const resources = blockRemoteImages
? ['img-src data: blob:', 'font-src data:', "media-src 'none'"]
: ['img-src data: blob: https: http:', 'font-src data: https: http:', 'media-src data: https: http:'];
return [
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
...resources,
"base-uri 'none'",
"form-action 'none'",
].join('; ');
}
/**
* Whether the composer must open the editor with remote images blocked: it
* is seeded with someone else's HTML (the quoted original of a reply or
* forward, or a reopened draft that carries a quote) and the viewer would
* have shown that message with its remote content blocked too. A reopened
* draft doesn't say whose message it quotes, so its quote counts as
* untrusted.
*/
export function shouldBlockEditorRemoteImages(opts: {
/** The HTML the editor is seeded with from a message, if any. */
seedHtml?: string | null;
isDraft: boolean;
externalContentPolicy: 'allow' | 'block' | 'ask';
/** The quoted message's sender is trusted (ignored for drafts). */
senderTrusted: boolean;
}): boolean {
const { seedHtml, isDraft, externalContentPolicy, senderTrusted } = opts;
if (!seedHtml || externalContentPolicy === 'allow') return false;
if (isDraft) return /\sdata-quoted-html=|<blockquote\b/i.test(seedHtml);
return !senderTrusted;
}
export function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
c: ThemePalette;
/** Block remote images and fonts (see `buildEditorCsp`). */
blockRemoteImages?: boolean;
}): string {
const { initialHtml, placeholder, c } = opts;
const { initialHtml, placeholder, c, blockRemoteImages = false } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
@@ -30,6 +76,8 @@ export function buildEditorHtml(opts: {
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="${buildEditorCsp(blockRemoteImages)}" />
<meta name="referrer" content="no-referrer" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+20
View File
@@ -55,6 +55,7 @@ import {
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient,
} from '../lib/recipients';
import { findDraftIdentityId, resolveReplyFrom } from '../lib/reply-identity';
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
import {
hasSignature, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature,
insertSignatureAboveQuote, getPlainTextSignature, appendPlainTextSignature,
@@ -530,6 +531,24 @@ export default function ComposeScreen({ route, navigation }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Remote images in a quoted original stay blocked in the editor whenever
// the viewer blocks them; the sent HTML keeps them. Read once: the editor
// page is built on mount.
const blockRemoteImages = React.useMemo(() => {
const settings = useSettingsStore.getState();
const sender = replyTo?.from.email?.trim().toLowerCase();
return shouldBlockEditorRemoteImages({
seedHtml: draft ? draft.htmlBody : replyTo?.htmlBody,
isDraft: !!draft,
externalContentPolicy: settings.externalContentPolicy,
senderTrusted: !!sender && (
settings.isSenderTrusted(sender)
|| useContactsStore.getState().trustedSenderEmails.includes(sender)
),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const initialBodyHtml = React.useMemo(
() => {
if (draft) {
@@ -2317,6 +2336,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<RichTextEditor
ref={editorRef}
initialHtml={initialBodyHtml}
blockRemoteImages={blockRemoteImages}
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
onChange={setBodyHtml}
onSelectionChange={setSelState}