openvibers/openvibe.network Commits
SSO check: ov_sso never authenticates API requests (CSRF), origin allow-list is the owned zones only
Changes
@@@ -56,7 +56,10 @@ function clearSessionCookies(res) {res.clearCookie('ov_token', { path: '/', sameSite: 'Lax', secure: true });res.clearCookie('ov_sso', { path: '/', sameSite: 'None', secure: true, httpOnly: true });}--/** The session token a request carries: Bearer header, then the page cookie, then the cross-site one. */++/**++ * The session token a request carries: Bearer header, then the page cookie, then the cross-site++ * one. Only for endpoints that change nothing (/sso/check) — see makeRequireAuth for why.++ */function requestToken(req) {const h = req.headers?.authorization;if (h && h.startsWith('Bearer ')) return h.slice(7);@@@ -66,7 +69,10 @@ function requestToken(req) {function makeRequireAuth(getCtx, signToken) {return function requireAuth(req, res, next) {const authHeader = req.headers.authorization;-- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : (req.cookies?.ov_token || req.cookies?.ov_sso);++ // Never ov_sso here: it is SameSite=None, so a hostile page could make the browser send it++ // on a cross-site POST. Only the read-only /sso/check and the prompt=none authorize++ // (which yields a code bound to a registered redirect_uri) may consult it.++ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;const ctx = getCtx(req);const out = verifySession(token, ctx);if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });@@@ -14,7 +14,9 @@// Browsers that partition third-party cookies see no ov_sso cookie and get signedIn: false.// ═══════════════════════════════════════════════════════════════--const ORIGIN_RE = /^https:\/\/(?:[a-z0-9-]+\.)*(?:openvibe\.[a-z]+|openre\.stream)$/i;++// Only the zones OpenVibe owns — not openvibe.<anything>, which anyone could register.++const OWNED = ['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 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) {@@@ -14,6 +14,8 @@ assert.strictEqual(allowedOrigin('https://play.openvibe.games'), 'https://play.oassert.strictEqual(allowedOrigin('https://ingest.openre.stream'), 'https://ingest.openre.stream');assert.strictEqual(allowedOrigin('https://openvibe.tools.evil.com'), null, 'suffix spoof rejected');assert.strictEqual(allowedOrigin('https://evilopenvibe.tools'), null);++assert.strictEqual(allowedOrigin('https://openvibe.xyz'), null, 'a TLD we do not own is not ours');++assert.strictEqual(allowedOrigin('https://x.openvibe.deals'), 'https://x.openvibe.deals');assert.strictEqual(allowedOrigin('http://openvibe.tools'), null, 'plain http rejected');assert.strictEqual(allowedOrigin('https://openvibe.tools/path'), null, 'origins only');assert.strictEqual(allowedOrigin('http://localhost:4301', { NODE_ENV: 'development' }), 'http://localhost:4301');
@@ -56,7 +56,10 @@ function clearSessionCookies(res) {
@@ -56,7 +56,10 @@ function clearSessionCookies(res) {
res.clearCookie('ov_token', { path: '/', sameSite: 'Lax', secure: true });
res.clearCookie('ov_token', { path: '/', sameSite: 'Lax', secure: true });
res.clearCookie('ov_sso', { path: '/', sameSite: 'None', secure: true, httpOnly: true });
res.clearCookie('ov_sso', { path: '/', sameSite: 'None', secure: true, httpOnly: true });
}
}
-/** The session token a request carries: Bearer header, then the page cookie, then the cross-site one. */
+/**
+ * The session token a request carries: Bearer header, then the page cookie, then the cross-site
+ * one. Only for endpoints that change nothing (/sso/check) — see makeRequireAuth for why.
+ */
function requestToken(req) {
function requestToken(req) {
const h = req.headers?.authorization;
const h = req.headers?.authorization;
if (h && h.startsWith('Bearer ')) return h.slice(7);
if (h && h.startsWith('Bearer ')) return h.slice(7);
@@ -66,7 +69,10 @@ function requestToken(req) {
@@ -66,7 +69,10 @@ function requestToken(req) {
function makeRequireAuth(getCtx, signToken) {
function makeRequireAuth(getCtx, signToken) {
return function requireAuth(req, res, next) {
return function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
const authHeader = req.headers.authorization;
- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : (req.cookies?.ov_token || req.cookies?.ov_sso);
+ // Never ov_sso here: it is SameSite=None, so a hostile page could make the browser send it
+ // on a cross-site POST. Only the read-only /sso/check and the prompt=none authorize
+ // (which yields a code bound to a registered redirect_uri) may consult it.
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
const ctx = getCtx(req);
const ctx = getCtx(req);
const out = verifySession(token, ctx);
const out = verifySession(token, ctx);
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
@@ -14,7 +14,9 @@
@@ -14,7 +14,9 @@
// Browsers that partition third-party cookies see no ov_sso cookie and get signedIn: false.
// Browsers that partition third-party cookies see no ov_sso cookie and get signedIn: false.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-const ORIGIN_RE = /^https:\/\/(?:[a-z0-9-]+\.)*(?:openvibe\.[a-z]+|openre\.stream)$/i;
+// Only the zones OpenVibe owns — not openvibe.<anything>, which anyone could register.
+const OWNED = ['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 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) {
@@ -14,6 +14,8 @@ assert.strictEqual(allowedOrigin('https://play.openvibe.games'), 'https://play.o
@@ -14,6 +14,8 @@ assert.strictEqual(allowedOrigin('https://play.openvibe.games'), 'https://play.o
assert.strictEqual(allowedOrigin('https://ingest.openre.stream'), 'https://ingest.openre.stream');
assert.strictEqual(allowedOrigin('https://ingest.openre.stream'), 'https://ingest.openre.stream');
assert.strictEqual(allowedOrigin('https://openvibe.tools.evil.com'), null, 'suffix spoof rejected');
assert.strictEqual(allowedOrigin('https://openvibe.tools.evil.com'), null, 'suffix spoof rejected');
assert.strictEqual(allowedOrigin('https://evilopenvibe.tools'), null);
assert.strictEqual(allowedOrigin('https://evilopenvibe.tools'), null);
+assert.strictEqual(allowedOrigin('https://openvibe.xyz'), null, 'a TLD we do not own is not ours');
+assert.strictEqual(allowedOrigin('https://x.openvibe.deals'), 'https://x.openvibe.deals');
assert.strictEqual(allowedOrigin('http://openvibe.tools'), null, 'plain http rejected');
assert.strictEqual(allowedOrigin('http://openvibe.tools'), null, 'plain http rejected');
assert.strictEqual(allowedOrigin('https://openvibe.tools/path'), null, 'origins only');
assert.strictEqual(allowedOrigin('https://openvibe.tools/path'), null, 'origins only');
assert.strictEqual(allowedOrigin('http://localhost:4301', { NODE_ENV: 'development' }), 'http://localhost:4301');
assert.strictEqual(allowedOrigin('http://localhost:4301', { NODE_ENV: 'development' }), 'http://localhost:4301');