From 55a408e810be9d3c384481666f64598568713db7 Mon Sep 17 00:00:00 2001 From: Timo Streule <48442147+tstreule@users.noreply.github.com> Date: Fri, 15 May 2026 14:05:17 +0200 Subject: [PATCH] feat: allow img in HTML identity signatures - Restricts src to https: URLs or base64-embedded raster data: URIs (png/jpeg/gif/webp). - SVG is excluded for safety reasons. - Images with a disallowed src are removed entirely so they don't render as broken-image icons. --- lib/__tests__/email-sanitization.test.ts | 73 +++++++++++++++++++++--- lib/email-sanitization.ts | 27 +++++++-- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts index f36c5b50..1f9c2cb2 100644 --- a/lib/__tests__/email-sanitization.test.ts +++ b/lib/__tests__/email-sanitization.test.ts @@ -78,11 +78,67 @@ describe('email-sanitization', () => { expect(clean).toContain('John Doe'); }); - it('should remove images from signatures', () => { - const signature = '

John

Logo'; + it('should allow img with https src', () => { + const signature = '

John

Logo'; const clean = sanitizeSignatureHtml(signature); + expect(clean).toContain(' { + const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg=='; + const signature = `Logo`; + const clean = sanitizeSignatureHtml(signature); + expect(clean).toContain(' { + const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA']; + for (const src of cases) { + const clean = sanitizeSignatureHtml(`x`); + expect(clean).toContain(' { + const signature = 'Logo'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('http://insecure.example.com'); expect(clean).not.toContain(' { + const signature = 'x'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('javascript:'); + expect(clean).not.toContain(' { + const signature = 'x'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('data:image/svg'); + expect(clean).not.toContain(' { + const signature = 'x'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('data:text/html'); + expect(clean).not.toContain(' { + const signature = 'x'; + const clean = sanitizeSignatureHtml(signature); + expect(clean).not.toContain('onerror'); + expect(clean).not.toContain('onload'); + expect(clean).toContain('https://cdn.example.com/logo.png'); }); it('should remove video and audio tags', () => { @@ -113,16 +169,17 @@ describe('email-sanitization', () => { }); it('should be stricter than email sanitization', () => { - const html = '

Text

Data
'; + const html = '

Text

Data
'; const emailClean = sanitizeEmailHtml(html); const signatureClean = sanitizeSignatureHtml(html); - // Email allows img and table - expect(emailClean).toContain(''); - // Signature blocks img but may allow some tables (verify in implementation) - expect(signatureClean).not.toContain(' for company logos */ export const SIGNATURE_SANITIZE_CONFIG = { - ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'], - ALLOWED_ATTR: ['href', 'style', 'class'], + ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img'], + ALLOWED_ATTR: ['href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title'], ALLOW_DATA_ATTR: false, - FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'], + FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'video', 'audio'], FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'], }; /** - * Sanitize HTML signature for storage and display + * Sanitize HTML signature for storage and display. + * img src is restricted to https: or base64-embedded raster data: URIs + * (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect + * bytes inside a data: URI. Images with a disallowed src are removed + * entirely so they don't render as broken-image icons. * @param html - User-provided HTML signature * @returns Sanitized signature (no scripts, no external resources) */ export function sanitizeSignatureHtml(html: string): string { if (!html?.trim()) return ''; - return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG); + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName !== 'IMG') return; + const src = node.getAttribute('src'); + if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) { + node.remove(); + } + }); + try { + return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG); + } finally { + DOMPurify.removeAllHooks(); + } } /**