fix: tighten HTML sanitization at plain-text email + signature + i18n render sites

This commit is contained in:
Linus Rath
2026-05-18 16:07:34 +02:00
parent eb0643d887
commit 3ceada7b8a
5 changed files with 51 additions and 11 deletions
+4 -4
View File
@@ -9,7 +9,7 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
@@ -137,7 +137,7 @@ function buildEmbeddedSignatureHtml(
: `<p data-signature-block="start"></p>`;
const endMarker = `<p data-signature-block="end"></p>`;
if (identity?.htmlSignature) {
return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
}
if (identity?.textSignature) {
const escaped = identity.textSignature
@@ -471,7 +471,7 @@ export function EmailComposer({
]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
@@ -1128,7 +1128,7 @@ export function EmailComposer({
if (signatureAlreadyInBody) return '';
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) {
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
}
if (signatureIdentity?.textSignature) {
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
+2 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -5178,7 +5178,7 @@ export function EmailViewer({
) : (
<div
className="email-content-text text-foreground"
dangerouslySetInnerHTML={{ __html: effectiveEmailContent.html }}
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
style={{
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
fontSize: '14px',
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
@@ -598,7 +598,7 @@ function EmailCard({
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
/>
)}
</div>
@@ -12,6 +12,7 @@ import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type Ap
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
function PasswordChangeSection() {
const t = useTranslations('settings.security');
@@ -671,7 +672,7 @@ export function AccountSecuritySettings() {
if (isStalwart === false) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t('not_available')) }} />
</SettingsSection>
);
}
+41 -2
View File
@@ -12,9 +12,12 @@ export const EMAIL_SANITIZE_CONFIG = {
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
// data: is restricted to image/* MIME types to prevent SVG script injection.
// data: is restricted to a fixed set of raster image types. SVG (image/svg+xml)
// is excluded because DOMPurify cannot inspect bytes inside a data: URI, so an
// SVG payload can carry <script>/<foreignObject> that the surrounding sanitizer
// never sees. The `(?=[;,])` anchor prevents prefix matches like image/png-evil.
// eslint-disable-next-line no-useless-escape
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)(?=[;,])|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base',
@@ -101,6 +104,42 @@ export function sanitizeSignatureHtml(html: string): string {
}
}
/**
* Sanitizer for translation strings that contain inline markup (e.g. a
* documentation link). The translation catalog is trusted today, but using
* dangerouslySetInnerHTML on a translation makes that trust permanent and
* implicit; this allowlist limits the blast radius if a translation ever
* becomes attacker-influenced (community PR, crowdsourced service).
*/
const I18N_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['a', 'b', 'strong', 'i', 'em', 'u', 'span', 'br', 'code'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/|#)/i,
};
export function sanitizeI18nHtml(html: string): string {
return DOMPurify.sanitize(html, I18N_SANITIZE_CONFIG);
}
/**
* Sanitizer for the non-iframe branch of email rendering (plain-text bodies,
* S/MIME plain-text, TNEF text, no-body fallbacks). The producer
* (`plainTextToSafeHtml`) already escapes text and emits only safe <a> tags,
* so this is defense-in-depth: it ensures the render site is safe even if a
* future code path passes raw HTML in by mistake.
*/
const PLAIN_TEXT_RENDERED_CONFIG = {
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|cid:|#)/i,
};
export function sanitizePlainTextRenderedHtml(html: string): string {
return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG);
}
/**
* Safe HTML parsing without execution
* Use instead of innerHTML for detection/parsing