fix: keep a hostile inline image type out of the reader and the composer

This commit is contained in:
Linus Rath
2026-10-29 04:22:05 +01:00
parent 7dc9ee24db
commit 07679cbf8c
4 changed files with 82 additions and 6 deletions
+10
View File
@@ -186,4 +186,14 @@ describe('cid image hydration', () => {
expect(hydrated).toContain('data-cid="pic@x"'); expect(hydrated).toContain('data-cid="pic@x"');
expect(rewriteInlineImages(hydrated).html).toContain('src="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('<p><img src="cid:pic@x"></p>');
const hostile = 'data:image/x"><meta http-equiv=refresh content=0,url=https://evil.example/>;base64,QUJD';
const hydrated = replaceInlineImagePlaceholders(html, new Map([['pic@x', hostile]]));
expect(hydrated).toBe(html);
expect(hydrated).not.toContain('<meta');
});
}); });
+34
View File
@@ -240,6 +240,34 @@ describe('prepareEmailHtml', () => {
expect(res.html).toContain(`src="${TRANSPARENT_BLOCKED_PIXEL}"`); 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"><meta http-equiv=refresh content=0,url=https://evil.example/>';
const bytes = new TextEncoder().encode('ABC');
const uri = `data:${sniffImageMime(bytes, type)};base64,QUJD`;
for (const blockRemoteImages of [true, false]) {
const { html } = prepareEmailHtml('<p>hi</p><img src="cid:p1">', { blockRemoteImages, cidMap: { p1: uri }, isDark: false });
expect(html).not.toMatch(/<meta[^>]*refresh/i);
expect(html).not.toContain('evil.example');
expect(html).toContain(`<img src="${TRANSPARENT_BLOCKED_PIXEL}">`);
}
// A map value that is not an image type plus base64 is not used either.
const { html } = prepareEmailHtml('<img src="cid:p1">', { cidMap: { p1: 'data:image/png;base64,AA"><b>x</b>' } });
expect(html).not.toContain('<b>x</b>');
});
it('swaps cid references in src only, not in text or other attributes', () => {
const { html } = prepareEmailHtml(
'<img src="cid:abc" alt="cid:abc"><p>see cid:abc</p>',
{ cidMap: { abc: 'data:image/png;base64,AAA' } },
);
expect(html).toContain('<img src="data:image/png;base64,AAA" alt="cid:abc">');
expect(html).toContain('<p>see cid:abc</p>');
});
it('tightens Word/Outlook HTML and drops the gutter for full-bleed canvases in auto mode', () => { it('tightens Word/Outlook HTML and drops the gutter for full-bleed canvases in auto mode', () => {
const word = prepareEmailHtml('<div class="WordSection1"><p class="MsoNormal">x</p></div>'); const word = prepareEmailHtml('<div class="WordSection1"><p class="MsoNormal">x</p></div>');
expect(word.html).toContain('p.MsoNormal'); 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]), 'image/tiff')).toBe('image/tiff');
expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'application/octet-stream')).toBe('application/octet-stream'); 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"><meta http-equiv=refresh content=0,url=https://evil.example/>'))
.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', () => { describe('reading width on a wide pane', () => {
+5 -2
View File
@@ -2,7 +2,7 @@
// what the webmail composer (`components/email/email-composer.tsx`) produces. // what the webmail composer (`components/email/email-composer.tsx`) produces.
// All values are HTML-escaped before insertion into the document. // 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 { buildQuoteHeader, type QuoteHeaderLabels } from './quote-header';
import type { TimeFormat } from '../stores/settings-store'; 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) => { (full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => {
const cid = dq ?? sq ?? ''; const cid = dq ?? sq ?? '';
const dataUrl = cidToDataUrl.get(cid); 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(); const rest = (before + after).replace(/\ssrc=("[^"]*"|'[^']*')/gi, '').trim();
return `<img src="${dataUrl}" data-cid="${cid.replace(/"/g, '&quot;')}"${rest ? ' ' + rest : ''}>`; return `<img src="${dataUrl}" data-cid="${cid.replace(/"/g, '&quot;')}"${rest ? ' ' + rest : ''}>`;
}, },
+33 -4
View File
@@ -752,10 +752,37 @@ export function extractCidRefs(html: string): string[] {
return Array.from(out); 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, string>): string { function replaceCidRefs(html: string, cidMap: Record<string, string>): string {
return html.replace(/\bcid:([^"'\s)>]+)/gi, (_m, ref: string) => { return mapTags(html, (_tag, attrs) => {
const key = ref.replace(/^<|>$/g, ''); let changed = false;
return cidMap[key] ?? TRANSPARENT_BLOCKED_PIXEL; 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 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
) return 'image/webp'; ) return 'image/webp';
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp'; 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/<subtype>` is used.
const f = (fallback || '').toLowerCase().split(';')[0].trim(); 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'; return 'application/octet-stream';
} }