diff --git a/static/js/emailLibrary/utils.js b/static/js/emailLibrary/utils.js index e4dc898..82a5c86 100644 --- a/static/js/emailLibrary/utils.js +++ b/static/js/emailLibrary/utils.js @@ -30,6 +30,28 @@ export function _esc(text) { return div.innerHTML; } +function _attrEsc(text) { + return String(text ?? '') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') + .replace(/`/g, '`'); +} + +function _compactUrlSchemeValue(value) { + return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase(); +} + +function _isDangerousUrl(value) { + const compact = _compactUrlSchemeValue(value); + return compact.startsWith('javascript:') || compact.startsWith('vbscript:') || compact.startsWith('data:'); +} + +function _isDangerousSrcset(value) { + return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate)); +} + // Escape + linkify URLs and email addresses. Returns innerHTML-safe markup. export function _escLinkify(text) { const escaped = _esc(text); @@ -39,9 +61,9 @@ export function _escLinkify(text) { return escaped .replace(urlRe, (m) => { const href = m.startsWith('www.') ? `https://${m}` : m; - return `${m}`; + return `${m}`; }) - .replace(mailRe, (m) => `${m}`); + .replace(mailRe, (m) => `${m}`); } // Pull display name out of "Name "; fallback to local-part of @@ -133,19 +155,14 @@ export function _initials(s) { // `data:` URLs on every known URL attribute, scrubs inline colour/font/ // position styles so the theme can take over, and wraps highlight-bearing // inline tags in so they render legibly across themes. -export function _sanitizeHtml(html) { +function _sanitizeHtmlOnce(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll( 'script, iframe, object, embed, form, style, link, ' + 'svg, math, base, meta, noscript, frame, frameset, applet, portal' ).forEach(el => el.remove()); - const URL_ATTRS = ['href', 'src', 'srcset', 'action', 'formaction', 'background', 'poster', 'data']; - const isDangerousUrl = (val) => { - if (!val) return false; - const v = val.trim().toLowerCase(); - return v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:'); - }; + const URL_ATTRS = ['href', 'src', 'xlink:href', 'srcset', 'action', 'formaction', 'background', 'poster', 'data']; const STRIP_CSS_PROPS = ['color', 'background', 'background-color', 'font-family', 'font', '-webkit-text-fill-color', @@ -160,7 +177,7 @@ export function _sanitizeHtml(html) { const name = attr.name.toLowerCase(); if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; } if (name === 'srcdoc') { el.removeAttribute(attr.name); continue; } - if (URL_ATTRS.includes(name) && isDangerousUrl(attr.value)) { + if (URL_ATTRS.includes(name) && (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value))) { el.removeAttribute(attr.name); continue; } @@ -177,8 +194,8 @@ export function _sanitizeHtml(html) { if (style) { const kept = style.split(';').map(s => s.trim()).filter(decl => { if (!decl) return false; - const lower = decl.toLowerCase(); - if (lower.includes('javascript:') || lower.includes('expression(')) return false; + const lower = _compactUrlSchemeValue(decl); + if (lower.includes('javascript:') || lower.includes('vbscript:') || lower.includes('data:') || lower.includes('expression(')) return false; const prop = decl.split(':', 1)[0].trim().toLowerCase(); return !STRIP_CSS_PROPS.includes(prop); }); @@ -200,3 +217,13 @@ export function _sanitizeHtml(html) { return doc.body.innerHTML; } + +export function _sanitizeHtml(html) { + let out = String(html ?? ''); + for (let i = 0; i < 4; i++) { + const next = _sanitizeHtmlOnce(out); + if (next === out) break; + out = next; + } + return out; +} diff --git a/tests/test_email_linkify_security_js.py b/tests/test_email_linkify_security_js.py new file mode 100644 index 0000000..fc667be --- /dev/null +++ b/tests/test_email_linkify_security_js.py @@ -0,0 +1,102 @@ +"""DOM-XSS regressions for email plain-text linkification helpers.""" + +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_HELPER = _REPO / "static" / "js" / "emailLibrary" / "utils.js" +_HAS_NODE = shutil.which("node") is not None + + +def _run(js: str) -> str: + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, + capture_output=True, + text=True, + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout.strip() + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_plain_text_linkify_escapes_href_attribute_without_double_escaping(): + js = textwrap.dedent( + f""" + globalThis.document = {{ + createElement() {{ + return {{ + set textContent(v) {{ + this._t = String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }}, + get innerHTML() {{ return this._t || ''; }} + }}; + }} + }}; + const {{ _escLinkify }} = await import('{_HELPER.as_posix()}'); + const out = _escLinkify('See https://example.test/path?a=1&b=2 and www.example.test/a`b'); + console.log(JSON.stringify(out)); + """ + ) + + html = json.loads(_run(js)) + + assert 'href="https://example.test/path?a=1&b=2"' in html + assert "amp;amp" not in html + assert 'href="https://www.example.test/a`b"' in html + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_email_url_scheme_checks_strip_embedded_controls(): + js = textwrap.dedent( + f""" + import fs from 'node:fs'; + + let source = fs.readFileSync('{_HELPER.as_posix()}', 'utf8'); + source = source + .replace('function _compactUrlSchemeValue', 'export function _compactUrlSchemeValue') + .replace('function _isDangerousUrl', 'export function _isDangerousUrl') + .replace('function _isDangerousSrcset', 'export function _isDangerousSrcset'); + + const mod = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64')); + const checks = {{ + compact: mod._compactUrlSchemeValue('java\\n script:\\talert(1)'), + jsUrl: mod._isDangerousUrl('java\\n script:\\talert(1)'), + vbUrl: mod._isDangerousUrl('vb\\rscript:msgbox(1)'), + dataUrl: mod._isDangerousUrl(' data:text/html,'), + httpUrl: mod._isDangerousUrl('https://example.test/?q=javascript:alert(1)'), + srcset: mod._isDangerousSrcset('https://safe.test/a.png 1x, java\\nscript:alert(1) 2x'), + }}; + console.log(JSON.stringify(checks)); + """ + ) + + checks = json.loads(_run(js)) + + assert checks["compact"] == "javascript:alert(1)" + assert checks["jsUrl"] is True + assert checks["vbUrl"] is True + assert checks["dataUrl"] is True + assert checks["httpUrl"] is False + assert checks["srcset"] is True + + +def test_email_html_sanitizer_runs_to_fixpoint(): + source = _HELPER.read_text(encoding="utf-8") + + assert "function _sanitizeHtmlOnce(html)" in source + assert "for (let i = 0; i < 4; i++)" in source + assert "const next = _sanitizeHtmlOnce(out);" in source + assert "if (next === out) break;" in source