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)

OP OpenVibers committed 28 days ago · 14573f89ef · 3 files, +40 −6

Changes

ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 00c3075..8e93256 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -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 };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 83ba9f2..95e9be9 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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);
ddiff --git a/test/web.test.js b/test/web.test.js
iindex b0f0175..c103673 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -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);