diff --git a/packages/webui/tests/editor-browser.test.mjs b/packages/webui/tests/editor-browser.test.mjs
index b7a7966dc..198ccf5b7 100644
--- a/packages/webui/tests/editor-browser.test.mjs
+++ b/packages/webui/tests/editor-browser.test.mjs
@@ -31,11 +31,15 @@ const files = { 'docs/hostile.md': HOSTILE, 'docs/every.md': EVERY, 'docs/mix.md
// 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.
+// attribute was created, the one statement below in editor.js, and whose target is the document. That filter alone cannot
+// tell a handler that stayed in the detached document from one that was attached to the live page and taken off again
+// before the report was sent: Chromium then also targets the document. So the page also records, with a MutationObserver
+// on the live document that does not look at CSP events, every element that enters the page carrying an on* attribute and
+// every on* attribute set on an element in the page (STUB, window.__live; quiet() asserts it stays empty). Mutation records
+// outlast an attach and a removal in one turn. A handler put on a live element by other code also reports another file or
+// line, 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) {',
@@ -60,6 +64,12 @@ const STUB = `(() => {
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.__live = [];
+ const handlers = el => [...el.attributes].map(a => a.name).filter(n => /^on/i.test(n));
+ new MutationObserver(records => { for (const r of records) {
+ if (r.type === 'attributes' && /^on/i.test(r.attributeName)) window.__live.push('set ' + r.attributeName + ' on ' + r.target.localName);
+ for (const n of r.addedNodes) if (n.nodeType === 1) for (const e of [n, ...n.querySelectorAll('*')]) if (handlers(e).length) window.__live.push('added ' + e.localName + ' with ' + handlers(e));
+ } }).observe(document, { subtree: true, childList: true, attributes: true });
window.fetch = async (url, init = {}) => {
const u = String(url);
if (u === '/api/config') {
@@ -97,8 +107,12 @@ async function session(run) {
pm: () => wait('document.querySelector("#doc-visual .ProseMirror")'),
answer: a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`),
// What the page holds that must stay empty, whatever else a test does.
- quiet: async () => assert.deepEqual(await b.evaluate('({ csp: window.__csp, errors: window.__errors, rejections: window.__rejections, injected: window.injected ?? null })'),
- { csp: [], errors: [], rejections: [], injected: null }),
+ quiet: async () => {
+ assert.deepEqual(await b.evaluate('({ csp: window.__csp, errors: window.__errors, rejections: window.__rejections, injected: window.injected ?? null })'),
+ { csp: [], errors: [], rejections: [], injected: null });
+ // read apart from the CSP record: the filter on it cannot tell a handler that entered the page and left again
+ assert.deepEqual(await b.evaluate('window.__live'), [], 'no element with an inline handler entered the live page');
+ },
// The editor's inert-ness: nothing in it can run or load.
inert: () => b.evaluate(`(() => { const p = document.querySelector('#doc-visual .ProseMirror'); return {
// prosemirror-view's own src-less
(a cursor hack after a non-editable node) is not content
@@ -271,8 +285,11 @@ 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.
+// set on a paragraph in the editor and clicked, one parsed into an element the page owns but never attaches, one that
+// prosemirror-view itself sets on an editor it draws, so that report names editor.js at another line, and one parsed into
+// an element the page attaches and takes off again in the same turn. Each also says whether the page's MutationObserver
+// saw the handler enter the page (window.__live): it must for all but the element never attached, or quiet() could not
+// catch a pasted handler that was attached and removed before its CSP report was sent.
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');
@@ -292,19 +309,21 @@ test('after a hostile paste, an inline handler on the live page still fails the
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 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; })()`, true],
+ ['a handler parsed into an element the page owns and never attaches', `(() => { document.createElement('div').innerHTML = '
'; return true; })()`, false],
['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; })()`],
+ const ed = window.MosaicEditor.mount(host, { text: 'x', editable: false }); ed.view.setProps({ attributes: { onclick: 'window.injected=66' } }); ed.view.dom.click(); return true; })()`, true],
+ ['a handler parsed into an element the page attaches and takes off again in the same turn', `(() => { const d = document.createElement('div'); d.innerHTML = 'x'; document.body.appendChild(d); d.remove(); return true; })()`, true],
];
- for (const [what, expr] of LIVE) {
+ for (const [what, expr, entered] of LIVE) {
const aside = await h.q('window.__cspInert.length');
- await h.q('window.__csp = [], true');
+ await h.q('window.__csp = [], window.__live = [], 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`);
+ assert.equal(await h.q('window.__live.length > 0'), entered, `${what}: ${entered ? 'seen' : 'not seen'} entering the page`);
}
});
});