openvibers/openvibe.codes Commits

Session cookie accepts only Network session tokens (audience openvibe.network, not FedCM/app tokens)

OP OpenVibers committed 23 Sept 2026 · 76b1234d2f · 4 files, +69 −2

Exploit: codes_at was verified by issuer and signature only. The Network signs FedCM
assertions (aud = the requesting origin, any https://*.openvibe.* page incl. future
*.openvibe.host tenant sites) with the same key, carrying subject_id and username. A page
that obtains a Codes staff member's assertion could put it in its own codes_at cookie and
act as that person in Codes, e.g. set trust tiers at /staff/trust (Codes-local, no Network
call). App/service tokens were likewise treated as session candidates.

Fix: the session (and the refreshed token) must carry audience openvibe.network
(OV_SESSION_AUDIENCE) and must not be a FedCM assertion or an app/service token.

Changes

ddiff --git a/.env.example b/.env.example
iindex 9d9c2ed..138c209 100644
---- a/.env.example
++++ b/.env.example
@@@ -14,6 +14,7 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_OAUTH_CLIENT_ID=codes
OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.codes/auth/callback
++# OV_SESSION_AUDIENCE=openvibe.network # audience a Network session token must carry to be a Codes session
# Signs the per-person form token (CSRF). Unset: a random per-process key (forms expire on restart).
CODES_FORM_SECRET=
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 4b3b336..8a8ae94 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -85,6 +85,18 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
return data;
}
++ /**
++ * A Codes session is a Network SESSION token: audience openvibe.network and a person. The Network
++ * signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and
++ * internal tokens with the same key and issuer; none of them may become a session here.
++ */
++ function verifySession(token) {
++ const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
++ const c = v.claims;
++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
++ return v;
++ }
++
function viewerFromClaims(claims, token) {
const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
const role = typeof claims.role === 'string' ? claims.role : 'user';
@@@ -112,7 +124,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
try {
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
setSession(res, data);
-- const v = verifyJwt(data.access_token, { publicKey: keys.get(), issuer: config.networkIssuer, now: now() });
++ const v = verifySession(data.access_token);
return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
} catch (err) {
if (err.status && err.status < 500) clearSession(res);
@@@ -127,7 +139,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
const token = req.cookies && req.cookies[ACCESS_COOKIE];
if (!token) return next();
await keys.ensure();
-- const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, now: now() });
++ const v = verifySession(token);
if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
if (v.expired) {
const fresh = await refresh(req, res);
ddiff --git a/server/config.js b/server/config.js
iindex e8e96c4..93c3d40 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -47,6 +47,9 @@ function load(env = process.env) {
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
scope: 'profile',
++ // Network session tokens (what /oauth/token hands Codes) carry this audience; FedCM
++ // assertions, app and service tokens signed with the same key do not.
++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
// Signs the per-person form token (CSRF). Unset: a random per-process key.
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
nnew file mode 100644
iindex 0000000..e37eb38
---- /dev/null
++++ b/test/security-session.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * The codes_at session cookie must hold a Network SESSION token (audience openvibe.network, a
++ * person), not just anything the Network signed. The Network signs FedCM assertions (audience =
++ * the relying party's origin, any https://*.openvibe.* origin), app/service tokens and internal
++ * tokens with the same key and issuer; none of them is a Codes session.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { signJwt } = require('./helpers/mocks');
++
++(async () => {
++ const s = await boot();
++ const staff = s.network.addUser('stafferson');
++ s.ctx.config.staffSubjects.push(staff.subject); // as CODES_STAFF_SUBJECTS would
++ const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';
++
++ const now = () => Math.floor(Date.now() / 1000);
++ // What POST /fedcm/assertion hands to ANY page on an owned zone the person continues on.
++ const fedcm = () => signJwt({
++ iss: s.network.url, sub: staff.id, id: staff.id, subject_id: staff.subject, username: staff.username, display_name: staff.username,
++ nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now(), exp: now() + 300,
++ }, s.network.privatePem);
++
++ await check('a FedCM assertion is not a Codes session (cannot act as staff)', async () => {
++ const cookie = `codes_at=${fedcm()}`;
++ const page = await s.get('/staff', { cookie });
++ assert.notStrictEqual(page.status, 200, 'the staff console must not open for an assertion');
++ const body = new URLSearchParams({ app_id: APP, tier: 'first-party', note: 'pwned', csrf: s.csrf(staff) }).toString();
++ const r = await s.get('/staff/trust', { cookie, body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });
++ assert.notStrictEqual(r.status, 303, 'the trust tier must not be set');
++ assert.strictEqual(s.ctx.trust.get(APP).tier, 'unreviewed');
++ });
++
++ await check('an app token is not a Codes session', async () => {
++ const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);
++ const me = await s.get('/auth/me', { cookie: `codes_at=${token}` });
++ assert.strictEqual(me.status, 401);
++ });
++
++ await check('a real Network session token still signs the staff member in', async () => {
++ const page = await s.get('/staff', { as: staff });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
++ const r = await s.get('/staff/trust', { as: staff, form: { app_id: APP, tier: 'reviewed', note: 'checked' } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(s.ctx.trust.get(APP).tier, 'reviewed');
++ });
++
++ await s.close();
++ done();
++})();