From 235f4ea77fd744ffbbe56f9256f99f635fb19d1a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:37:45 +0200 Subject: [PATCH] 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. --- src/components/RichTextEditor.tsx | 6 ++- src/lib/__tests__/editor-html.test.ts | 66 ++++++++++++++++++++++++++- src/lib/editor-html.ts | 50 +++++++++++++++++++- src/screens/ComposeScreen.tsx | 20 ++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index e8b0a8f0..2590edfb 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -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(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(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 diff --git a/src/lib/__tests__/editor-html.test.ts b/src/lib/__tests__/editor-html.test.ts index d5d0acbc..d5cac31a 100644 --- a/src/lib/__tests__/editor-html.test.ts +++ b/src/lib/__tests__/editor-html.test.ts @@ -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: '


', placeholder: 'Write…', c: LIGHT_COLORS }; + + function cspOf(html: string): string { + const m = html.match(/ { + const html = buildEditorHtml(base); + expect(html.indexOf('Content-Security-Policy')).toBeLessThan(html.indexOf('')); + 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 = '

hi

'; + + 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: '

Hi

x
' })).toBe(true); + expect(shouldBlockEditorRemoteImages({ ...opts, seedHtml: '

Hi

x
' })).toBe(true); + expect(shouldBlockEditorRemoteImages({ ...opts, seedHtml: '

Hi

' })).toBe(false); + }); +}); diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index 6e00881d..f0e1ad4d 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -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=| + +