security(webui): block remote mail preview content

This commit is contained in:
2026-07-21 17:52:02 +02:00
parent 70ca772138
commit fa9119bea7
2 changed files with 139 additions and 2 deletions

View File

@@ -23,6 +23,32 @@ export type MessageDisplayAttachment = {
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 = {
title?: string | null;
fields?: MessageDisplayField[];
@@ -71,6 +97,7 @@ export default function MessageDisplayPanel({
const showBodySwitch = hasHtml && hasText;
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 safeHtmlDocument = buildSafeMessageHtmlDocument(bodyHtml || htmlBodyFallback);
return (
<div className="message-display-panel">
@@ -104,7 +131,12 @@ export default function MessageDisplayPanel({
}
</div>
{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>
}
@@ -278,3 +310,67 @@ function formatBytes(value?: number | null): 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();
}
/**
* 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeCssString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n\f]/g, "");
}