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
Changes
@@@ -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) ───────────nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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');
@@ -289,17 +289,7 @@ function getNetworkRedirectUri() {
@@ -289,17 +289,7 @@ function getNetworkRedirectUri() {
* through /api/auth/sso/login?silent=1&next=https://openvibe.network/sso/fanout?… and must
* 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.
* 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' };
const HINT_COOKIE = { httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000, sameSite: 'Lax' };
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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');