import { xml } from '@xmpp/client'; import assert from 'assert'; import { mintToken } from './helpers/jwt.js'; import { createXmppClient } from './helpers/xmpp_client.js'; const CONFERENCE = 'conference-identity.localhost'; let _roomCounter = 0; const room = () => `presence-identity-${++_roomCounter}@${CONFERENCE}`; /** * Connects to the hs256.localhost VirtualHost with a signed token. * mod_presence_identity is loaded on this VirtualHost. * * @param {string} roomJid full room JID, e.g. 'room@conference-identity.localhost' * @param {object} [tokenPayload] JWT payload overrides (e.g. { context: { user: ... } }). * @returns {Promise} */ function hs256Client(roomJid, tokenPayload = {}) { const roomName = roomJid.split('@')[0]; const token = mintToken({ room: roomName, ...tokenPayload }); return createXmppClient({ domain: 'hs256.localhost', params: { token } }); } /** * Returns the first presence stanza in the observer's queue that comes from * the given room and is NOT the observer's own self-presence. * * @param {XmppTestClient} observer * @param {string} roomJid * @param {string} observerNick */ function waitForOtherPresence(observer, roomJid, observerNick) { return observer.waitForPresence( p => p.attrs.from?.startsWith(`${roomJid}/`) && p.attrs.from !== `${roomJid}/${observerNick}` ); } describe('mod_presence_identity', () => { const clients = []; afterEach(async () => { await Promise.all(clients.map(c => c.disconnect())); clients.length = 0; }); // ------------------------------------------------------------------------- // identity injection // ------------------------------------------------------------------------- describe('identity injection', () => { it('injects from JWT context.user into MUC presence', async () => { const r = room(); const identity = await hs256Client(r, { context: { user: { id: 'u1', name: 'Alice' } } }); clients.push(identity); await identity.joinRoom(r); const observer = await createXmppClient(); clients.push(observer); await observer.joinRoom(r); const presence = await waitForOtherPresence(observer, r, observer.nick); const identityEl = presence.getChild('identity'); assert.ok(identityEl, 'presence must contain '); const userEl = identityEl.getChild('user'); assert.ok(userEl, ' must contain '); assert.strictEqual(userEl.getChild('id')?.text(), 'u1', 'id must match token'); assert.strictEqual(userEl.getChild('name')?.text(), 'Alice', 'name must match token'); }); it('adds inside when context.group is set', async () => { const r = room(); const identity = await hs256Client(r, { context: { user: { id: 'u2' }, group: 'mygroup' } }); clients.push(identity); await identity.joinRoom(r); const observer = await createXmppClient(); clients.push(observer); await observer.joinRoom(r); const presence = await waitForOtherPresence(observer, r, observer.nick); const identityEl = presence.getChild('identity'); assert.ok(identityEl, 'presence must contain '); assert.strictEqual(identityEl.getChild('group')?.text(), 'mygroup', ' must match token context.group'); }); it('does not inject when token has no context.user', async () => { const r = room(); // Valid token but no context field → no jitsi_meet_context_user on session. const noContext = await hs256Client(r); clients.push(noContext); await noContext.joinRoom(r); const observer = await createXmppClient(); clients.push(observer); await observer.joinRoom(r); const presence = await waitForOtherPresence(observer, r, observer.nick); assert.ok(!presence.getChild('identity'), 'presence must not contain when token has no context.user'); }); it('injects in presence updates (not only on join)', async () => { const r = room(); const observer = await createXmppClient(); clients.push(observer); await observer.joinRoom(r); const identity = await hs256Client(r, { context: { user: { id: 'u3', name: 'Bob' } } }); clients.push(identity); await identity.joinRoom(r); // Consume the join presence. const joinPresence = await waitForOtherPresence(observer, r, observer.nick); assert.ok(joinPresence.getChild('identity'), 'join presence must have '); // Send a presence update (no element — this is a regular update, // not a join). Both pre-presence/bare and pre-presence/full hooks fire. const identityNick = joinPresence.attrs.from.split('/')[1]; await identity.sendPresence(`${r}/${identityNick}`); // The update must also carry . const updatePresence = await observer.waitForPresence( p => p.attrs.from === `${r}/${identityNick}` ); assert.ok(updatePresence.getChild('identity'), 'presence update must also carry '); }); }); // ------------------------------------------------------------------------- // malformed JWT context.user keys // ------------------------------------------------------------------------- // // update_presence_identity() iterates over JWT context.user keys and uses // each key as an XML element name: presence:tag(key):text(tostring(value)). // Fixed in 2e8980d2b: keys that do not match the XML name pattern // ^[%a_][%w%-%.%:_]*$ are now silently skipped before reaching tag(). // This test verifies that bad keys are dropped and valid keys are preserved. describe('malformed JWT context.user keys', () => { it('survives context.user keys that contain XML-special characters', async () => { const r = room(); // Key names with '<' and '>' — these become XML element names. // Prosody / LuaXML may escape, drop, or crash on them. const identity = await hs256Client(r, { context: { user: { id: 'safe-id', 'bad': 'value' } } }); clients.push(identity); await identity.joinRoom(r); const observer = await createXmppClient(); clients.push(observer); await observer.joinRoom(r); // If Prosody crashed the hook the join presence would never arrive. const presence = await waitForOtherPresence(observer, r, observer.nick); assert.ok(presence, 'join presence must arrive even with unusual key names'); // The safe 'id' element must be present and correct. const identityEl = presence.getChild('identity'); assert.ok(identityEl, 'presence must still contain '); assert.strictEqual( identityEl.getChild('user')?.getChild('id') ?.text(), 'safe-id', 'safe id field must be present and unmodified' ); }); }); // ------------------------------------------------------------------------- // spoofing prevention // ------------------------------------------------------------------------- describe('spoofing prevention', () => { it('strips a client-supplied when session has no context.user', async () => { const r = room(); // Token with no context → no jitsi_meet_context_user on session. // The client manually crafts an element in the join presence. const intruder = await hs256Client(r); clients.push(intruder); await intruder.joinRoom(r, undefined, { extensions: [ xml('identity', {}, xml('user', {}, xml('id', {}, 'hacker'), xml('name', {}, 'Evil Corp') ) ) ] }); const observer = await createXmppClient(); clients.push(observer); await observer.joinRoom(r); const presence = await waitForOtherPresence(observer, r, observer.nick); assert.ok(!presence.getChild('identity'), 'module must strip the hand-crafted when no context.user is set'); }); it('strips a client-supplied and replaces it with the real one', async () => { const r = room(); // Token with real context.user but also a crafted in the stanza. // The module must strip the crafted one and inject the real one from the session. const identity = await hs256Client(r, { context: { user: { id: 'real-id', name: 'Real User' } } }); clients.push(identity); await identity.joinRoom(r, undefined, { extensions: [ xml('identity', {}, xml('user', {}, xml('id', {}, 'spoofed-id'), xml('name', {}, 'Spoofed Name') ) ) ] }); const observer = await createXmppClient(); clients.push(observer); await observer.joinRoom(r); const presence = await waitForOtherPresence(observer, r, observer.nick); const identityEl = presence.getChild('identity'); assert.ok(identityEl, 'presence must contain '); assert.strictEqual(identityEl.getChild('user')?.getChild('id') ?.text(), 'real-id', 'id must be from the verified JWT context, not the spoofed stanza'); assert.strictEqual(identityEl.getChild('user')?.getChild('name') ?.text(), 'Real User', 'name must be from the verified JWT context, not the spoofed stanza'); }); }); });