fix: sandbox thread email HTML in srcDoc iframe with CSP meta
This commit is contained in:
@@ -2776,8 +2776,16 @@ export function EmailViewer({
|
|||||||
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
|
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
|
// Defense-in-depth CSP inside srcDoc: even if the sanitizer ever lets a
|
||||||
|
// <script> tag through, the iframe document forbids script execution
|
||||||
|
// (default-src 'none'). img/style/font remain permissive to match what the
|
||||||
|
// sanitizer is allowed to emit and what the host already permits when
|
||||||
|
// external content is loaded.
|
||||||
|
const iframeCsp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||||
img { max-width: 100% !important; height: auto !important; }
|
img { max-width: 100% !important; height: auto !important; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } 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 } from "@/lib/email-sanitization";
|
||||||
@@ -440,6 +440,49 @@ function EmailCard({
|
|||||||
return { html: "", isHtml: false };
|
return { html: "", isHtml: false };
|
||||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
||||||
|
|
||||||
|
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
|
||||||
|
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
|
||||||
|
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
|
||||||
|
// emits a <script> tag through a parser quirk.
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const emailIframeSrcDoc = useMemo(() => {
|
||||||
|
if (!emailContent.isHtml || !emailContent.html) return '';
|
||||||
|
const csp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||||
|
return `<!DOCTYPE html><html><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||||
|
img { max-width: 100% !important; height: auto !important; }
|
||||||
|
a { color: #1a73e8; }
|
||||||
|
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||||
|
td, th { word-break: break-word; padding: 0.5rem; }
|
||||||
|
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||||
|
</style></head><body>${emailContent.html}</body></html>`;
|
||||||
|
}, [emailContent.isHtml, emailContent.html]);
|
||||||
|
|
||||||
|
const handleIframeLoad = useCallback(() => {
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
if (!iframe) return;
|
||||||
|
try {
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc?.body) return;
|
||||||
|
const resize = () => {
|
||||||
|
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(doc.body);
|
||||||
|
doc.querySelectorAll('a').forEach((a) => {
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
||||||
@@ -534,18 +577,30 @@ function EmailCard({
|
|||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<div style={{ padding: 'var(--density-card-p)' }}>
|
<div style={{ padding: 'var(--density-card-p)' }}>
|
||||||
<div
|
{emailContent.isHtml ? (
|
||||||
className={cn(
|
<iframe
|
||||||
"prose prose-sm max-w-none",
|
ref={iframeRef}
|
||||||
!emailAlwaysLightMode && "dark:prose-invert",
|
srcDoc={emailIframeSrcDoc}
|
||||||
"prose-p:my-2 prose-headings:my-3",
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
title="Email content"
|
||||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
className="w-full border-0 block"
|
||||||
"[&_img]:max-w-full [&_img]:h-auto"
|
style={{ minHeight: '60px' }}
|
||||||
)}
|
onLoad={handleIframeLoad}
|
||||||
style={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined}
|
/>
|
||||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
) : (
|
||||||
/>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"prose prose-sm max-w-none",
|
||||||
|
!emailAlwaysLightMode && "dark:prose-invert",
|
||||||
|
"prose-p:my-2 prose-headings:my-3",
|
||||||
|
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||||
|
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||||
|
"[&_img]:max-w-full [&_img]:h-auto"
|
||||||
|
)}
|
||||||
|
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
|
|||||||
Reference in New Issue
Block a user