openvibers/openvibe.chat Commits
openvibe.chat pages: a Content-Security-Policy with a per-response nonce (scripts only from the site, inline ones only with the nonce): Chat renders other people's words live and had none (#116)
Changes
@@@ -62,7 +62,31 @@ function navConfig(o, config) {}/**-- * o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),++ * The pages' Content-Security-Policy. Chat renders other people's words live, so injected script is the risk this++ * stops: scripts only from this site (and Cloudflare's analytics beacon), inline ones only with this response's++ * nonce. Styles may be inline (style attributes), pictures and media come from anywhere over https (posted++ * pictures, avatars), and connections go to this site, Network (sign-in, the Frame) and Events (release and++ * notification streams). Checked on production pages with the policy attached before shipping (2026-10-17).++ */++function contentSecurityPolicy(nonce) {++ return [++ "default-src 'self'",++ `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com`,++ "style-src 'self' 'unsafe-inline'",++ "img-src 'self' data: blob: https:",++ "media-src 'self' blob: https:",++ "font-src 'self' data:",++ "connect-src 'self' wss://openvibe.chat https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com",++ "frame-ancestors 'self'",++ "base-uri 'self'",++ "object-src 'none'",++ "form-action 'self' https://openvibe.network",++ ].join('; ');++}++++/**++ * o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js), nonce (the++ * response's CSP nonce: every inline <script> carries it),* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread* rooms/messages for the navigation), script / callScript (public/web/chat.js, call.js)*/@@@ -83,7 +107,7 @@ function renderPage(o) {const p = o.prefs || {};const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';-- return `<!DOCTYPE html>++ const html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">@@@ -117,6 +141,7 @@ document.addEventListener('DOMContentLoaded', function () {</script></body></html>`;++ return o.nonce ? html.replace(/<script>/g, `<script nonce="${o.nonce}">`) : html;}--module.exports = { renderPage, asset, assetVersion, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };++module.exports = { renderPage, contentSecurityPolicy, asset, assetVersion, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };@@@ -33,7 +33,7 @@ const rateLimit = require('express-rate-limit');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const cache = require('openvibe-shared/cache-policy');--const { renderPage, assetVersion, esc, SITE_NAME } = require('./layout');++const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = require('./layout');const { createSessionRoutes } = require('./session');const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const session = require('../auth/network-session');@@@ -166,8 +166,9 @@ function createWebRoutes({ config }) {const page = async (req, res, status, o) => {const actor = o.actor || await viewerOf(req);-- res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).type('html')-- .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), ...o }));++ const nonce = require('crypto').randomBytes(16).toString('base64');++ res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).set('Content-Security-Policy', contentSecurityPolicy(nonce)).type('html')++ .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), nonce, ...o }));};const needUser = async (req, res) => {const actor = await viewerOf(req);@@@ -35,6 +35,14 @@ t('global chat reads without JavaScript; signing in shows the composer', async (assert.match(r.text, /Sign in with OpenVibe/);assert.match(r.text, /<meta name="robots" content="index, follow">/);assert.match(r.text, /\/shared\/navbar\.js\?v=/, 'the Frame from this site\'s own /shared/');++ // A Content-Security-Policy with a per-response nonce, carried by every inline script and only by them.++ const csp = r.headers.get('content-security-policy') || '';++ const nonce = (/'nonce-([^']+)'/.exec(csp) || [])[1];++ assert.ok(nonce && /script-src 'self' 'nonce-/.test(csp) && !/script-src[^;]*'unsafe-inline'/.test(csp), csp);++ assert.ok(/object-src 'none'/.test(csp) && /frame-ancestors 'self'/.test(csp) && /base-uri 'self'/.test(csp));++ assert.ok(!/<script>/.test(r.text), 'no inline script without the nonce');++ assert.ok(r.text.includes(`<script nonce="${nonce}">`), 'inline scripts carry this response\'s nonce');++ assert.notStrictEqual((/'nonce-([^']+)'/.exec((await req('GET', '/')).headers.get('content-security-policy')) || [])[1], nonce, 'a new nonce per response');const card = /<meta property="og:image" content="([^"]+)">/.exec(r.text);assert.ok(card && /\/shared\/social\/chat\.jpg\?v=/.test(card[1]), 'global chat shares the site card');const img = await req('GET', new URL(card[1]).pathname + new URL(card[1]).search);
@@ -62,7 +62,31 @@ function navConfig(o, config) {
@@ -62,7 +62,31 @@ function navConfig(o, config) {
}
}
/**
/**
- * o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),
+ * The pages' Content-Security-Policy. Chat renders other people's words live, so injected script is the risk this
+ * stops: scripts only from this site (and Cloudflare's analytics beacon), inline ones only with this response's
+ * nonce. Styles may be inline (style attributes), pictures and media come from anywhere over https (posted
+ * pictures, avatars), and connections go to this site, Network (sign-in, the Frame) and Events (release and
+ * notification streams). Checked on production pages with the policy attached before shipping (2026-10-17).
+ */
+function contentSecurityPolicy(nonce) {
+ return [
+ "default-src 'self'",
+ `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com`,
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob: https:",
+ "media-src 'self' blob: https:",
+ "font-src 'self' data:",
+ "connect-src 'self' wss://openvibe.chat https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com",
+ "frame-ancestors 'self'",
+ "base-uri 'self'",
+ "object-src 'none'",
+ "form-action 'self' https://openvibe.network",
+ ].join('; ');
+}
+
+/**
+ * o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js), nonce (the
+ * response's CSP nonce: every inline <script> carries it),
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* rooms/messages for the navigation), script / callScript (public/web/chat.js, call.js)
* rooms/messages for the navigation), script / callScript (public/web/chat.js, call.js)
*/
*/
@@ -83,7 +107,7 @@ function renderPage(o) {
@@ -83,7 +107,7 @@ function renderPage(o) {
const p = o.prefs || {};
const p = o.prefs || {};
const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');
const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');
const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';
const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';
- return `<!DOCTYPE html>
+ const html = `<!DOCTYPE html>
<html lang="en">
<html lang="en">
<head>
<head>
<meta charset="utf-8">
<meta charset="utf-8">
@@ -117,6 +141,7 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -117,6 +141,7 @@ document.addEventListener('DOMContentLoaded', function () {
</script>
</script>
</body>
</body>
</html>`;
</html>`;
+ return o.nonce ? html.replace(/<script>/g, `<script nonce="${o.nonce}">`) : html;
}
}
-module.exports = { renderPage, asset, assetVersion, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
+module.exports = { renderPage, contentSecurityPolicy, asset, assetVersion, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
@@ -33,7 +33,7 @@ const rateLimit = require('express-rate-limit');
@@ -33,7 +33,7 @@ const rateLimit = require('express-rate-limit');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
-const { renderPage, assetVersion, esc, SITE_NAME } = require('./layout');
+const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = require('./layout');
const { createSessionRoutes } = require('./session');
const { createSessionRoutes } = require('./session');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const session = require('../auth/network-session');
const session = require('../auth/network-session');
@@ -166,8 +166,9 @@ function createWebRoutes({ config }) {
@@ -166,8 +166,9 @@ function createWebRoutes({ config }) {
const page = async (req, res, status, o) => {
const page = async (req, res, status, o) => {
const actor = o.actor || await viewerOf(req);
const actor = o.actor || await viewerOf(req);
- res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).type('html')
- .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), ...o }));
+ const nonce = require('crypto').randomBytes(16).toString('base64');
+ res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).set('Content-Security-Policy', contentSecurityPolicy(nonce)).type('html')
+ .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), nonce, ...o }));
};
};
const needUser = async (req, res) => {
const needUser = async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
@@ -35,6 +35,14 @@ t('global chat reads without JavaScript; signing in shows the composer', async (
@@ -35,6 +35,14 @@ t('global chat reads without JavaScript; signing in shows the composer', async (
assert.match(r.text, /Sign in with OpenVibe/);
assert.match(r.text, /Sign in with OpenVibe/);
assert.match(r.text, /<meta name="robots" content="index, follow">/);
assert.match(r.text, /<meta name="robots" content="index, follow">/);
assert.match(r.text, /\/shared\/navbar\.js\?v=/, 'the Frame from this site\'s own /shared/');
assert.match(r.text, /\/shared\/navbar\.js\?v=/, 'the Frame from this site\'s own /shared/');
+ // A Content-Security-Policy with a per-response nonce, carried by every inline script and only by them.
+ const csp = r.headers.get('content-security-policy') || '';
+ const nonce = (/'nonce-([^']+)'/.exec(csp) || [])[1];
+ assert.ok(nonce && /script-src 'self' 'nonce-/.test(csp) && !/script-src[^;]*'unsafe-inline'/.test(csp), csp);
+ assert.ok(/object-src 'none'/.test(csp) && /frame-ancestors 'self'/.test(csp) && /base-uri 'self'/.test(csp));
+ assert.ok(!/<script>/.test(r.text), 'no inline script without the nonce');
+ assert.ok(r.text.includes(`<script nonce="${nonce}">`), 'inline scripts carry this response\'s nonce');
+ assert.notStrictEqual((/'nonce-([^']+)'/.exec((await req('GET', '/')).headers.get('content-security-policy')) || [])[1], nonce, 'a new nonce per response');
const card = /<meta property="og:image" content="([^"]+)">/.exec(r.text);
const card = /<meta property="og:image" content="([^"]+)">/.exec(r.text);
assert.ok(card && /\/shared\/social\/chat\.jpg\?v=/.test(card[1]), 'global chat shares the site card');
assert.ok(card && /\/shared\/social\/chat\.jpg\?v=/.test(card[1]), 'global chat shares the site card');
const img = await req('GET', new URL(card[1]).pathname + new URL(card[1]).search);
const img = await req('GET', new URL(card[1]).pathname + new URL(card[1]).search);