openvibers/openvibe.live Commits

Go live: allow the camera kit from openvibe.cam (it was refused on every browser) (#843)

OP OpenVibers committed 1 days ago · bf622564af · 3 files, +57 −3

The go-live page loads OpenVibe.Cam's camera kit (cam-kit.js, hash-pinned) when a
streamer presses "Turn on camera". Live's script-src never named
https://openvibe.cam, so every browser refused the script and the page answered
"Could not access camera/mic: Could not load camera controls". A streamer
reported it from a phone on 11-12; it has been so since the preview card arrived
on 11-09.

script-src and style-src (the kit adds its own stylesheet) now allow
https://openvibe.cam, and so does the report-only script-src-elem.

Test: test/csp-script-hosts.test.js reads every script a page script adds from
another host (createElement('script') ... .src = 'https://host/...') and checks
the host against both policies, so a new one cannot be refused silently again.

Changes

ddiff --git a/server/index.js b/server/index.js
iindex 480fac32..1d87d0d1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -391,9 +391,11 @@ app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
-- scriptSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "https://openvibe.network", "https://jsmpeg.com", "https://esm.sh", "https://static.cloudflareinsights.com"],
++ scriptSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "https://openvibe.network", "https://jsmpeg.com", "https://esm.sh", "https://static.cloudflareinsights.com", "https://openvibe.cam"],
// esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restream
-- styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com"],
++ // openvibe.cam: the go-live page's camera kit (cam-kit.js, hash-pinned) and the stylesheet it adds. Without
++ // it every browser refused the kit and "Turn on camera" said "Could not load camera controls" (11-09 to 11-12).
++ styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com", "https://openvibe.cam"],
fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],
// i.ytimg.com / i.vimeocdn.com carry media-request thumbnails. Without them
// every queued item renders as a broken image, which is what the media
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex 402fca30..691456c4 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -422,7 +422,7 @@ function cspReportOnly(html) {
if (/type="application\/(ld\+)?json"/i.test(m[1])) continue;
hashes.add(`'sha256-${crypto.createHash('sha256').update(m[2], 'utf8').digest('base64')}'`);
}
-- 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 https://openvibe.cam";
const policy = [
// 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).
ddiff --git a/test/csp-script-hosts.test.js b/test/csp-script-hosts.test.js
nnew file mode 100644
iindex 00000000..53d423df
---- /dev/null
++++ b/test/csp-script-hosts.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++// Every script a page adds from another host is allowed by Live's Content Security Policy, both the enforced one
++// (server/index.js helmet scriptSrc) and the report-only one (server/web/assets.js cspReportOnly).
++//
++// 11-12: the go-live page loads OpenVibe.Cam's camera kit (public/js/broadcast-workspace.js, cam-kit.js with an
++// integrity hash), but script-src never named https://openvibe.cam, so every browser refused it and "Turn on camera"
++// answered "Could not load camera controls" from 11-09 until this test.
++// node test/csp-script-hosts.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { cspReportOnly } = require('../server/web/assets');
++
++const root = path.join(__dirname, '..');
++const index = fs.readFileSync(path.join(root, 'server/index.js'), 'utf8');
++const directive = (name) => {
++ const m = index.match(new RegExp(`${name}:\\s*\\[([^\\]]*)\\]`));
++ assert.ok(m, `server/index.js has a ${name} list`);
++ return m[1];
++};
++const scriptSrc = directive('scriptSrc');
++const styleSrc = directive('styleSrc');
++const reportOnly = cspReportOnly('<html><head></head></html>').split('; ').find((d) => d.startsWith('script-src-elem ')) || '';
++
++// script.src = 'https://host/…' (or "…") in any page script: the host must be in both policies.
++const files = [];
++const walk = (dir) => {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'vendor') walk(p); } else if (e.name.endsWith('.js')) files.push(p);
++ }
++};
++walk(path.join(root, 'public/js'));
++const hosts = new Map();
++for (const f of files) {
++ const text = fs.readFileSync(f, 'utf8');
++ for (const m of text.matchAll(/\b[A-Za-z_$][\w$]*\.src\s*=\s*['"`](https:\/\/[a-z0-9.-]+)\//g)) {
++ // Only script elements: an <img>/<video> src is another directive. The variable is named for its element.
++ const before = text.slice(Math.max(0, m.index - 800), m.index);
++ if (!/createElement\(\s*['"]script['"]\s*\)/.test(before)) continue;
++ hosts.set(m[1], path.relative(root, f));
++ }
++}
++assert.ok(hosts.has('https://openvibe.cam'), 'the scan finds the camera kit (or the test no longer reads the page)');
++for (const [host, file] of hosts) {
++ const bare = host.replace(/^https:\/\//, ''); // a source may be written with or without its scheme
++ assert.ok(scriptSrc.includes(`"${host}"`) || scriptSrc.includes(`"${bare}"`), `${file} loads a script from ${host}: script-src must allow it`);
++ assert.ok(reportOnly.split(' ').some((x) => x === host || x === bare), `${file} loads a script from ${host}: the report-only script-src-elem must allow it`);
++}
++// The camera kit adds its own stylesheet from the same host.
++assert.ok(styleSrc.includes('"https://openvibe.cam"'), 'style-src allows the camera kit\'s stylesheet');
++console.log(`csp script hosts: ${hosts.size} third-party script host(s) allowed`);