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(
+ '
see cid:abc
',
+ { cidMap: { abc: 'data:image/png;base64,AAA' } },
+ );
+ expect(html).toContain('
');
+ 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('');
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';
}