From 07679cbf8c21169ff6f294ebbf1eaa702d54b3f7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:22:05 +0100 Subject: [PATCH 1/8] fix: keep a hostile inline image type out of the reader and the composer --- src/lib/__tests__/compose-html.test.ts | 10 +++++++ src/lib/__tests__/email-html.test.ts | 34 +++++++++++++++++++++++ src/lib/compose-html.ts | 7 +++-- src/lib/email-html.ts | 37 +++++++++++++++++++++++--- 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/lib/__tests__/compose-html.test.ts b/src/lib/__tests__/compose-html.test.ts index 3874b6f1..ae0bdcc2 100644 --- a/src/lib/__tests__/compose-html.test.ts +++ b/src/lib/__tests__/compose-html.test.ts @@ -186,4 +186,14 @@ describe('cid image hydration', () => { expect(hydrated).toContain('data-cid="pic@x"'); expect(rewriteInlineImages(hydrated).html).toContain('src="cid:pic@x"'); }); + + // The composer builds the URI from the part's type, which the server + // returns verbatim. + it('leaves the placeholder when the data URL is not an image type plus base64', () => { + const { html } = rewriteCidImagesForEditor('

'); + const hostile = 'data:image/x">;base64,QUJD'; + const hydrated = replaceInlineImagePlaceholders(html, new Map([['pic@x', hostile]])); + expect(hydrated).toBe(html); + expect(hydrated).not.toContain(' { expect(res.html).toContain(`src="${TRANSPARENT_BLOCKED_PIXEL}"`); }); + // Stalwart (0.16 and 1.0) returns a part's Content-Type verbatim. A related + // part declared as this type, with bytes that are no known image format, + // used to put a live refresh meta into the reader (Android follows it + // without a tap). + it('never writes a hostile part type into the document', () => { + const type = 'image/x">'; + const bytes = new TextEncoder().encode('ABC'); + const uri = `data:${sniffImageMime(bytes, type)};base64,QUJD`; + for (const blockRemoteImages of [true, false]) { + const { html } = prepareEmailHtml('

hi

', { blockRemoteImages, cidMap: { p1: uri }, isDark: false }); + expect(html).not.toMatch(/]*refresh/i); + expect(html).not.toContain('evil.example'); + expect(html).toContain(``); + } + // A map value that is not an image type plus base64 is not used either. + const { html } = prepareEmailHtml('', { cidMap: { p1: 'data:image/png;base64,AA">x' } }); + expect(html).not.toContain('x'); + }); + + it('swaps cid references in src only, not in text or other attributes', () => { + const { html } = prepareEmailHtml( + 'cid:abc

see cid:abc

', + { cidMap: { abc: 'data:image/png;base64,AAA' } }, + ); + expect(html).toContain('cid:abc'); + expect(html).toContain('

see cid:abc

'); + }); + it('tightens Word/Outlook HTML and drops the gutter for full-bleed canvases in auto mode', () => { const word = prepareEmailHtml('

x

