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] 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'; }