mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user