openvibers/openvibe.live Commits

Home reveal listener hooked once; inline speculation rules stop filing CSP reports (#572)

OP OpenVibers committed 10 days ago · e1f4875c36 · 4 files, +70 −2

The browser smoke's lap leak (windowListeners 82→86) was startRevealSafety adding a scroll listener each time its
sweeps restarted. And every guest's direct /broadcast load reported the navbar's inline speculation rules: the
report-only policy now names 'inline-speculation-rules'. axe found no serious or critical violations.

Changes

ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
iindex 79673bdf..d8ab301b 100644
---- a/public/js/home-fx.js
++++ b/public/js/home-fx.js
@@@ -106,6 +106,7 @@
* It is now a standalone schedule that cannot be starved, and it sweeps a few times to catch
* sections that mount late.
*/
++ let revealScrollHooked = false;
function startRevealSafety() {
if (revealSafety) return;
const sweep = () => {
@@@ -119,7 +120,10 @@
revealSafety = setTimeout(sweep, 1200);
// A fling can carry a section past the screen between two frames, so the observer never reports it, and the
// sweeps above stop after ten seconds. When scrolling stops, anything above the bottom of the screen that is
-- // still waiting is shown: one query per pause, never during the scroll.
++ // still waiting is shown: one query per pause, never during the scroll. Hooked once per page life: the sweeps
++ // restart on later mutations, and each restart used to add another scroll listener (11-03, the smoke's lap leak).
++ if (revealScrollHooked) return;
++ revealScrollHooked = true;
let settle = 0;
window.addEventListener('scroll', () => {
clearTimeout(settle);
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex 4b4b6d58..44fde809 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -407,7 +407,9 @@ function cspReportOnly(html) {
}
const scriptHosts = "cdnjs.cloudflare.com cdn.jsdelivr.net https://openvibe.network https://esm.sh https://static.cloudflareinsights.com";
const policy = [
-- `script-src-elem 'self' ${[...hashes].join(' ')} ${scriptHosts}`,
++ // The shared navbar adds inline speculation rules (same-site prefetch); CSP 3 allows them with their own
++ // keyword, not a hash (11-03: every guest's direct /broadcast load reported one).
++ `script-src-elem 'self' 'inline-speculation-rules' ${[...hashes].join(' ')} ${scriptHosts}`,
"script-src-attr 'unsafe-inline'",
"object-src 'none'",
"base-uri 'self'",
ddiff --git a/test/csp-report-only.test.js b/test/csp-report-only.test.js
nnew file mode 100644
iindex 00000000..29b0ba64
---- /dev/null
++++ b/test/csp-report-only.test.js
@@@ -0,0 +1,19 @@
++'use strict';
++// The report-only policy for rendered documents (server/web/assets.js cspReportOnly): inline scripts by hash, and
++// the shared navbar's inline speculation rules by their own CSP 3 keyword (11-03: every guest's direct /broadcast load
++// reported one).
++// node test/csp-report-only.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const { cspReportOnly } = require('../server/web/assets');
++
++const inline = 'window.x = 1;';
++const policy = cspReportOnly(`<html><head><script>${inline}</script><script type="application/ld+json">{"a":1}</script><script src="/js/app.js"></script></head></html>`);
++const elem = policy.split('; ').find((d) => d.startsWith('script-src-elem '));
++assert.ok(elem, 'a script-src-elem directive');
++assert.ok(elem.includes("'inline-speculation-rules'"), 'inline speculation rules are allowed');
++assert.ok(elem.includes(`'sha256-${crypto.createHash('sha256').update(inline, 'utf8').digest('base64')}'`), 'the inline script by its hash');
++assert.ok(!elem.includes("'unsafe-inline'"), 'never unsafe-inline for elements');
++assert.strictEqual((elem.match(/'sha256-/g) || []).length, 1, 'JSON-LD and src scripts need no hash');
++assert.ok(policy.includes('report-uri /api/csp-report'));
++console.log('csp report-only: all checks passed');
ddiff --git a/test/home-fx-reveal-once.test.js b/test/home-fx-reveal-once.test.js
nnew file mode 100644
iindex 00000000..2e52b8ba
---- /dev/null
++++ b/test/home-fx-reveal-once.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++// The home's reveal safety (public/js/home-fx.js startRevealSafety) hooks its scroll listener once per page life. Its
++// sweeps stop after five passes and restart on a later mutation; each restart used to add another window scroll
++// listener, so the browser smoke's laps grew by four every lap (11-03, windowListeners 82→86).
++// node test/home-fx-reveal-once.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'home-fx.js'), 'utf8');
++const decl = /let io = null, revealSafety = 0, revealPasses = 0, firstRevealPass = true;/.exec(src);
++assert.ok(decl, 'the reveal state is where the test expects it');
++const at = src.indexOf('let revealScrollHooked = false;');
++assert.ok(at > 0, 'the once flag is declared');
++const fnAt = src.indexOf('function startRevealSafety()', at);
++let depth = 0;
++let end = -1;
++for (let i = src.indexOf('{', fnAt); i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) { end = i + 1; break; }
++}
++const fnSrc = src.slice(at, end);
++
++const scrolls = [];
++const timers = [];
++const sandbox = {
++ window: { innerHeight: 800, addEventListener: (type, fn) => { if (type === 'scroll') scrolls.push(fn); } },
++ document: { querySelectorAll: () => [] },
++ setTimeout: (fn) => { timers.push(fn); return timers.length; },
++ clearTimeout: () => {},
++};
++vm.createContext(sandbox);
++vm.runInContext(`${decl[0]}\n${fnSrc}\nthis.start = startRevealSafety; this.drain = () => { while (this.timers.length) this.timers.shift()(); };`, Object.assign(sandbox, { timers }));
++
++sandbox.start();
++assert.strictEqual(scrolls.length, 1, 'one scroll listener');
++sandbox.drain(); // the five sweeps run out; revealSafety is 0 again
++sandbox.start(); // a later mutation restarts the sweeps
++sandbox.drain();
++sandbox.start();
++assert.strictEqual(scrolls.length, 1, 'still one scroll listener after the sweeps restart');
++console.log('home-fx reveal: one scroll listener per page life');