openvibers/openvibe.network Commits
Sign-in redirects: /login?return= ran javascript: URLs; fanout next= left for any openvibe.<tld>
Changes
@@@ -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"},@@@ -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):@@@ -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:endconst 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.'; }@@@ -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 '/';nnew file mode 100644@@@ -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');
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -729,9 +729,30 @@ function ripple(btn, e) {
@@ -729,9 +729,30 @@ function ripple(btn, e) {
setTimeout(() => r.remove(), 600);
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)
// Get return URL (non-OAuth)
function getReturnUrl() {
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):
// After any successful sign-in the browser runs the sign-in-everywhere chain (/sso/fanout):
@@ -63,7 +63,26 @@ li.now .dot i{animation:spin 1s linear infinite}
@@ -63,7 +63,26 @@ li.now .dot i{animation:spin 1s linear infinite}
const doneIds = (q.get('done') || '').split(',').filter(Boolean);
const doneIds = (q.get('done') || '').split(',').filter(Boolean);
const skippedIds = (q.get('skipped') || '').split(',').filter(Boolean);
const skippedIds = (q.get('skipped') || '').split(',').filter(Boolean);
const nextRaw = q.get('next') || '/';
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);
const next = safeNext(nextRaw);
document.getElementById('skip').href = next;
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.'; }
if (action === 'logout') { document.getElementById('h').textContent = 'Signing you out everywhere'; document.getElementById('p').textContent = 'Closing your session on every OpenVibe site.'; }
@@ -14,6 +14,8 @@
@@ -14,6 +14,8 @@
// it points at https://openvibe.network/… .
// it points at https://openvibe.network/… .
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
+const { OWNED_ZONES } = 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',
login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={next}',
login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={next}',
@@ -49,12 +51,16 @@ function ssoTargets(env = process.env) {
@@ -49,12 +51,16 @@ function ssoTargets(env = process.env) {
function safeNext(raw, env = process.env) {
function safeNext(raw, env = process.env) {
const s = String(raw || '');
const s = String(raw || '');
if (!s) return '/';
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;
if (s.startsWith('/') && !s.startsWith('//')) return s;
try {
try {
const u = new URL(s);
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 '/';
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();
- 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 { /* */ } }
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 '/';
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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');