openvibers/openvibe.live Commits
Home reveal listener hooked once; inline speculation rules stop filing CSP reports (#572)
Changes
@@@ -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);@@@ -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'",nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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');
@@ -106,6 +106,7 @@
@@ -106,6 +106,7 @@
* It is now a standalone schedule that cannot be starved, and it sweeps a few times to catch
* It is now a standalone schedule that cannot be starved, and it sweeps a few times to catch
* sections that mount late.
* sections that mount late.
*/
*/
+ let revealScrollHooked = false;
function startRevealSafety() {
function startRevealSafety() {
if (revealSafety) return;
if (revealSafety) return;
const sweep = () => {
const sweep = () => {
@@ -119,7 +120,10 @@
@@ -119,7 +120,10 @@
revealSafety = setTimeout(sweep, 1200);
revealSafety = setTimeout(sweep, 1200);
// A fling can carry a section past the screen between two frames, so the observer never reports it, and the
// 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
// 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;
let settle = 0;
window.addEventListener('scroll', () => {
window.addEventListener('scroll', () => {
clearTimeout(settle);
clearTimeout(settle);
@@ -407,7 +407,9 @@ function cspReportOnly(html) {
@@ -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 scriptHosts = "cdnjs.cloudflare.com cdn.jsdelivr.net https://openvibe.network https://esm.sh https://static.cloudflareinsights.com";
const policy = [
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'",
"script-src-attr 'unsafe-inline'",
"object-src 'none'",
"object-src 'none'",
"base-uri 'self'",
"base-uri 'self'",
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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');