openvibers/openvibe.network Commits
Cross-site trust stops at Host tenant sites: *.openvibe.host gets no FedCM assertion, /sso/check answer or redirect
Changes
@@@ -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 oursfunction 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 */ }@@@ -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 oursfunction 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 */ }@@@ -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;}@@@ -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;}@@@ -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 };@@@ -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 '/';@@@ -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');
@@ -733,6 +733,7 @@ function ripple(btn, e) {
@@ -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
// 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.
// 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_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) {
function safeReturnUrl(raw) {
const s = String(raw == null ? '' : raw);
const s = String(raw == null ? '' : raw);
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
@@ -742,7 +743,7 @@ function safeReturnUrl(raw) {
@@ -742,7 +743,7 @@ function safeReturnUrl(raw) {
if (u.username || u.password) return '/';
if (u.username || u.password) return '/';
if (u.origin === window.location.origin) return u.toString();
if (u.origin === window.location.origin) return u.toString();
const h = u.hostname.toLowerCase();
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)$/;
const local = /^(localhost|127\.0\.0\.1)$/;
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
} catch (e) { /* not a URL */ }
} catch (e) { /* not a URL */ }
@@ -67,6 +67,7 @@ li.now .dot i{animation:spin 1s linear infinite}
@@ -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
// 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.
// 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_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) {
function safeNext(raw) {
const s = String(raw == null ? '' : raw);
const s = String(raw == null ? '' : raw);
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
@@ -76,7 +77,7 @@ li.now .dot i{animation:spin 1s linear infinite}
@@ -76,7 +77,7 @@ li.now .dot i{animation:spin 1s linear infinite}
if (u.username || u.password) return '/';
if (u.username || u.password) return '/';
if (u.origin === window.location.origin) return u.toString();
if (u.origin === window.location.origin) return u.toString();
const h = u.hostname.toLowerCase();
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)$/;
const local = /^(localhost|127\.0\.0\.1)$/;
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
if (u.protocol === 'http:' && local.test(h) && local.test(window.location.hostname)) return u.toString();
} catch (e) { /* not a URL */ }
} catch (e) { /* not a URL */ }
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
-const { OWNED_ZONES } = require('./sso-owned');
+const { OWNED_ZONES, isTrustedHost } = require('./sso-owned');
const ASSERTION_TTL_S = 300;
const ASSERTION_TTL_S = 300;
const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED_ZONES.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');
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+)?$/;
@@ -31,7 +31,7 @@ const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
function rpOrigin(raw, env = process.env) {
function rpOrigin(raw, env = process.env) {
const o = String(raw || '').trim().replace(/\/$/, '');
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;
if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;
return null;
return null;
}
}
@@ -15,14 +15,14 @@
@@ -15,14 +15,14 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Only the zones OpenVibe owns — not openvibe.<anything>, which anyone could register.
// 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 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+)?$/;
const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
function allowedOrigin(raw, env = process.env) {
function allowedOrigin(raw, env = process.env) {
const o = String(raw || '').trim().replace(/\/$/, '');
const o = String(raw || '').trim().replace(/\/$/, '');
if (!o) return null;
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;
if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;
return null;
return null;
}
}
@@ -7,6 +7,16 @@ const OWNED_ZONES = [
@@ -7,6 +7,16 @@ const OWNED_ZONES = [
'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons',
'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons',
'openre.stream',
'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. */
/** Which RP origins an OAuth client may exchange FedCM assertions for. */
function clientOriginMatcher(client) {
function clientOriginMatcher(client) {
@@ -22,4 +32,4 @@ function clientOriginMatcher(client) {
@@ -22,4 +32,4 @@ function clientOriginMatcher(client) {
};
};
}
}
-module.exports = { OWNED_ZONES, clientOriginMatcher };
+module.exports = { OWNED_ZONES, USER_CONTENT_ZONES, isTrustedHost, clientOriginMatcher };
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
// it points at https://openvibe.network/… .
// it points at https://openvibe.network/… .
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-const { OWNED_ZONES } = require('./sso-owned');
+const { isTrustedHost } = require('./sso-owned');
const DEFAULT_TARGETS = [
const DEFAULT_TARGETS = [
{ id: 'live', name: 'OpenVibe.Live', origin: 'https://openvibe.live',
{ id: 'live', name: 'OpenVibe.Live', origin: 'https://openvibe.live',
@@ -59,8 +59,8 @@ function safeNext(raw, env = process.env) {
@@ -59,8 +59,8 @@ function safeNext(raw, env = process.env) {
if (u.username || u.password) return '/';
if (u.username || u.password) return '/';
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return '/';
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return '/';
const h = u.hostname.toLowerCase();
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 { /* */ } }
for (const t of ssoTargets(env)) { try { if (t.origin && new URL(t.origin).hostname === h) return u.toString(); } catch { /* */ } }
} catch { /* not a URL */ }
} catch { /* not a URL */ }
return '/';
return '/';
@@ -5,8 +5,10 @@
@@ -5,8 +5,10 @@
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
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 { 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`. */
/** The page's navigation guard, evaluated as the browser would at `pageOrigin`. */
function pageGuard(file, name, pageOrigin) {
function pageGuard(file, name, pageOrigin) {
@@ -15,7 +17,7 @@ function pageGuard(file, name, pageOrigin) {
@@ -15,7 +17,7 @@ function pageGuard(file, name, pageOrigin) {
assert.ok(m, `${file} has a safe-redirect block`);
assert.ok(m, `${file} has a safe-redirect block`);
const loc = new URL(pageOrigin);
const loc = new URL(pageOrigin);
// eslint-disable-next-line no-new-func
// 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 = [
const BAD = [
@@ -24,6 +26,8 @@ const BAD = [
@@ -24,6 +26,8 @@ const BAD = [
'//evil.example', '/\\evil.example', '/\t/evil.example', '\\\\evil.example',
'//evil.example', '/\\evil.example', '/\t/evil.example', '\\\\evil.example',
'https://evil.example/', 'https://openvibe.network.evil.example/', 'https://evilopenvibe.network/',
'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]/',
'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 = [
const GOOD = [
['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
@@ -31,11 +35,13 @@ const GOOD = [
@@ -31,11 +35,13 @@ const GOOD = [
['https://json.openvibe.tools/', 'https://json.openvibe.tools/'],
['https://json.openvibe.tools/', 'https://json.openvibe.tools/'],
['https://openvibe.network/admin', 'https://openvibe.network/admin'],
['https://openvibe.network/admin', 'https://openvibe.network/admin'],
['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],
['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']]) {
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([...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 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`);
for (const [g, want] of GOOD) assert.strictEqual(fn(g), want, `${file}: ${g} is kept`);
assert.strictEqual(fn(''), '/');
assert.strictEqual(fn(''), '/');
@@ -51,4 +57,15 @@ for (const [file, name] of [['login.html', 'safeReturnUrl'], ['sso-fanout.html',
@@ -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 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`);
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');