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

OP OpenVibers committed 23 Sept 2026 · 3160659a99 · 2 files, +34 −2


Changes

ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 8a8ae94..90782f3 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -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) => {
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex e37eb38..b46c466 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -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();
})();