From d8d98caa784f4dee791bdfef8ef338abd582b933 Mon Sep 17 00:00:00 2001 From: Ocean Bennett <204957658+undergroundrap@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:21:18 -0400 Subject: [PATCH] fix(security): sanitize email rich body render path (#5212) --- static/js/document.js | 19 +++++++++++++------ static/js/markdown.js | 3 ++- tests/test_markdown_dom_xss_helpers.py | 13 +++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/static/js/document.js b/static/js/document.js index b245ec852..0aadaa503 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -2378,20 +2378,27 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; } // ── WYSIWYG email body helpers ── + function _emailPlainTextToHtml(text) { + const d = document.createElement('div'); + d.textContent = text == null ? '' : String(text); + return d.innerHTML.replace(/\n/g, '
'); + } + function _emailBodyToHtml(text) { const t = (text || '').trim(); if (!t) return ''; // If it already contains a formatting/structural HTML tag, it's a saved - // WYSIWYG body — use it verbatim. (Checking a leading '<' isn't enough: a + // WYSIWYG body — sanitize it before rendering. (Checking a leading '<' isn't enough: a // rich body often starts with plain text, e.g. "Hi there".) - if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t; + if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) { + return markdownModule.sanitizeAllowedHtml + ? markdownModule.sanitizeAllowedHtml(t) + : _emailPlainTextToHtml(t); + } // Email body: keep author-typed `:shortcode:` text literal. Issue #345 // (shortcode → emoji) is scoped to chat; do not rewrite colons in mail. try { return markdownModule.mdToHtml(text, { shortcodes: false }); } - catch (_) { - const d = document.createElement('div'); d.textContent = text; - return d.innerHTML.replace(/\n/g, '
'); - } + catch (_) { return _emailPlainTextToHtml(text); } } // Mirror the rich body's plain text into the hidden textarea so the existing // send / draft / change-detection plumbing (which reads the textarea) stays diff --git a/static/js/markdown.js b/static/js/markdown.js index f2bb7f85f..a3b27fbc8 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -133,7 +133,7 @@ function _cleanAllowedHtmlOnce(htmlString) { return tpl.innerHTML; } -function sanitizeAllowedHtml(html) { +export function sanitizeAllowedHtml(html) { const raw = String(html == null ? '' : html); // Non-browser context (e.g. a future SSR/Node import): fail closed by // escaping rather than trusting the markup. @@ -824,6 +824,7 @@ export function renderMermaid(container) { const markdownModule = { escapeHtml, mdToHtml, + sanitizeAllowedHtml, squashOutsideCode, renderContent, processWithThinking, diff --git a/tests/test_markdown_dom_xss_helpers.py b/tests/test_markdown_dom_xss_helpers.py index 25b18417d..db9ab9c9b 100644 --- a/tests/test_markdown_dom_xss_helpers.py +++ b/tests/test_markdown_dom_xss_helpers.py @@ -23,3 +23,16 @@ def test_markdown_raw_html_sanitizer_strips_scriptable_css(): assert "if (name === 'style')" in src assert r"javascript:|vbscript:|data:|expression\(" in src assert "el.removeAttribute(attr.name);" in src + + +def test_email_rich_body_render_path_reuses_raw_html_sanitizer(): + markdown_src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8") + document_src = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8") + email_body_helper = document_src.split("function _emailBodyToHtml(text)", 1)[1].split( + " // Mirror the rich body's plain text", 1 + )[0] + + assert "export function sanitizeAllowedHtml(html)" in markdown_src + assert "sanitizeAllowedHtml," in markdown_src + assert "markdownModule.sanitizeAllowedHtml(t)" in email_body_helper + assert "return t;" not in email_body_helper