openvibers/openvibe.chat Commits

Credentialed CORS for exact origins only: the *.openvibe.tools suffix rule inherited from Live gave any tools subdomain credentialed access to a signed-in person's DMs and chat, and no tools satellite embeds chat. The list is Live's exact origins (BASE_URL + www, the Network, openvibe.games, openvibe.tools) plus ALLOWED_ORIGINS, the same list the WebSocket upgrade already used

OP OpenVibers committed 23 Sept 2026 · 766ee35889 · 4 files, +44 −14


Changes

ddiff --git a/.env.example b/.env.example
iindex a71b64b..a338b31 100644
---- a/.env.example
++++ b/.env.example
@@@ -12,8 +12,9 @@ NODE_ENV=development
BASE_URL=http://localhost:3000
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2), as Live
TRUST_PROXY=2
--# Extra origins for WebSocket upgrades / credentialed CORS (Live's are built in)
--#ALLOWED_ORIGINS=
++# Extra origins for WebSocket upgrades / credentialed CORS (Live's exact ones are built in).
++# Exact origins only, no wildcard: list any *.openvibe.tools satellite that embeds chat.
++#ALLOWED_ORIGINS=https://example.openvibe.tools
# Chat's own database (production: systemd StateDirectory=openvibe-chat)
CHAT_DB_PATH=./data/chat.db
ddiff --git a/server/app.js b/server/app.js
iindex 11f8dbf..7bd148b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -3,8 +3,8 @@
*
* Browsers reach Chat on Live's origin: nginx sends /ws/chat and the chat REST prefixes
* (/api/chat/, /api/dm/, /api/tts/, /api/sounds) here (docs/cutover.md), so the guards Live ran
-- * in front of those routes run here too, with Live's values: credentialed CORS for Live's
-- * origins, the /api rate limit, IP/network bans (admins exempt) and the ov_banned cookie, the
++ * in front of those routes run here too, with Live's values: credentialed CORS for an explicit
++ * list of origins (never a wildcard), the /api rate limit, IP/network bans (admins exempt) and the ov_banned cookie, the
* WebSocket origin allow-list and IP-ban check at upgrade.
*/
'use strict';
@@@ -27,7 +27,13 @@ function normalizeOrigin(origin) {
}
}
--/** Live's getAllowedOrigins(), plus ALLOWED_ORIGINS. */
++/**
++ * The browser origins allowed credentialed CORS and WebSocket upgrades: Live's exact list (BASE_URL
++ * and its www variant, the Network, openvibe.games, openvibe.tools) plus ALLOWED_ORIGINS. Exact
++ * origins only: Live's *.openvibe.tools suffix rule is not carried over, because a credentialed
++ * CORS grant lets that origin read a signed-in person's DMs and chat, and no tools satellite embeds
++ * chat. A host that starts to must be listed in ALLOWED_ORIGINS.
++ */
function getAllowedOrigins() {
const allowed = new Set();
const baseOrigin = normalizeOrigin(config.baseUrl);
@@@ -59,14 +65,9 @@ function createApp({ chatServer, bridge, mirror, relay }) {
// Cloudflare address (DNS-only hosts reach nginx directly with a client-written X-Forwarded-For).
app.set('trust proxy', trustProxy(config.trustProxy));
-- /** Exact allow-list first, then *.openvibe.tools over https (Live's isAllowedOrigin). */
++ /** Exact allow-list only (no subdomain wildcard). */
function isAllowedOrigin(origin) {
-- if (allowedOrigins.has(origin)) return true;
-- try {
-- const u = new URL(origin);
-- if (u.protocol !== 'https:') return false;
-- return u.hostname === 'openvibe.tools' || u.hostname.endsWith('.openvibe.tools');
-- } catch { return false; }
++ return allowedOrigins.has(origin);
}
// ── Internal (loopback; never routed by nginx) ─────────────
ddiff --git a/server/config.js b/server/config.js
iindex 6f75d93..90fdf6b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -27,6 +27,7 @@ module.exports = {
trustProxy: process.env.TRUST_PROXY != null ? Number(process.env.TRUST_PROXY) : 2,
// Extra browser origins allowed for WebSocket upgrades and credentialed CORS, on top of the
// ones Live allows (BASE_URL + www variant, the Network, openvibe.games, openvibe.tools).
++ // Exact origins only: a *.openvibe.tools satellite that embeds chat must be listed here.
extraOrigins: String(process.env.ALLOWED_ORIGINS || '').split(',').map((s) => strip(s.trim())).filter(Boolean),
// Chat's own SQLite database (systemd StateDirectory openvibe-chat).
ddiff --git a/test/rest.test.js b/test/rest.test.js
iindex fcee91c..4c0ef52 100644
---- a/test/rest.test.js
++++ b/test/rest.test.js
@@@ -189,12 +189,39 @@ t('CORS: Live’s origins allowed with credentials, others refused', async () =>
const ok = await h.http('GET', '/api/chat/filters/friendly', { headers: { Origin: 'https://openvibe.network' } });
assert.strictEqual(ok.headers.get('access-control-allow-origin'), 'https://openvibe.network');
assert.strictEqual(ok.headers.get('access-control-allow-credentials'), 'true');
-- const sat = await h.http('GET', '/api/chat/filters/friendly', { headers: { Origin: 'https://pastes.openvibe.tools' } });
-- assert.strictEqual(sat.status, 200);
const bad = await h.http('GET', '/api/chat/filters/friendly', { headers: { Origin: 'https://evil-openvibe.tools' } });
assert.strictEqual(bad.status, 403);
});
++t('CORS: no *.openvibe.tools wildcard — only exact origins get credentialed CORS; ALLOWED_ORIGINS adds one', async () => {
++ const { createApp } = require('../server/app');
++ const probe = (origin, method = 'GET') => h.http(method, '/api/dm/conversations', { headers: { Origin: origin, 'Access-Control-Request-Method': 'GET' } });
++ for (const origin of ['https://pastes.openvibe.tools', 'https://anything.openvibe.tools', 'https://a.b.openvibe.tools', 'http://pastes.openvibe.tools']) {
++ for (const method of ['GET', 'OPTIONS']) {
++ const r = await probe(origin, method);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, `${method} ${origin} got CORS`);
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null, `${method} ${origin} got credentials`);
++ assert.strictEqual(r.status, 403, `${method} ${origin}`);
++ }
++ }
++ const apex = await probe('https://openvibe.tools');
++ assert.strictEqual(apex.headers.get('access-control-allow-origin'), 'https://openvibe.tools', 'the listed apex keeps working');
++ // The same list decides the exported check (and the WebSocket upgrade).
++ const stub = { chatServer: { getTotalConnections: () => 0 }, bridge: (q, s, n) => n() };
++ const { isAllowedOrigin, allowedOrigins } = createApp(stub);
++ assert.strictEqual(isAllowedOrigin('https://pastes.openvibe.tools'), false);
++ assert.strictEqual(isAllowedOrigin('https://openvibe.live'), true);
++ assert.ok(![...allowedOrigins].some((o) => o.includes('*')));
++ const config = require('../server/config');
++ const saved = config.extraOrigins;
++ config.extraOrigins = ['https://embed.openvibe.tools/'];
++ try {
++ const withExtra = createApp(stub);
++ assert.strictEqual(withExtra.isAllowedOrigin('https://embed.openvibe.tools'), true, 'ALLOWED_ORIGINS lists a satellite exactly');
++ assert.strictEqual(withExtra.isAllowedOrigin('https://other.openvibe.tools'), false);
++ } finally { config.extraOrigins = saved; }
++});
++
t('scripts/parity-check.js: two identical services answer the same', async () => {
const { spawn } = require('child_process');
const path = require('path');