openvibers/openvibe.network Commits

Cross-site trust stops at Host tenant sites: *.openvibe.host gets no FedCM assertion, /sso/check answer or redirect

OP OpenVibers committed 23 Sept 2026 · 032f597e98 · 7 files, +43 −14

Exploit: every owned-zone matcher accepted subdomains of openvibe.host, but <site>.openvibe.host is
tenant content (OpenVibe.Host Stage B, the tenant's own JS). A tenant page could
- ask the browser for a FedCM assertion (fedcm.js rpOrigin) for the visiting user, which services
  that verify user JWTs by signature and issuer alone accept as a session;
- frame /sso/check (sso-check.js allowedOrigin) and receive { signedIn, username }, naming every
  signed-in visitor;
- be a post-sign-in redirect target of /login?return= and /sso/fanout?next=.
sso-owned.js now has USER_CONTENT_ZONES (openvibe.host) and isTrustedHost: the apex (the Host
dashboard) stays trusted, its subdomains do not. Applied to rpOrigin, allowedOrigin, safeNext and
the page guards. Domain validation still refuses openvibe.host names as tool domains.

Changes

ddiff --git a/public/login.html b/public/login.html
iindex ea1161a8..1ba53846 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -733,6 +733,7 @@ function ripple(btn, e) {
// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone
// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.
const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];
++const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is ours
function safeReturnUrl(raw) {
const s = String(raw == null ? '' : raw);
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
@@@ -742,7 +743,7 @@ function safeReturnUrl(raw) {
if (u.username || u.password) return '/';
if (u.origin === window.location.origin) return u.toString();
const h = u.hostname.toLowerCase();
-- if (u.protocol === 'https:' && OV_OWNED_ZONES.some(z => h === z || h.endsWith('.' + z))) return u.toString();
++ if (u.protocol === 'https:' && OV_OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !OV_USER_CONTENT_ZONES.includes(z)))) return u.toString();
const local = /^(localhost|127\.0\.0\.1)$/;
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
} catch (e) { /* not a URL */ }
ddiff --git a/public/sso-fanout.html b/public/sso-fanout.html
iindex 8f8916f7..1fee9f35 100644
---- a/public/sso-fanout.html
++++ b/public/sso-fanout.html
@@@ -67,6 +67,7 @@ li.now .dot i{animation:spin 1s linear infinite}
// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone
// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.
const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];
++ const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is ours
function safeNext(raw) {
const s = String(raw == null ? '' : raw);
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
@@@ -76,7 +77,7 @@ li.now .dot i{animation:spin 1s linear infinite}
if (u.username || u.password) return '/';
if (u.origin === window.location.origin) return u.toString();
const h = u.hostname.toLowerCase();
-- if (u.protocol === 'https:' && OV_OWNED_ZONES.some(z => h === z || h.endsWith('.' + z))) return u.toString();
++ if (u.protocol === 'https:' && OV_OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !OV_USER_CONTENT_ZONES.includes(z)))) return u.toString();
const local = /^(localhost|127\.0\.0\.1)$/;
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
} catch (e) { /* not a URL */ }
ddiff --git a/server/auth/fedcm.js b/server/auth/fedcm.js
iindex 1232aa54..cc53ae19 100644
---- a/server/auth/fedcm.js
++++ b/server/auth/fedcm.js
@@@ -23,7 +23,7 @@
const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
--const { OWNED_ZONES } = require('./sso-owned');
++const { OWNED_ZONES, isTrustedHost } = require('./sso-owned');
const ASSERTION_TTL_S = 300;
const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED_ZONES.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');
@@@ -31,7 +31,7 @@ const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
function rpOrigin(raw, env = process.env) {
const o = String(raw || '').trim().replace(/\/$/, '');
-- if (ORIGIN_RE.test(o)) return o;
++ if (ORIGIN_RE.test(o) && isTrustedHost(new URL(o).hostname)) return o;
if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;
return null;
}
ddiff --git a/server/auth/sso-check.js b/server/auth/sso-check.js
iindex 82216f74..fc9a9a91 100644
---- a/server/auth/sso-check.js
++++ b/server/auth/sso-check.js
@@@ -15,14 +15,14 @@
// ═══════════════════════════════════════════════════════════════
// Only the zones OpenVibe owns — not openvibe.<anything>, which anyone could register.
--const { OWNED_ZONES: OWNED } = require('./sso-owned');
++const { OWNED_ZONES: OWNED, isTrustedHost } = require('./sso-owned');
const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');
const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
function allowedOrigin(raw, env = process.env) {
const o = String(raw || '').trim().replace(/\/$/, '');
if (!o) return null;
-- if (ORIGIN_RE.test(o)) return o;
++ if (ORIGIN_RE.test(o) && isTrustedHost(new URL(o).hostname)) return o;
if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;
return null;
}
ddiff --git a/server/auth/sso-owned.js b/server/auth/sso-owned.js
iindex 936ee629..6cb3851b 100644
---- a/server/auth/sso-owned.js
++++ b/server/auth/sso-owned.js
@@@ -7,6 +7,16 @@ const OWNED_ZONES = [
'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons',
'openre.stream',
];
++// Zones whose subdomains are people's own content (OpenVibe.Host tenant sites, <site>.openvibe.host,
++// which run the tenant's JS): only the apex is OpenVibe's. Domain validation still refuses them as
++// tool hosts through OWNED_ZONES; trust checks go through isTrustedHost.
++const USER_CONTENT_ZONES = ['openvibe.host'];
++
++/** Is `hostname` an OpenVibe site we trust across sites (an owned zone, minus tenant subdomains)? */
++function isTrustedHost(hostname) {
++ const h = String(hostname || '').toLowerCase();
++ return OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !USER_CONTENT_ZONES.includes(z)));
++}
/** Which RP origins an OAuth client may exchange FedCM assertions for. */
function clientOriginMatcher(client) {
@@@ -22,4 +32,4 @@ function clientOriginMatcher(client) {
};
}
--module.exports = { OWNED_ZONES, clientOriginMatcher };
++module.exports = { OWNED_ZONES, USER_CONTENT_ZONES, isTrustedHost, clientOriginMatcher };
ddiff --git a/server/auth/sso-targets.js b/server/auth/sso-targets.js
iindex 5ff52b38..3cd9fdf0 100644
---- a/server/auth/sso-targets.js
++++ b/server/auth/sso-targets.js
@@@ -14,7 +14,7 @@
// it points at https://openvibe.network/… .
// ═══════════════════════════════════════════════════════════════
--const { OWNED_ZONES } = require('./sso-owned');
++const { isTrustedHost } = require('./sso-owned');
const DEFAULT_TARGETS = [
{ id: 'live', name: 'OpenVibe.Live', origin: 'https://openvibe.live',
@@@ -59,8 +59,8 @@ function safeNext(raw, env = process.env) {
if (u.username || u.password) return '/';
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return '/';
const h = u.hostname.toLowerCase();
-- // Only zones OpenVibe owns, never openvibe.<any tld> (anyone can register those).
-- if (OWNED_ZONES.some(z => h === z || h.endsWith('.' + z)) || /^(localhost|127\.0\.0\.1)$/.test(h)) return u.toString();
++ // Only zones OpenVibe owns (never openvibe.<any tld>, never a Host tenant site).
++ if (isTrustedHost(h) || /^(localhost|127\.0\.0\.1)$/.test(h)) return u.toString();
for (const t of ssoTargets(env)) { try { if (t.origin && new URL(t.origin).hostname === h) return u.toString(); } catch { /* */ } }
} catch { /* not a URL */ }
return '/';
ddiff --git a/test/security-redirects.test.js b/test/security-redirects.test.js
iindex 0cf716f1..be448183 100644
---- a/test/security-redirects.test.js
++++ b/test/security-redirects.test.js
@@@ -5,8 +5,10 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
--const { OWNED_ZONES } = require('../server/auth/sso-owned');
++const { OWNED_ZONES, USER_CONTENT_ZONES } = require('../server/auth/sso-owned');
const { safeNext } = require('../server/auth/sso-targets');
++const { allowedOrigin } = require('../server/auth/sso-check');
++const { rpOrigin } = require('../server/auth/fedcm');
/** The page's navigation guard, evaluated as the browser would at `pageOrigin`. */
function pageGuard(file, name, pageOrigin) {
@@@ -15,7 +17,7 @@ function pageGuard(file, name, pageOrigin) {
assert.ok(m, `${file} has a safe-redirect block`);
const loc = new URL(pageOrigin);
// eslint-disable-next-line no-new-func
-- return new Function('window', 'location', `${m[1]}\nreturn { fn: ${name}, zones: OV_OWNED_ZONES };`)({ location: loc }, loc);
++ return new Function('window', 'location', `${m[1]}\nreturn { fn: ${name}, zones: OV_OWNED_ZONES, userZones: OV_USER_CONTENT_ZONES };`)({ location: loc }, loc);
}
const BAD = [
@@@ -24,6 +26,8 @@ const BAD = [
'//evil.example', '/\\evil.example', '/\t/evil.example', '\\\\evil.example',
'https://evil.example/', 'https://openvibe.network.evil.example/', 'https://evilopenvibe.network/',
'https://openvibe.lol/', 'https://login.openvibe.xyz/', 'http://openvibe.live/', 'https://user:[email protected]/',
++ // *.openvibe.host are tenant sites (OpenVibe.Host Stage B): people's own content, not ours
++ 'https://evil.openvibe.host/', 'https://a.b.openvibe.host/x',
];
const GOOD = [
['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
@@@ -31,11 +35,13 @@ const GOOD = [
['https://json.openvibe.tools/', 'https://json.openvibe.tools/'],
['https://openvibe.network/admin', 'https://openvibe.network/admin'],
['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],
++ ['https://openvibe.host/dashboard', 'https://openvibe.host/dashboard'],
];
for (const [file, name] of [['login.html', 'safeReturnUrl'], ['sso-fanout.html', 'safeNext']]) {
-- const { fn, zones } = pageGuard(file, name, 'https://openvibe.network');
++ const { fn, zones, userZones } = pageGuard(file, name, 'https://openvibe.network');
assert.deepStrictEqual([...zones].sort(), [...OWNED_ZONES].sort(), `${file}: owned zones match server/auth/sso-owned.js`);
++ assert.deepStrictEqual([...userZones].sort(), [...USER_CONTENT_ZONES].sort(), `${file}: user-content zones match server/auth/sso-owned.js`);
for (const b of BAD) assert.strictEqual(fn(b), '/', `${file}: ${JSON.stringify(b)} is refused`);
for (const [g, want] of GOOD) assert.strictEqual(fn(g), want, `${file}: ${g} is kept`);
assert.strictEqual(fn(''), '/');
@@@ -51,4 +57,15 @@ for (const [file, name] of [['login.html', 'safeReturnUrl'], ['sso-fanout.html',
for (const b of BAD) assert.strictEqual(safeNext(b, { NODE_ENV: 'production' }), '/', `safeNext ${JSON.stringify(b)} is refused`);
for (const [g, want] of GOOD) assert.strictEqual(safeNext(g, { NODE_ENV: 'production' }), want, `safeNext ${g} is kept`);
--console.log('post-sign-in redirects: all checks passed');
++// Cross-site trust (FedCM relying parties, the /sso/check frame) stops at tenant subdomains too:
++// a Host tenant page must not get a FedCM assertion or learn who is signed in.
++for (const check of [allowedOrigin, rpOrigin]) {
++ assert.strictEqual(check('https://evil.openvibe.host', { NODE_ENV: 'production' }), null, `${check.name}: tenant site refused`);
++ assert.strictEqual(check('https://A.B.openvibe.host', { NODE_ENV: 'production' }), null, `${check.name}: nested tenant site refused`);
++ assert.strictEqual(check('https://openvibe.host', { NODE_ENV: 'production' }), 'https://openvibe.host', `${check.name}: the Host dashboard is ours`);
++ assert.strictEqual(check('https://json.openvibe.tools', { NODE_ENV: 'production' }), 'https://json.openvibe.tools');
++ assert.strictEqual(check('https://openvibe.live', { NODE_ENV: 'production' }), 'https://openvibe.live');
++ assert.strictEqual(check('https://evilopenvibe.live', { NODE_ENV: 'production' }), null);
++}
++
++console.log('post-sign-in redirects and owned-zone trust: all checks passed');