openvibers/openvibe.network Commits

Sign-in redirects: /login?return= ran javascript: URLs; fanout next= left for any openvibe.<tld>

OP OpenVibers committed 23 Sept 2026 · 149717603c · 5 files, +104 −4

Exploits fixed:
- /login?return=javascript:… : after choosing an account (one click on the chooser), the page
  did window.location.href = return with no check, running attacker script on openvibe.network,
  where ov_token (not httpOnly) and every saved account token in localStorage live. Any off-site
  URL was an open redirect too.
- /sso/fanout?next= accepted '/\evil.example' and '/<tab>/evil.example' (browsers read both as
  //evil.example) and any host matching openvibe.<any tld> (openvibe.lol and the like can be
  registered by anyone): an open redirect off openvibe.network after sign-in or sign-out.

Both pages and the server-side safeNext now allow only a same-site path (no backslash or control
characters), this origin, or https on a zone OpenVibe owns (server/auth/sso-owned.js; the test
keeps the page copies in sync); plain-http localhost only from a localhost page.

Changes

ddiff --git a/package.json b/package.json
iindex 0b744a7f..b980b8aa 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/security-session.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/security-session.test.js && node test/security-redirects.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/public/login.html b/public/login.html
iindex 83be4a63..ea1161a8 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -729,9 +729,30 @@ function ripple(btn, e) {
setTimeout(() => r.remove(), 600);
}
++// safe-redirect:begin
++// 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'];
++function safeReturnUrl(raw) {
++ const s = String(raw == null ? '' : raw);
++ if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
++ if (s.startsWith('/') && !s.startsWith('//')) return s;
++ try {
++ const u = new URL(s);
++ 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();
++ 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 */ }
++ return '/';
++}
++// safe-redirect:end
++
// Get return URL (non-OAuth)
function getReturnUrl() {
-- return params.get('return') || '/';
++ return safeReturnUrl(params.get('return'));
}
// After any successful sign-in the browser runs the sign-in-everywhere chain (/sso/fanout):
ddiff --git a/public/sso-fanout.html b/public/sso-fanout.html
iindex 515ecf8b..8f8916f7 100644
---- a/public/sso-fanout.html
++++ b/public/sso-fanout.html
@@@ -63,7 +63,26 @@ li.now .dot i{animation:spin 1s linear infinite}
const doneIds = (q.get('done') || '').split(',').filter(Boolean);
const skippedIds = (q.get('skipped') || '').split(',').filter(Boolean);
const nextRaw = q.get('next') || '/';
-- const safeNext = (s) => { try { if (s.startsWith('/') && !s.startsWith('//')) return s; const u = new URL(s); if (u.protocol !== 'https:' && u.protocol !== 'http:') return '/'; return /(^|\.)(openvibe\.[a-z]+|openre\.stream)$/.test(u.hostname) || /^(localhost|127\.0\.0\.1)$/.test(u.hostname) ? u.toString() : '/'; } catch { return '/'; } };
++ // safe-redirect:begin
++ // 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'];
++ function safeNext(raw) {
++ const s = String(raw == null ? '' : raw);
++ if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
++ if (s.startsWith('/') && !s.startsWith('//')) return s;
++ try {
++ const u = new URL(s);
++ 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();
++ 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 */ }
++ return '/';
++ }
++ // safe-redirect:end
const next = safeNext(nextRaw);
document.getElementById('skip').href = next;
if (action === 'logout') { document.getElementById('h').textContent = 'Signing you out everywhere'; document.getElementById('p').textContent = 'Closing your session on every OpenVibe site.'; }
ddiff --git a/server/auth/sso-targets.js b/server/auth/sso-targets.js
iindex 961ff25c..5ff52b38 100644
---- a/server/auth/sso-targets.js
++++ b/server/auth/sso-targets.js
@@@ -14,6 +14,8 @@
// it points at https://openvibe.network/… .
// ═══════════════════════════════════════════════════════════════
++const { OWNED_ZONES } = require('./sso-owned');
++
const DEFAULT_TARGETS = [
{ id: 'live', name: 'OpenVibe.Live', origin: 'https://openvibe.live',
login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={next}',
@@@ -49,12 +51,16 @@ function ssoTargets(env = process.env) {
function safeNext(raw, env = process.env) {
const s = String(raw || '');
if (!s) return '/';
++ // Backslashes and control characters: browsers read '/\\evil' and '/\t/evil' as '//evil'.
++ if (/[\\\u0000-\u001f\u007f]/.test(s)) return '/';
if (s.startsWith('/') && !s.startsWith('//')) return s;
try {
const u = new URL(s);
++ 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();
-- if (/(^|\.)openvibe\.[a-z]+$/.test(h) || /(^|\.)openre\.stream$/.test(h) || /^(localhost|127\.0\.0\.1)$/.test(h)) return u.toString();
++ // 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();
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
nnew file mode 100644
iindex 00000000..0cf716f1
---- /dev/null
++++ b/test/security-redirects.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++// Post-sign-in navigation targets: /login?return=… and /sso/fanout?next=… must never run
++// javascript: URLs or leave for a host OpenVibe does not own.
++// node test/security-redirects.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { OWNED_ZONES } = require('../server/auth/sso-owned');
++const { safeNext } = require('../server/auth/sso-targets');
++
++/** The page's navigation guard, evaluated as the browser would at `pageOrigin`. */
++function pageGuard(file, name, pageOrigin) {
++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', file), 'utf8');
++ const m = html.match(/\/\/ safe-redirect:begin\n([\s\S]*?)\/\/ safe-redirect:end/);
++ 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);
++}
++
++const BAD = [
++ 'javascript:alert(document.cookie)', 'JavaScript:alert(1)', ' javascript:alert(1)', 'java\tscript:alert(1)',
++ 'data:text/html,<script>alert(1)</script>', 'vbscript:msgbox(1)',
++ '//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]/',
++];
++const GOOD = [
++ ['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
++ ['https://openvibe.live/@x?y=1', 'https://openvibe.live/@x?y=1'],
++ ['https://json.openvibe.tools/', 'https://json.openvibe.tools/'],
++ ['https://openvibe.network/admin', 'https://openvibe.network/admin'],
++ ['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],
++];
++
++for (const [file, name] of [['login.html', 'safeReturnUrl'], ['sso-fanout.html', 'safeNext']]) {
++ const { fn, zones } = pageGuard(file, name, 'https://openvibe.network');
++ assert.deepStrictEqual([...zones].sort(), [...OWNED_ZONES].sort(), `${file}: owned 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(''), '/');
++ assert.strictEqual(fn(null), '/');
++ // local development: the page's own origin is fine, other local ports are not an open door in production
++ const local = pageGuard(file, name, 'http://localhost:4000').fn;
++ assert.strictEqual(local('http://localhost:4000/my'), 'http://localhost:4000/my');
++ assert.strictEqual(local('http://localhost:3000/'), 'http://localhost:3000/', 'local dev hops between local ports');
++ assert.strictEqual(fn('http://localhost:3000/'), '/', 'but never from a production page');
++}
++
++// the server-side twin (server/auth/sso-targets.js)
++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');