test(webui): the paste test catches a handler that enters the live page (Vikunja #784 R2)
Chromium also targets the document when a handler-bearing element is attached to the live page and removed before the CSP event is sent, so the readHTML filter alone (directive, source file, line, target document) could not tell that from the inert parse. The page now also records, with a MutationObserver on the live document, every element that enters the page carrying an on* attribute and every on* attribute set on an element in the page, and h.quiet() asserts the record is empty. The live-handler test gains a case for a handler attached and removed in the same turn, and checks per case that the observer saw the handler enter the page (not for the element never attached). Test-only; no product change.
This commit is contained in:
1 parent
0c6aa1a1e2
commit
e71222d1a0
1 file changed
+33
-14
@@ -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 <img class="ProseMirror-separator"> (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 = '<img src="/nothing.png" onerror="window.injected=65">'; 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 = '<img src="/nothing.png" onerror="window.injected=65">'; 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 = '<span onmouseover="window.injected=67">x</span>'; 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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user