openvibers/openvibe.codes Commits
Sign-out only from this site: a cross-site GET /auth/logout shows a confirm button instead of ending the session; POST /auth/logout needs a same-origin request
Changes
@@@ -206,7 +206,26 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat}});-- r.get('/logout', async (req, res) => {++ // Sign-out ends the session, so another site must not be able to trigger it (a cross-site++ // <img src=/auth/logout> or link). A same-origin navigation or form, or the address bar++ // (Sec-Fetch-Site none), signs out directly; anything else gets a confirm button (POST).++ const sameOriginRequest = (req) => {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');++ const origin = req.get('origin');++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;++ return req.method === 'GET'; // no fetch metadata and no Origin: an old browser navigating++ };++ r.get('/logout', (req, res, next) => {++ if (sameOriginRequest(req)) return next();++ res.set('Cache-Control', 'private, no-store');++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`++ + `<p>Sign out of OpenVibe.Codes?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ });++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));++ r.all('/logout', async (req, res) => {const rt = req.cookies && req.cookies[REFRESH_COOKIE];if (rt) {fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {@@@ -217,7 +236,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat}clearSession(res);res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });-- res.redirect(sanitizeNext(req.query.next));++ res.redirect(303, sanitizeNext(req.query.next));});r.get('/me', (req, res) => {@@@ -46,6 +46,19 @@ const { signJwt } = require('./helpers/mocks');assert.strictEqual(s.ctx.trust.get(APP).tier, 'reviewed');});++ await check('another site cannot sign a person out; this site can', async () => {++ const cross = await s.get('/auth/logout', { as: staff, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 200, 'a confirm page, not a sign-out');++ assert.ok(!/codes_at=;|codes_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')), 'the session cookie is kept');++ assert.match(cross.text, /method="post"/);++ const crossPost = await s.get('/auth/logout', { as: staff, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(crossPost.status, 403);++ const same = await s.get('/auth/logout?next=/projects', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/projects');++ assert.match(String(same.headers.get('set-cookie') || ''), /codes_at=;/);++ });++await s.close();done();})();
@@ -206,7 +206,26 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -206,7 +206,26 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}
}
});
});
- r.get('/logout', async (req, res) => {
+ // Sign-out ends the session, so another site must not be able to trigger it (a cross-site
+ // <img src=/auth/logout> or link). A same-origin navigation or form, or the address bar
+ // (Sec-Fetch-Site none), signs out directly; anything else gets a confirm button (POST).
+ const sameOriginRequest = (req) => {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
+ const origin = req.get('origin');
+ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
+ return req.method === 'GET'; // no fetch metadata and no Origin: an old browser navigating
+ };
+ r.get('/logout', (req, res, next) => {
+ if (sameOriginRequest(req)) return next();
+ res.set('Cache-Control', 'private, no-store');
+ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';
+ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ + `<p>Sign out of OpenVibe.Codes?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ });
+ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
+ r.all('/logout', async (req, res) => {
const rt = req.cookies && req.cookies[REFRESH_COOKIE];
const rt = req.cookies && req.cookies[REFRESH_COOKIE];
if (rt) {
if (rt) {
fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
@@ -217,7 +236,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -217,7 +236,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}
}
clearSession(res);
clearSession(res);
res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
- res.redirect(sanitizeNext(req.query.next));
+ res.redirect(303, sanitizeNext(req.query.next));
});
});
r.get('/me', (req, res) => {
r.get('/me', (req, res) => {
@@ -46,6 +46,19 @@ const { signJwt } = require('./helpers/mocks');
@@ -46,6 +46,19 @@ const { signJwt } = require('./helpers/mocks');
assert.strictEqual(s.ctx.trust.get(APP).tier, 'reviewed');
assert.strictEqual(s.ctx.trust.get(APP).tier, 'reviewed');
});
});
+ await check('another site cannot sign a person out; this site can', async () => {
+ const cross = await s.get('/auth/logout', { as: staff, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 200, 'a confirm page, not a sign-out');
+ assert.ok(!/codes_at=;|codes_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')), 'the session cookie is kept');
+ assert.match(cross.text, /method="post"/);
+ const crossPost = await s.get('/auth/logout', { as: staff, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(crossPost.status, 403);
+ const same = await s.get('/auth/logout?next=/projects', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/projects');
+ assert.match(String(same.headers.get('set-cookie') || ''), /codes_at=;/);
+ });
+
await s.close();
await s.close();
done();
done();
})();
})();