'); expect(word.html).toContain('p.MsoNormal'); @@ -319,6 +347,12 @@ describe('sniffImageMime', () => { expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'image/tiff')).toBe('image/tiff'); expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'application/octet-stream')).toBe('application/octet-stream'); }); + + it('uses only a plain image type from the part', () => { + expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'image/x">')) + .toBe('application/octet-stream'); + expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'image/svg+xml; charset=utf-8')).toBe('image/svg+xml'); + }); }); describe('reading width on a wide pane', () => { diff --git a/src/lib/compose-html.ts b/src/lib/compose-html.ts index 9460d19b..96313e15 100644 --- a/src/lib/compose-html.ts +++ b/src/lib/compose-html.ts @@ -2,7 +2,7 @@ // what the webmail composer (`components/email/email-composer.tsx`) produces. // All values are HTML-escaped before insertion into the document. -import { escapeHtml, stripDangerousTags } from './email-html'; +import { escapeHtml, isInlineImageDataUri, stripDangerousTags } from './email-html'; import { buildQuoteHeader, type QuoteHeaderLabels } from './quote-header'; import type { TimeFormat } from '../stores/settings-store'; @@ -269,7 +269,10 @@ export function replaceInlineImagePlaceholders( (full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => { const cid = dq ?? sq ?? ''; const dataUrl = cidToDataUrl.get(cid); - if (!dataUrl) return full; + // The URI is built from the part's declared type, which the server + // returns verbatim: anything but an image type and base64 would land + // in the editor's markup as written. + if (!dataUrl || !isInlineImageDataUri(dataUrl)) return full; const rest = (before + after).replace(/\ssrc=("[^"]*"|'[^']*')/gi, '').trim(); return ``; }, diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts index 24c296f8..48511910 100644 --- a/src/lib/email-html.ts +++ b/src/lib/email-html.ts @@ -752,10 +752,37 @@ export function extractCidRefs(html: string): string[] { return Array.from(out); } +/** + * A resolved inline image: an image type and nothing but base64 after it. + * The type comes from the part's Content-Type when the bytes aren't a known + * format, and the server hands that header over verbatim, quotes and `>` + * included. + */ +const INLINE_IMAGE_DATA_URI = /^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]*$/i; + +export function isInlineImageDataUri(uri: string | null | undefined): boolean { + return !!uri && INLINE_IMAGE_DATA_URI.test(uri); +} + +// Swap `cid:` references for their resolved data: URI inside src (and the +// legacy background) attributes only, through the tokenizer, on the +// sanitised markup. A string replace on the whole document also rewrote +// text and other attributes, and wrote a map value into the markup as is. function replaceCidRefs(html: string, cidMap: Record): string { - return html.replace(/\bcid:([^"'\s)>]+)/gi, (_m, ref: string) => { - const key = ref.replace(/^<|>$/g, ''); - return cidMap[key] ?? TRANSPARENT_BLOCKED_PIXEL; + return mapTags(html, (_tag, attrs) => { + let changed = false; + for (const attr of attrs) { + if (attr.value === null) continue; + const name = asciiLower(attr.name); + if (name !== 'src' && name !== 'background') continue; + const raw = attr.value.trim(); + const url = /^cid:/i.test(raw) ? raw : decodeUriEntities(raw); + if (!/^cid:/i.test(url)) continue; + const uri = cidMap[url.slice(4).replace(/^<|>$/g, '')]; + attr.value = isInlineImageDataUri(uri) ? uri : TRANSPARENT_BLOCKED_PIXEL; + changed = true; + } + return changed ? attrs : null; }); } @@ -941,8 +968,10 @@ export function sniffImageMime(bytes: Uint8Array, fallback?: string): string { && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 ) return 'image/webp'; if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp'; + // The declared type goes into a data: URI, and the server returns the + // part's Content-Type verbatim: only a plain `image/` is used. const f = (fallback || '').toLowerCase().split(';')[0].trim(); - if (f.startsWith('image/')) return f; + if (/^image\/[a-z0-9.+-]+$/.test(f)) return f; return 'application/octet-stream'; } From 47a1d24999217c8544d83ff5edf89a0dcaa492da Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:25:57 +0100 Subject: [PATCH 2/8] fix: keep a quoted stylesheet from ending the composer's editor script --- src/components/RichTextEditor.tsx | 10 ++------ src/lib/__tests__/editor-html.test.ts | 35 +++++++++++++++++++++---- src/lib/__tests__/email-html.test.ts | 11 ++++++++ src/lib/editor-html.ts | 37 ++++++++++++++++++++++++--- src/lib/email-html.ts | 15 ++++++++++- 5 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 3d1444ef..b961d380 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -4,7 +4,7 @@ import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { useColors } from '../theme/colors'; import type { ThemePalette } from '../theme/tokens'; import { perfEndAfterPaint } from '../lib/perf-marks'; -import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html'; +import { buildEditorHtml, isEditorNavigationAllowed, MIN_EDITOR_HEIGHT } from '../lib/editor-html'; export type RichTextCommand = | 'bold' @@ -276,13 +276,7 @@ const RichTextEditor = React.forwardRef(function Ri androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined} style={styles.webview} containerStyle={styles.webviewContainer} - onShouldStartLoadWithRequest={(request) => { - // Allow only the initial about:blank doc + data URIs we emit - // ourselves. Reject `about:*` (e.g. about:srcdoc) and every other - // scheme so a paste-injected anchor can't navigate the editor. - const url = request.url; - return !url || url === 'about:blank' || url.startsWith('data:'); - }} + onShouldStartLoadWithRequest={(request) => isEditorNavigationAllowed(request.url)} /> ); diff --git a/src/lib/__tests__/editor-html.test.ts b/src/lib/__tests__/editor-html.test.ts index a5f192b6..ab75c1aa 100644 --- a/src/lib/__tests__/editor-html.test.ts +++ b/src/lib/__tests__/editor-html.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, shouldBlockEditorRemoteImages, + buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, isEditorNavigationAllowed, shouldBlockEditorRemoteImages, } from '../editor-html'; import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens'; @@ -28,18 +28,43 @@ describe('buildEditorHtml', () => { it('emits no cooked-away backslashes in the script', () => { // The page script is written escape-free on purpose; a backslash-free // source cannot silently lose escapes to template-literal cooking. - const script = extractScript(buildEditorHtml(base)); + // (The embedded content writes `<` as an escape, so it is left out here.) + const script = extractScript(buildEditorHtml({ ...base, initialHtml: 'x' })); expect(script).not.toContain('\\'); }); - it('embeds initial content and placeholder as JSON strings', () => { + it('embeds initial content as a JSON string and the placeholder as an attribute', () => { const html = buildEditorHtml({ ...base, initialHtml: '

He said "hi" & left

', placeholder: 'Say "something"…', }); - expect(html).toContain(JSON.stringify('

He said "hi" & left

')); - expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`); + expect(html).toContain(JSON.stringify('

He said "hi" & left

').replace(/ carried ``: the page script + // ended there, the rest of the reply ran as markup and a refresh meta took + // the editor to a data:text/html page. + it('keeps a quoted { + const quoted = '

hello

-
+