diff --git a/packages/webui/tests/editor-browser.test.mjs b/packages/webui/tests/editor-browser.test.mjs index 869c74db9..b7a7966dc 100644 --- a/packages/webui/tests/editor-browser.test.mjs +++ b/packages/webui/tests/editor-browser.test.mjs @@ -4,6 +4,7 @@ // Row 148's checks (the Source view, the save receipts) are docs-browser.test.mjs's and run unchanged there. import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { startServer } from '../src/serve.mjs'; import { browser } from './browser.mjs'; import { fixture, close } from './fixture.mjs'; @@ -26,11 +27,38 @@ const HOSTILE = [ const MIX = '# Title\n\nFirst para.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nLast para.\n'; const files = { 'docs/hostile.md': HOSTILE, 'docs/every.md': EVERY, 'docs/mix.md': MIX, 'docs/empty.md': '', 'docs/tilde.md': '~~~a`b\ncode\n~~~\n' }; +// Chromium 156 checks script-src-attr when an inline handler attribute is created, not when the handler would run, and does +// so in a document with no window as well (WPT PR 62762; PR 64290 later skips a windowless document, and 156 lacks it). So +// prosemirror-view's parse of pasted HTML into a fresh createHTMLDocument raises one report per handler against the page's +// policy, though no handler there can run. The page record sets aside only reports whose event names, as the place the +// attribute was created, the one statement below in editor.js, and whose target is the document. Why that cannot hide a +// live handler: a handler put on a live element by any other code reports another file or line; an element from this parse +// attached to the page before its report is sent makes the report's target that element; and a release that checks only +// when a handler runs reports where it runs, never at this parse. INERT_PARSE pins the lines that make `elt` the body of a +// document from detachedDoc(), and DETACHED pins that detachedDoc() makes a new createHTMLDocument each call. +const BUNDLE = readFileSync(new URL('../src/public/editor.js', import.meta.url), 'utf8').split('\n'); +const INERT_PARSE = [ + ' function readHTML(html) {', + ' let metas = /^(\\s*]*>)*/.exec(html);', + ' if (metas) html = html.slice(metas[0].length);', + ' let doc3 = detachedDoc(), elt = doc3.body;', + ' let firstTag = /<([a-z][^>\\s]+)/i.exec(html), wrap2;', + ' if (wrap2 = firstTag && wrapMap[firstTag[1].toLowerCase()])', + ' html = wrap2.map((n) => "<" + n + ">").join("") + html + wrap2.map((n) => "").reverse().join("");', + ' elt.innerHTML = maybeWrapTrusted(html);', +]; +const DETACHED = [' function detachedDoc() {', ' return document.implementation.createHTMLDocument("title");', ' }']; +const found = lines => BUNDLE.flatMap((l, i) => lines.every((s, j) => BUNDLE[i + j] === s) ? [i] : []); +assert.ok(found(INERT_PARSE).length === 1 && found(DETACHED).length === 1 && BUNDLE.filter(l => /\bdetachedDoc\s*=|function detachedDoc\b/.test(l)).length === 1, + 'editor.js parses pasted HTML in readHTML into the body of a new createHTMLDocument; the CSP record below is keyed on that line'); +const INERT_PARSE_LINE = found(INERT_PARSE)[0] + INERT_PARSE.length; + const STUB = `(() => { const real = window.fetch.bind(window); - window.__saves = []; window.__answers = []; window.__rejections = []; window.__csp = []; window.__errors = []; + window.__saves = []; window.__answers = []; window.__rejections = []; window.__csp = []; window.__cspInert = []; window.__errors = []; addEventListener('unhandledrejection', e => window.__rejections.push(String(e.reason?.message ?? e.reason))); - addEventListener('securitypolicyviolation', e => window.__csp.push(e.violatedDirective + ' ' + e.blockedURI)); + addEventListener('securitypolicyviolation', e => (e.violatedDirective === 'script-src-attr' && e.sourceFile === location.origin + '/editor.js' + && e.lineNumber === ${INERT_PARSE_LINE} && e.target === document ? window.__cspInert : window.__csp).push(e.violatedDirective + ' ' + e.blockedURI)); addEventListener('error', e => window.__errors.push(String(e.message))); window.fetch = async (url, init = {}) => { const u = String(url); @@ -241,6 +269,46 @@ test('hostile content pasted into the editor runs and loads nothing, and a bad l }); }); +// Under Chromium 156 the paste raises one script-src-attr report per handler from the parse into prosemirror-view's +// detached document, and the page record sets those aside (STUB). A handler on the live page must still fail quiet(): one +// set on a paragraph in the editor and clicked, one parsed into an element the page owns but never attaches, and one that +// prosemirror-view itself sets on an editor it draws, so that report names editor.js at another line. +test('after a hostile paste, an inline handler on the live page still fails the quiet check', { timeout: 120000 }, async () => { + await session(async h => { + await h.open('docs/mix.md'); + await h.pm(); + const HTML = '

pasted

' + + '

a b

' + + ''; + await h.q(`(() => { + const p = document.querySelector('#doc-visual .ProseMirror'); p.focus(); + getSelection().selectAllChildren(p); getSelection().collapseToEnd(); + const dt = new DataTransfer(); dt.setData('text/html', ${JSON.stringify(HTML)}); + p.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + return true; })()`); + await h.wait('document.querySelector("#doc-visual .ProseMirror").textContent.includes("pasted")'); + await h.q('new Promise(r => setTimeout(r, 300)).then(() => true)'); + const view = await h.inert(); + assert.deepEqual([view.bad, view.handlers, view.fetched, view.injected], [[], 0, [], null]); + await h.quiet(); + const LIVE = [ + ['a handler set on a paragraph in the editor, then clicked', `(() => { const p = document.querySelector('#doc-visual .ProseMirror p'); p.setAttribute('onclick', 'window.injected=64'); p.click(); return true; })()`], + ['a handler parsed into an element the page owns and never attaches', `(() => { document.createElement('div').innerHTML = ''; return true; })()`], + ['a handler prosemirror-view sets through its attributes prop on an editor it draws, then clicked', `(() => { const host = document.body.appendChild(document.createElement('div')); + const ed = window.MosaicEditor.mount(host, { text: 'x', editable: false }); ed.view.setProps({ attributes: { onclick: 'window.injected=66' } }); ed.view.dom.click(); return true; })()`], + ]; + for (const [what, expr] of LIVE) { + const aside = await h.q('window.__cspInert.length'); + await h.q('window.__csp = [], true'); + await h.q(expr); + // the one report comes when the attribute is set (Chromium 156) or when the handler would fire (earlier releases) + await h.wait(`window.__csp.length + window.__cspInert.length > ${aside}`); + await assert.rejects(h.quiet(), e => { assert.deepEqual(e.actual, { csp: ['script-src-attr inline'], errors: [], rejections: [], injected: null }, what); return true; }, what); + assert.equal(await h.q('window.__cspInert.length'), aside, `${what}: nothing set aside`); + } + }); +}); + // Mos's text said an image becomes text; this row also gates a pasted image span (span.md-image[data-src], the editor's own // copy format) on the link gate, so a target the gate refuses never becomes an image node, and none can reach a save. test('a pasted image span is kept only for a target the link gate accepts, and a save writes only those', { timeout: 120000 }, async () => {