fix: tighten HTML sanitization at plain-text email + signature + i18n render sites
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user