test(webui): set aside the paste's inert-parse CSP reports under Chromium 156 (Vikunja #784)
Chromium 156 (host upgrade 2026-11-03) checks script-src-attr when an inline handler attribute is created, in a document with no window as well (WPT PR 62762; the follow-up PR 64290 that skips windowless documents is not in 156). prosemirror-view's readHTML parses pasted HTML into the body of a new createHTMLDocument, so the paste test at :154 got one report per pasted handler (img onerror, a onclick, a onmouseover, svg onload), each at editor.js:8212 with the document as target. No pasted handler reaches the live document; Chromium 153, 151 and Brave 154 report nothing there. The page record now sets aside only a script-src-attr report whose event names editor.js at that readHTML line and whose target is the document. The line is found from the bundle's text, and the lines that make the parse target a new createHTMLDocument body are pinned; the test file refuses to load if they change. h.quiet(), inert(), injected, errors and rejections are unchanged. No product or CSP change. New test: after a hostile paste, three live handlers each still fail h.quiet() with one script-src-attr report: an onclick set on an editor paragraph and clicked, an img onerror parsed into a page-owned detached element, and an onclick prosemirror-view sets through its attributes prop (a report naming editor.js at another line).
This commit is contained in:
1 parent
1ff45c4ad6
commit
0c6aa1a1e2
1 file changed
+70
-2
@@ -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*<meta [^>]*>)*/.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) => "</" + 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 = '<p>pasted</p><img src="https://tracker.example/p.png" onerror="window.injected=60">' +
|
||||
'<p><a href="https://ok.example/p" onclick="window.injected=61">a</a> <a href="https://ok.example/q" onmouseover="window.injected=62">b</a></p>' +
|
||||
'<svg onload="window.injected=63"></svg>';
|
||||
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 = '<img src="/nothing.png" onerror="window.injected=65">'; 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 () => {
|
||||
|
||||
Reference in new issue
Block a user