openvibers/openrestream Commits
A quiet sign-in returns to the page it started on (an absolute next on our own origin is its path), and sign-in/out clear the browser's copy of the site (#93)
Changes
@@@ -20,9 +20,19 @@ const HINT = 'ov_sso_hint';const STATE = 'ov_oauth_state';const NEXT = 'ov_oauth_next';--function sanitizeNext(next) {++/**++ * A same-site path, or '/'. An absolute URL on this site's own origin (BASE_URL) counts as its path: the shared navbar's++ * silent sign-in sends the whole page address, and until 2026-10-22 that landed on the home page.++ */++function sanitizeNext(next, config) {// Browsers drop tabs and newlines and read \ as /, so "/<TAB>/evil.com" or "/\\evil.com" would leave the site.if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {++ let u = null, own = null;++ try { u = new URL(next); own = config && config.baseUrl ? new URL(config.baseUrl).origin : null; } catch { return '/'; }++ if (!own || u.origin !== own) return '/';++ next = `${u.pathname}${u.search}${u.hash}`;++ }if (typeof next === 'string' && /^\/(?!\/)/.test(next)) return next;return '/';}@@@ -66,13 +76,13 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {router.get('/login', (req, res) => {const { url, state } = client.getAuthorizationUrl(config.oauth.scope);res.cookie(STATE, state, flow);-- res.cookie(NEXT, sanitizeNext(req.query.next), flow);++ res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);res.redirect(url);});router.get('/callback', async (req, res) => {const { code, state, error } = req.query;-- const next = sanitizeNext(req.cookies && req.cookies[NEXT]);++ const next = sanitizeNext(req.cookies && req.cookies[NEXT], config);const expected = req.cookies && req.cookies[STATE];res.clearCookie(STATE, { path: '/auth' });res.clearCookie(NEXT, { path: '/auth' });@@@ -84,6 +94,8 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {try {const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });setSession(res, data.access_token, data.refresh_token);++ // Whatever this browser kept of the site was drawn for a guest: drop it.++ res.set('Clear-Site-Data', '"cache"');return res.redirect(next);} catch (err) {return res.status(502).type('text/plain').send(`Sign-in failed: ${err.message}`);@@@ -94,7 +106,8 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {res.clearCookie(ACCESS, { path: '/' });res.clearCookie(REFRESH, { path: '/auth' });res.cookie(HINT, 'guest', { ...base, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });-- res.redirect(sanitizeNext(req.query.next));++ res.set('Clear-Site-Data', '"cache"');++ res.redirect(sanitizeNext(req.query.next, config));});router.get('/me', async (req, res) => {@@@ -34,5 +34,12 @@ const { sanitizeNext } = require('../server/auth/sso');for (const bad of ['/\t/evil.com', '/\n/evil.com', '/\\evil.com', '/a\\b', '//evil.com', 'https://evil.com', '', null]) {assert.strictEqual(sanitizeNext(bad), '/', `${JSON.stringify(bad)} stays on the site`);}++ // The shared navbar's silent sign-in sends the whole page address: on this site's own origin it is that page's path.++ const cfg = { baseUrl: 'https://openre.stream' };++ assert.strictEqual(sanitizeNext('https://openre.stream/streams/std_1?x=1', cfg), '/streams/std_1?x=1');++ for (const bad of ['https://evil.com/a', 'https://openre.stream.evil.com/a', 'https://openre.stream//evil.com', 'http://openre.stream/a']) {++ assert.strictEqual(sanitizeNext(bad, cfg), '/', bad);++ }++ assert.strictEqual(sanitizeNext('https://openre.stream/a'), '/', 'with no origin known, an absolute URL goes home');console.log('launch hardening: outputs and tests pinned to the checked address; next stays on the site');})().catch((err) => { console.error(err); process.exit(1); });
@@ -20,9 +20,19 @@ const HINT = 'ov_sso_hint';
@@ -20,9 +20,19 @@ const HINT = 'ov_sso_hint';
const STATE = 'ov_oauth_state';
const STATE = 'ov_oauth_state';
const NEXT = 'ov_oauth_next';
const NEXT = 'ov_oauth_next';
-function sanitizeNext(next) {
+/**
+ * A same-site path, or '/'. An absolute URL on this site's own origin (BASE_URL) counts as its path: the shared navbar's
+ * silent sign-in sends the whole page address, and until 2026-10-22 that landed on the home page.
+ */
+function sanitizeNext(next, config) {
// Browsers drop tabs and newlines and read \ as /, so "/<TAB>/evil.com" or "/\\evil.com" would leave the site.
// Browsers drop tabs and newlines and read \ as /, so "/<TAB>/evil.com" or "/\\evil.com" would leave the site.
if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
+ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
+ let u = null, own = null;
+ try { u = new URL(next); own = config && config.baseUrl ? new URL(config.baseUrl).origin : null; } catch { return '/'; }
+ if (!own || u.origin !== own) return '/';
+ next = `${u.pathname}${u.search}${u.hash}`;
+ }
if (typeof next === 'string' && /^\/(?!\/)/.test(next)) return next;
if (typeof next === 'string' && /^\/(?!\/)/.test(next)) return next;
return '/';
return '/';
}
}
@@ -66,13 +76,13 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
@@ -66,13 +76,13 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
router.get('/login', (req, res) => {
router.get('/login', (req, res) => {
const { url, state } = client.getAuthorizationUrl(config.oauth.scope);
const { url, state } = client.getAuthorizationUrl(config.oauth.scope);
res.cookie(STATE, state, flow);
res.cookie(STATE, state, flow);
- res.cookie(NEXT, sanitizeNext(req.query.next), flow);
+ res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);
res.redirect(url);
res.redirect(url);
});
});
router.get('/callback', async (req, res) => {
router.get('/callback', async (req, res) => {
const { code, state, error } = req.query;
const { code, state, error } = req.query;
- const next = sanitizeNext(req.cookies && req.cookies[NEXT]);
+ const next = sanitizeNext(req.cookies && req.cookies[NEXT], config);
const expected = req.cookies && req.cookies[STATE];
const expected = req.cookies && req.cookies[STATE];
res.clearCookie(STATE, { path: '/auth' });
res.clearCookie(STATE, { path: '/auth' });
res.clearCookie(NEXT, { path: '/auth' });
res.clearCookie(NEXT, { path: '/auth' });
@@ -84,6 +94,8 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
@@ -84,6 +94,8 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
try {
try {
const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
+ // Whatever this browser kept of the site was drawn for a guest: drop it.
+ res.set('Clear-Site-Data', '"cache"');
return res.redirect(next);
return res.redirect(next);
} catch (err) {
} catch (err) {
return res.status(502).type('text/plain').send(`Sign-in failed: ${err.message}`);
return res.status(502).type('text/plain').send(`Sign-in failed: ${err.message}`);
@@ -94,7 +106,8 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
@@ -94,7 +106,8 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
res.clearCookie(ACCESS, { path: '/' });
res.clearCookie(ACCESS, { path: '/' });
res.clearCookie(REFRESH, { path: '/auth' });
res.clearCookie(REFRESH, { path: '/auth' });
res.cookie(HINT, 'guest', { ...base, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
res.cookie(HINT, 'guest', { ...base, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
- res.redirect(sanitizeNext(req.query.next));
+ res.set('Clear-Site-Data', '"cache"');
+ res.redirect(sanitizeNext(req.query.next, config));
});
});
router.get('/me', async (req, res) => {
router.get('/me', async (req, res) => {
@@ -34,5 +34,12 @@ const { sanitizeNext } = require('../server/auth/sso');
@@ -34,5 +34,12 @@ const { sanitizeNext } = require('../server/auth/sso');
for (const bad of ['/\t/evil.com', '/\n/evil.com', '/\\evil.com', '/a\\b', '//evil.com', 'https://evil.com', '', null]) {
for (const bad of ['/\t/evil.com', '/\n/evil.com', '/\\evil.com', '/a\\b', '//evil.com', 'https://evil.com', '', null]) {
assert.strictEqual(sanitizeNext(bad), '/', `${JSON.stringify(bad)} stays on the site`);
assert.strictEqual(sanitizeNext(bad), '/', `${JSON.stringify(bad)} stays on the site`);
}
}
+ // The shared navbar's silent sign-in sends the whole page address: on this site's own origin it is that page's path.
+ const cfg = { baseUrl: 'https://openre.stream' };
+ assert.strictEqual(sanitizeNext('https://openre.stream/streams/std_1?x=1', cfg), '/streams/std_1?x=1');
+ for (const bad of ['https://evil.com/a', 'https://openre.stream.evil.com/a', 'https://openre.stream//evil.com', 'http://openre.stream/a']) {
+ assert.strictEqual(sanitizeNext(bad, cfg), '/', bad);
+ }
+ assert.strictEqual(sanitizeNext('https://openre.stream/a'), '/', 'with no origin known, an absolute URL goes home');
console.log('launch hardening: outputs and tests pinned to the checked address; next stays on the site');
console.log('launch hardening: outputs and tests pinned to the checked address; next stays on the site');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });