fix(security): prevent global CSS injection from email style tags

Emails containing <style> tags were injecting global CSS rules that
affected the entire application UI. For example, button:hover rules
from emails would override all button hover states app-wide.

Changes:
- Block <style> tags in email sanitization config (security fix)
- Keep inline style attributes for element-specific formatting
- Add explicit bg-transparent to banner buttons (defense in depth)
- Add CSS isolation to banner container
- Fix theme dependency in email content memoization

This prevents malicious or poorly-formatted emails from breaking the
UI or being used for phishing via CSS injection attacks.
This commit is contained in:
Matthieu MALVACHE
2026-01-08 19:18:59 +01:00
parent 1bca54e5d6
commit e600cd387d
3 changed files with 13 additions and 11 deletions
+5 -5
View File
@@ -524,7 +524,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, theme]);
// Detect List-Unsubscribe header for newsletter banners
const listHeaders = useMemo(() => {
@@ -1290,9 +1290,9 @@ export function EmailViewer({
{/* Unified Notification Banner - External Content + Unsubscribe */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && (
<div className="border-b border-border bg-muted/30">
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5">
<div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3">
<div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-center gap-3 flex-wrap">
@@ -1300,7 +1300,7 @@ export function EmailViewer({
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors min-h-[44px] md:min-h-0"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
@@ -1316,7 +1316,7 @@ export function EmailViewer({
setAllowExternalContent(true);
}
}}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors min-h-[44px] md:min-h-0"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('trust_sender')}
</button>
+4 -4
View File
@@ -92,7 +92,7 @@ export function UnsubscribeBanner({
</span>
<button
onClick={onDismiss}
className="text-sm text-muted-foreground hover:text-foreground transition-colors min-h-[44px] md:min-h-0"
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.dismiss')}
</button>
@@ -110,14 +110,14 @@ export function UnsubscribeBanner({
<button
onClick={handleUnsubscribe}
disabled={processing}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] md:min-h-0"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] md:min-h-0"
>
{processing && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{t('email_viewer.unsubscribe_banner.confirm_button')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors min-h-[44px] md:min-h-0"
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.cancel')}
</button>
@@ -125,7 +125,7 @@ export function UnsubscribeBanner({
) : (
<button
onClick={() => setShowConfirm(true)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors min-h-[44px] md:min-h-0"
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.button')}
</button>
+4 -2
View File
@@ -3,16 +3,18 @@ import DOMPurify from 'dompurify';
/**
* Unified DOMPurify configuration for email content
* Blocks all script execution vectors while preserving formatting
* NOTE: <style> tags are forbidden to prevent global CSS injection
* Inline style attributes are still allowed for element-specific styling
*/
export const EMAIL_SANITIZE_CONFIG = {
ADD_TAGS: ['style'],
ADD_TAGS: [],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base',
'svg', 'math'
'svg', 'math', 'style'
],
FORBID_ATTR: [
'onerror', 'onload', 'onclick', 'onmouseover',