"""DOM-XSS regressions for email plain-text linkification helpers.""" import json import os 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 @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_linkify_preserves_text_and_link_targets_in_a_real_dom(): cases = [ ("See https://example.test/contact?email=jane@example.test&next=1 and bob@example.test.", ["https://example.test/contact?email=jane@example.test&next=1", "mailto:bob@example.test"]), ("https://alice@example.test/path", ["https://alice@example.test/path"]), ("www.example.test/?email=jane@example.test", ["https://www.example.test/?email=jane@example.test"]), (" and \"jane@example.test\"", ["https://example.test/path", "mailto:jane@example.test"]), ("(https://example.test/path), jane@example.test!", ["https://example.test/path", "mailto:jane@example.test"]), ("https://example.test/?literal="&next=1", ["https://example.test/?literal="&next=1"]), (' jane@example.test', ["mailto:jane@example.test"]), ("No links\njust & text", []), ] script = r''' import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN; await page.goto(`${origin}/static/js/documentStats.js`); const results = await page.evaluate(async (cases) => { const { _escLinkify } = await import('/static/js/emailLibrary/utils.js'); return cases.map(([raw]) => { const div = document.createElement('div'); div.innerHTML = _escLinkify(raw); return { text: div.textContent, hrefs: [...div.querySelectorAll('a')].map(a => a.getAttribute('href')), otherElements: [...div.querySelectorAll('*')].map(e => e.tagName).filter(t => t !== 'A'), unsafeBlankLink: [...div.querySelectorAll('a[target="_blank"]')] .some(a => !a.relList.contains('noopener') || !a.relList.contains('noreferrer')), }; }); }, JSON.parse(process.env.ODYSSEUS_LINKIFY_CASES)); console.log(JSON.stringify(results)); } finally { await browser.close(); } ''' result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=_REPO, capture_output=True, text=True, timeout=30, env={**os.environ, "ODYSSEUS_LINKIFY_CASES": json.dumps(cases)}, ) assert result.returncode == 0, result.stderr results = json.loads(result.stdout) for (raw, hrefs), actual in zip(cases, results, strict=True): assert actual == {"text": raw, "hrefs": hrefs, "otherElements": [], "unsafeBlankLink": False}, raw