mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: keep a hostile inline image type out of the reader and the composer
This commit is contained in:
@@ -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('<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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,6 +240,34 @@ describe('prepareEmailHtml', () => {
|
||||
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', () => {
|
||||
const word = prepareEmailHtml('<div class="WordSection1"><p class="MsoNormal">x</p></div>');
|
||||
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"><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', () => {
|
||||
|
||||
@@ -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 `<img src="${dataUrl}" data-cid="${cid.replace(/"/g, '"')}"${rest ? ' ' + rest : ''}>`;
|
||||
},
|
||||
|
||||
+33
-4
@@ -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, string>): 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/<subtype>` 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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user