diff --git a/tests/pr6503_security_browser.cjs b/tests/pr6503_security_browser.cjs index d0d276cba..a8b45d689 100644 --- a/tests/pr6503_security_browser.cjs +++ b/tests/pr6503_security_browser.cjs @@ -1,4 +1,4 @@ -const { chromium } = require('playwright'); +const playwright = require('playwright'); const { readFileSync } = require('node:fs'); const { execFileSync } = require('node:child_process'); const assert = require('node:assert/strict'); @@ -11,7 +11,9 @@ function documentFunction(name, text = source) { } (async () => { - const browser = await chromium.launch({ headless: true }); + const engine = process.env.ODYSSEUS_SECURITY_BROWSER || 'chromium'; + assert(['chromium', 'firefox', 'webkit'].includes(engine), engine); + const browser = await playwright[engine].launch({ headless: true }); try { // Opt-in positive controls use an explicit vulnerable revision, so these // regressions also work after the fix is committed or in a shallow checkout. @@ -249,6 +251,23 @@ function documentFunction(name, text = source) { assert.equal(email.executed, 0); assert.deepEqual(probes, []); + // The current payload must execute at an active DOM boundary. This makes + // the non-execution assertions above independent of old git revisions. + const activeEmailControl = await page.evaluate(async () => { + const active = document.createElement('div'); + active.innerHTML = ''; + document.body.appendChild(active); + const deadline = Date.now() + 2000; + while (!window.executed && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 20)); + } + active.remove(); + const executed = window.executed; + window.executed = 0; + return executed; + }); + assert(activeEmailControl > 0); + const gallery = await page.evaluate(async functions => { const API_BASE = ''; const _escHtml = eval('(' + functions._escHtml + ')'); @@ -288,7 +307,10 @@ function documentFunction(name, text = source) { const cleanPaste = eval('(' + functions._cleanRichTextPasteHtml + ')'); const rejected = ['javascript:window.executed++', 'java\tscript:window.executed++', 'data:text/html,', 'vbscript:msgbox(1)', - 'file:///etc/passwd', 'https://example.com/\njavascript:window.executed++']; + 'file:///etc/passwd', 'https://example.com/\njavascript:window.executed++', + 'blob:https://example.com/id', 'about:blank', 'ftp://example.com/path', + 'JaVaScRiPt:window.executed++', 'java\rscript:window.executed++', + 'https://', '//outside.example/path']; const rich = document.createElement('div'); rich.contentEditable = 'true'; // Literal markup in an existing selection must remain text after linking. @@ -346,7 +368,7 @@ function documentFunction(name, text = source) { result.outside = insert(rich, internal); return result; }, functions); - assert.deepEqual(links.rejected, Array(6).fill('')); + assert.deepEqual(links.rejected, Array(13).fill('')); assert.equal(links.inserted, true); assert.equal(links.href, links.internal); assert.equal(links.text, links.literal); @@ -368,8 +390,11 @@ function documentFunction(name, text = source) { assert.equal(links.outside, false); // Exercise the sanitizer itself, then the exact live HTML insertion path. - const markdown = await page.evaluate(async () => { + const markdown = await page.evaluate(async functions => { const mod = await import('/static/js/markdown.js'); + const markdownModule = mod; + const _normalizeRichLinkUrl = eval('(' + functions._normalizeRichLinkUrl + ')'); + const cleanPaste = eval('(' + functions._cleanRichTextPasteHtml + ')'); const payloads = [ '', 'click', @@ -382,6 +407,14 @@ function documentFunction(name, text = source) { '
Nested

bad

', '', '