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 { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
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 { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -137,7 +137,7 @@ function buildEmbeddedSignatureHtml(
|
|||||||
: `<p data-signature-block="start"></p>`;
|
: `<p data-signature-block="start"></p>`;
|
||||||
const endMarker = `<p data-signature-block="end"></p>`;
|
const endMarker = `<p data-signature-block="end"></p>`;
|
||||||
if (identity?.htmlSignature) {
|
if (identity?.htmlSignature) {
|
||||||
return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
|
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
|
||||||
}
|
}
|
||||||
if (identity?.textSignature) {
|
if (identity?.textSignature) {
|
||||||
const escaped = identity.textSignature
|
const escaped = identity.textSignature
|
||||||
@@ -471,7 +471,7 @@ export function EmailComposer({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||||
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
|
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||||
: signatureIdentity?.textSignature
|
: signatureIdentity?.textSignature
|
||||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||||
: '';
|
: '';
|
||||||
@@ -1128,7 +1128,7 @@ export function EmailComposer({
|
|||||||
if (signatureAlreadyInBody) return '';
|
if (signatureAlreadyInBody) return '';
|
||||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||||
if (signatureIdentity?.htmlSignature) {
|
if (signatureIdentity?.htmlSignature) {
|
||||||
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
|
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
|
||||||
}
|
}
|
||||||
if (signatureIdentity?.textSignature) {
|
if (signatureIdentity?.textSignature) {
|
||||||
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
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 { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
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 { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -5178,7 +5178,7 @@ export function EmailViewer({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="email-content-text text-foreground"
|
className="email-content-text text-foreground"
|
||||||
dangerouslySetInnerHTML={{ __html: effectiveEmailContent.html }}
|
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
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 { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
@@ -598,7 +598,7 @@ function EmailCard({
|
|||||||
"[&_img]:max-w-full [&_img]:h-auto"
|
"[&_img]:max-w-full [&_img]:h-auto"
|
||||||
)}
|
)}
|
||||||
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
|
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
|
||||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type Ap
|
|||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
|
||||||
|
|
||||||
function PasswordChangeSection() {
|
function PasswordChangeSection() {
|
||||||
const t = useTranslations('settings.security');
|
const t = useTranslations('settings.security');
|
||||||
@@ -671,7 +672,7 @@ export function AccountSecuritySettings() {
|
|||||||
if (isStalwart === false) {
|
if (isStalwart === false) {
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<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>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ export const EMAIL_SANITIZE_CONFIG = {
|
|||||||
ALLOW_DATA_ATTR: false,
|
ALLOW_DATA_ATTR: false,
|
||||||
FORCE_BODY: true,
|
FORCE_BODY: true,
|
||||||
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
|
// 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
|
// 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: [
|
FORBID_TAGS: [
|
||||||
'script', 'iframe', 'object', 'embed', 'form',
|
'script', 'iframe', 'object', 'embed', 'form',
|
||||||
'input', 'button', 'meta', 'link', 'base',
|
'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
|
* Safe HTML parsing without execution
|
||||||
* Use instead of innerHTML for detection/parsing
|
* Use instead of innerHTML for detection/parsing
|
||||||
|
|||||||
Reference in New Issue
Block a user