security(webui): block remote mail preview content
This commit is contained in:
@@ -23,6 +23,32 @@ export type MessageDisplayAttachment = {
|
|||||||
|
|
||||||
type MessageDisplayBodyMode = "text" | "html";
|
type MessageDisplayBodyMode = "text" | "html";
|
||||||
|
|
||||||
|
const MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY = [
|
||||||
|
"default-src 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"font-src 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"img-src data: cid:",
|
||||||
|
"manifest-src 'none'",
|
||||||
|
"media-src 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"prefetch-src 'none'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'unsafe-inline'",
|
||||||
|
"worker-src 'none'"
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
const URL_ATTRIBUTE_PATTERN = /(\s)(src|href|xlink:href|poster|background|action|formaction|cite|longdesc|ping|data)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
|
||||||
|
const SOURCE_SET_ATTRIBUTE_PATTERN = /\s(?:srcset|imagesrcset)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
||||||
|
const EVENT_HANDLER_ATTRIBUTE_PATTERN = /\son[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
||||||
|
const CSS_IMPORT_PATTERN = /@import\s+(?:url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)|"[^"]*"|'[^']*')[^;]*;?/gi;
|
||||||
|
const CSS_URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
|
||||||
|
const UNSAFE_DOCUMENT_ELEMENT_PATTERN = /<\s*(?:base|link|meta)\b[^>]*>/gi;
|
||||||
|
const SCRIPT_BLOCK_PATTERN = /<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi;
|
||||||
|
const SCRIPT_TAG_PATTERN = /<\s*\/?\s*script\b[^>]*>/gi;
|
||||||
|
|
||||||
type MessageDisplayPanelProps = {
|
type MessageDisplayPanelProps = {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
fields?: MessageDisplayField[];
|
fields?: MessageDisplayField[];
|
||||||
@@ -71,6 +97,7 @@ export default function MessageDisplayPanel({
|
|||||||
const showBodySwitch = hasHtml && hasText;
|
const showBodySwitch = hasHtml && hasText;
|
||||||
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
||||||
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
||||||
|
const safeHtmlDocument = buildSafeMessageHtmlDocument(bodyHtml || htmlBodyFallback);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-display-panel">
|
<div className="message-display-panel">
|
||||||
@@ -104,7 +131,12 @@ export default function MessageDisplayPanel({
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{activeBodyMode === "html" ?
|
{activeBodyMode === "html" ?
|
||||||
<iframe className="message-display-html-frame" title="i18n:govoplan-core.rendered_html_message_body.8638b634" sandbox="" srcDoc={bodyHtml || htmlBodyFallback} /> :
|
<iframe
|
||||||
|
className="message-display-html-frame"
|
||||||
|
title="i18n:govoplan-core.rendered_html_message_body.8638b634"
|
||||||
|
sandbox=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
srcDoc={safeHtmlDocument} /> :
|
||||||
|
|
||||||
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
||||||
}
|
}
|
||||||
@@ -278,3 +310,67 @@ function formatBytes(value?: number | null): string {
|
|||||||
function stripHtml(value: string): string {
|
function stripHtml(value: string): string {
|
||||||
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the isolated document used for untrusted email HTML previews.
|
||||||
|
*
|
||||||
|
* Attribute neutralization keeps common clients from even attempting remote
|
||||||
|
* resource resolution. The document-local CSP is the authoritative backstop
|
||||||
|
* for malformed markup and parser edge cases: no network-capable resource is
|
||||||
|
* permitted, while inline presentation plus embedded CID/raster data images
|
||||||
|
* remain usable inside the already scriptless sandbox.
|
||||||
|
*/
|
||||||
|
export function buildSafeMessageHtmlDocument(value: string): string {
|
||||||
|
const neutralized = neutralizeRemoteMessageContent(value);
|
||||||
|
return [
|
||||||
|
"<!doctype html>",
|
||||||
|
"<html>",
|
||||||
|
"<head>",
|
||||||
|
'<meta charset="utf-8">',
|
||||||
|
`<meta http-equiv="Content-Security-Policy" content="${MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY}">`,
|
||||||
|
'<meta name="referrer" content="no-referrer">',
|
||||||
|
"</head>",
|
||||||
|
`<body>${neutralized}</body>`,
|
||||||
|
"</html>"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function neutralizeRemoteMessageContent(value: string): string {
|
||||||
|
return value.
|
||||||
|
replace(SCRIPT_BLOCK_PATTERN, "").
|
||||||
|
replace(SCRIPT_TAG_PATTERN, "").
|
||||||
|
replace(UNSAFE_DOCUMENT_ELEMENT_PATTERN, "").
|
||||||
|
replace(EVENT_HANDLER_ATTRIBUTE_PATTERN, "").
|
||||||
|
replace(SOURCE_SET_ATTRIBUTE_PATTERN, "").
|
||||||
|
replace(URL_ATTRIBUTE_PATTERN, (_match, spacing: string, attributeName: string, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
|
||||||
|
const url = doubleQuoted ?? singleQuoted ?? unquoted ?? "";
|
||||||
|
if (!messagePreviewUrlAllowed(attributeName, url)) return "";
|
||||||
|
return `${spacing}${attributeName.toLowerCase()}="${escapeHtmlAttribute(url.trim())}"`;
|
||||||
|
}).
|
||||||
|
replace(CSS_IMPORT_PATTERN, "").
|
||||||
|
replace(CSS_URL_PATTERN, (_match, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
|
||||||
|
const url = (doubleQuoted ?? singleQuoted ?? unquoted ?? "").trim();
|
||||||
|
return isSafeEmbeddedMessageResource(url) ? `url("${escapeCssString(url)}")` : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagePreviewUrlAllowed(attributeName: string, value: string): boolean {
|
||||||
|
const attribute = attributeName.toLowerCase();
|
||||||
|
if (["action", "formaction", "ping"].includes(attribute)) return false;
|
||||||
|
const url = value.trim();
|
||||||
|
if ((attribute === "href" || attribute === "xlink:href") && /^#[^\s]*$/.test(url)) return true;
|
||||||
|
return isSafeEmbeddedMessageResource(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeEmbeddedMessageResource(value: string): boolean {
|
||||||
|
if (/^cid:[^\s"'<>]+$/i.test(value)) return true;
|
||||||
|
return /^data:image\/(?:png|gif|jpe?g|webp);base64,[a-z0-9+/=\s]+$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCssString(value: string): string {
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n\f]/g, "");
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function assertDeepEqual(actual: unknown, expected: unknown, message = "values s
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import CredentialPanel from "../src/components/CredentialPanel";
|
import CredentialPanel from "../src/components/CredentialPanel";
|
||||||
import PasswordField from "../src/components/PasswordField";
|
import PasswordField from "../src/components/PasswordField";
|
||||||
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
import MessageDisplayPanel, { buildSafeMessageHtmlDocument } from "../src/components/MessageDisplayPanel";
|
||||||
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
||||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
@@ -245,3 +245,44 @@ assert(messageDisplay.includes("agenda.pdf"), "direct attachment is rendered");
|
|||||||
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
||||||
assert(messageDisplay.includes("i18n:govoplan-core.password_protected.09d9e174"), "ZIP protection badge is rendered");
|
assert(messageDisplay.includes("i18n:govoplan-core.password_protected.09d9e174"), "ZIP protection badge is rendered");
|
||||||
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
||||||
|
|
||||||
|
const safeMessageDocument = buildSafeMessageHtmlDocument(`
|
||||||
|
<base href="https://tracking.example/">
|
||||||
|
<meta http-equiv="refresh" content="0;url=https://tracking.example/refresh">
|
||||||
|
<link rel="stylesheet" href="https://tracking.example/mail.css">
|
||||||
|
<style>
|
||||||
|
@import "https://tracking.example/import.css";
|
||||||
|
.remote { background-image: url(https://tracking.example/background.png); }
|
||||||
|
.embedded { background-image: url('cid:background@example.org'); }
|
||||||
|
</style>
|
||||||
|
<a href="https://tracking.example/click" ping="https://tracking.example/ping">Remote link</a>
|
||||||
|
<a href="#local-section">Local link</a>
|
||||||
|
<img src="https://tracking.example/pixel.gif" srcset="https://tracking.example/retina.gif 2x" onerror="fetch('https://tracking.example/error')">
|
||||||
|
<img src="cid:logo@example.org">
|
||||||
|
<img src="data:image/png;base64,iVBORw0KGgo=">
|
||||||
|
<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">
|
||||||
|
<script>fetch("https://tracking.example/script")</script>
|
||||||
|
`);
|
||||||
|
assert(safeMessageDocument.startsWith("<!doctype html><html><head>"), "message HTML is wrapped in an isolated document");
|
||||||
|
assert(safeMessageDocument.includes("default-src 'none'"), "message iframe CSP denies resources by default");
|
||||||
|
assert(safeMessageDocument.includes("img-src data: cid:"), "message iframe CSP permits only embedded image sources");
|
||||||
|
assert(safeMessageDocument.includes("connect-src 'none'"), "message iframe CSP blocks active network connections");
|
||||||
|
assert(safeMessageDocument.includes("form-action 'none'"), "message iframe CSP blocks form submissions");
|
||||||
|
assert(safeMessageDocument.includes('src="cid:logo@example.org"'), "CID image references remain available");
|
||||||
|
assert(safeMessageDocument.includes('src="data:image/png;base64,iVBORw0KGgo="'), "conservative raster data images remain available");
|
||||||
|
assert(safeMessageDocument.includes('href="#local-section"'), "same-document fragment links remain available");
|
||||||
|
assert(!safeMessageDocument.includes("tracking.example"), "remote message URLs are neutralized before preview");
|
||||||
|
assert(!safeMessageDocument.includes("data:image/svg+xml"), "active SVG data resources are rejected");
|
||||||
|
assert(!safeMessageDocument.includes("onerror="), "event-handler attributes are removed");
|
||||||
|
assert(!safeMessageDocument.includes("<script"), "script elements are removed");
|
||||||
|
|
||||||
|
const privateMessageDisplay = renderToStaticMarkup(
|
||||||
|
<MessageDisplayPanel
|
||||||
|
title="Untrusted message"
|
||||||
|
bodyHtml='<img src="https://tracking.example/pixel.gif"><img src="cid:logo@example.org">'
|
||||||
|
preferredBodyMode="html"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert(privateMessageDisplay.includes('sandbox=""'), "message HTML remains inside a scriptless iframe sandbox");
|
||||||
|
assert(privateMessageDisplay.includes('referrerPolicy="no-referrer"'), "message iframe suppresses referrer data");
|
||||||
|
assert(!privateMessageDisplay.includes("tracking.example"), "rendered iframe source contains no remote tracking URL");
|
||||||
|
|||||||
Reference in New Issue
Block a user