openvibers/openvibe.live Commits

Open redirect fixed (roadmap WS-R task 5): Live's sign-in next let "/<TAB>/evil.com" and "/\evil.com" through (browsers read both as "//evil.com") and trusted openvibe.<any tld>, which anyone can register. safeNext moves to server/auth/safe-next.js: control characters and backslashes go home, only the zones OpenVibe owns (Network's sso-owned list; never a Host tenant subdomain), no credentials in the URL. test/open-redirect.test.js

OP OpenVibers committed 26 Sept 2026 · c4e1761752 · 3 files, +58 −11


Changes

ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 36d02e3c..191130dc 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -289,17 +289,7 @@ function getNetworkRedirectUri() {
* through /api/auth/sso/login?silent=1&next=https://openvibe.network/sso/fanout?… and must
* be handed back. Anything else falls back to the home page.
*/
--function safeNext(raw) {
-- const s = String(raw || '');
-- if (!s) return '/';
-- if (s.startsWith('/') && !s.startsWith('//')) return s;
-- try {
-- const u = new URL(s);
-- if (u.protocol !== 'https:' && !(u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return '/';
-- if (/(^|\.)openvibe\.[a-z]+$/.test(u.hostname) || /(^|\.)openre\.stream$/.test(u.hostname) || /^(localhost|127\.0\.0\.1)$/.test(u.hostname)) return u.toString();
-- } catch { /* not a URL */ }
-- return '/';
--}
++const { safeNext } = require('./safe-next');
const HINT_COOKIE = { httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000, sameSite: 'Lax' };
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
ddiff --git a/server/auth/safe-next.js b/server/auth/safe-next.js
nnew file mode 100644
iindex 00000000..65ec8446
---- /dev/null
++++ b/server/auth/safe-next.js
@@@ -0,0 +1,39 @@
++'use strict';
++/**
++ * Where a sign-in may send the person back to (the `next` of /api/auth/login): a same-site path, or an
++ * https URL on a zone OpenVibe owns (the list OpenVibe.Network keeps in server/auth/sso-owned.js).
++ *
++ * Never openvibe.<any tld>, which anyone can register, and never a Host tenant site (<site>.openvibe.host,
++ * people's own pages). Browsers drop tab and newline characters from a URL and read a backslash as "/",
++ * so "/<TAB>/evil.com" and "/\evil.com" are "//evil.com" to them: any control character or backslash
++ * goes home. Until 2026-09-26 both got through (WS-R task 5).
++ */
++const 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 USER_CONTENT_ZONES = ['openvibe.host'];
++
++function ownedHost(hostname) {
++ const h = String(hostname || '').toLowerCase();
++ return OWNED_ZONES.some((z) => h === z || (h.endsWith(`.${z}`) && !USER_CONTENT_ZONES.includes(z)));
++}
++
++function safeNext(raw) {
++ const s = String(raw || '');
++ if (!s) return '/';
++ 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 '/';
++ const local = /^(localhost|127\.0\.0\.1)$/.test(u.hostname);
++ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return '/';
++ if (ownedHost(u.hostname) || local) return u.toString();
++ } catch { /* not a URL */ }
++ return '/';
++}
++
++module.exports = { safeNext, ownedHost, OWNED_ZONES };
ddiff --git a/test/open-redirect.test.js b/test/open-redirect.test.js
nnew file mode 100644
iindex 00000000..aa79ab3e
---- /dev/null
++++ b/test/open-redirect.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++// Open redirect regression (WS-R task 5): the sign-in `next` never leaves OpenVibe's own zones. Until
++// 2026-09-26 "/<TAB>/evil.com" and "/\evil.com" (both "//evil.com" to a browser) and openvibe.<any tld>
++// got through.
++const assert = require('assert');
++const { safeNext } = require('../server/auth/safe-next');
++
++for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/\n/evil.com', '\t//evil.com',
++ '/\\/evil.com', 'https:/\\evil.com', '///evil.com', 'javascript:alert(1)', 'https://openvibe.xyz/', 'https://evil.openvibe.co/',
++ 'https://tenant.openvibe.host/', 'https://user:[email protected]/', 'http://openvibe.live/', 'https://openvibe.live.evil.com/']) {
++ assert.strictEqual(safeNext(v), '/', `${JSON.stringify(v)} goes home`);
++}
++for (const [v, want] of [['/@someone?tab=vods', '/@someone?tab=vods'], ['https://openvibe.network/sso/fanout?x=1', 'https://openvibe.network/sso/fanout?x=1'],
++ ['https://www.openvibe.live/a', 'https://www.openvibe.live/a'], ['https://openvibe.host/', 'https://openvibe.host/'], ['https://openre.stream/x', 'https://openre.stream/x'],
++ ['http://localhost:3000/', 'http://localhost:3000/']]) {
++ assert.strictEqual(safeNext(v), want, `${JSON.stringify(v)} is kept`);
++}
++console.log('open redirect: all checks passed');