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
Changes
@@@ -12,8 +12,9 @@ NODE_ENV=developmentBASE_URL=http://localhost:3000# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2), as LiveTRUST_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@@@ -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) ─────────────@@@ -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).@@@ -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');
@@ -12,8 +12,9 @@ NODE_ENV=development
@@ -12,8 +12,9 @@ NODE_ENV=development
BASE_URL=http://localhost:3000
BASE_URL=http://localhost:3000
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2), as Live
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2), as Live
TRUST_PROXY=2
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's own database (production: systemd StateDirectory=openvibe-chat)
CHAT_DB_PATH=./data/chat.db
CHAT_DB_PATH=./data/chat.db
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
*
*
* Browsers reach Chat on Live's origin: nginx sends /ws/chat and the chat REST prefixes
* 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
* (/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.
* WebSocket origin allow-list and IP-ban check at upgrade.
*/
*/
'use strict';
'use strict';
@@ -27,7 +27,13 @@ function normalizeOrigin(origin) {
@@ -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() {
function getAllowedOrigins() {
const allowed = new Set();
const allowed = new Set();
const baseOrigin = normalizeOrigin(config.baseUrl);
const baseOrigin = normalizeOrigin(config.baseUrl);
@@ -59,14 +65,9 @@ function createApp({ chatServer, bridge, mirror, relay }) {
@@ -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).
// Cloudflare address (DNS-only hosts reach nginx directly with a client-written X-Forwarded-For).
app.set('trust proxy', trustProxy(config.trustProxy));
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) {
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) ─────────────
// ── Internal (loopback; never routed by nginx) ─────────────
@@ -27,6 +27,7 @@ module.exports = {
@@ -27,6 +27,7 @@ module.exports = {
trustProxy: process.env.TRUST_PROXY != null ? Number(process.env.TRUST_PROXY) : 2,
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
// 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).
// 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),
extraOrigins: String(process.env.ALLOWED_ORIGINS || '').split(',').map((s) => strip(s.trim())).filter(Boolean),
// Chat's own SQLite database (systemd StateDirectory openvibe-chat).
// Chat's own SQLite database (systemd StateDirectory openvibe-chat).
@@ -189,12 +189,39 @@ t('CORS: Live’s origins allowed with credentials, others refused', async () =>
@@ -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' } });
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-origin'), 'https://openvibe.network');
assert.strictEqual(ok.headers.get('access-control-allow-credentials'), 'true');
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' } });
const bad = await h.http('GET', '/api/chat/filters/friendly', { headers: { Origin: 'https://evil-openvibe.tools' } });
assert.strictEqual(bad.status, 403);
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 () => {
t('scripts/parity-check.js: two identical services answer the same', async () => {
const { spawn } = require('child_process');
const { spawn } = require('child_process');
const path = require('path');
const path = require('path');