A WebSocket gateway that fans Invision Community events out to browsers usually authenticates the connection — a short-lived token, signed with a shared secret, proves the socket came from a page your community served. That is enough while every channel is public, like a forum topic. The moment a channel carries something private — a conversation between two members — it is not enough, because the token says who the member is but not which channels they are allowed to join. Nothing stops a logged-in member from editing the client and subscribing to pm.999, a conversation they are not part of.
Sign the channel into the token, not just the member
The fix is to make the granted channels part of what the signature covers. When the server renders a page, it already knows what that page is allowed to listen to — so it signs those channels into the token:
// PHP, when injecting the client on a page the member may listen to
$exp = time() + 3600;
sort( $channels ); // deterministic, so both sides rebuild the same string
$csv = implode( ',', $channels );
$sig = hash_hmac( 'sha256', $memberId . ':' . $exp . ':' . $csv, $secret );
The gateway recomputes the same HMAC from the member id, the expiry, and the channel list the client presents, and rejects the connection if they do not match. Because the channels are inside the signature, a member cannot add one: change the channel list and the signature no longer verifies.
// gateway (Node), on auth
const csv = Array.isArray(channels) ? [...channels].sort().join(',') : '';
const expected = createHmac('sha256', RT_SECRET).update(`${memberId}:${exp}:${csv}`).digest('hex');
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return reject();
ws.grants = new Set(channels); // the private channels this socket may join
Then enforce the grant on subscribe
Public channels still need no grant — a forum topic carries nothing private. Only the private ones are gated, against the grant the verified token established:
if (/^(topic|forum)\.\d+$/.test(c)) join(c); // public
else if (/^pm\.\d+$/.test(c) && ws.grants.has(c)) join(c); // private: only if signed in
So privacy rests on two things at once: the signature (a member cannot forge a grant) and the enforcement (the gateway refuses a private channel that was not granted). The server only ever signs a private channel after it has checked, in PHP, that the member is genuinely a participant.
Changing the signed payload is a breaking change for the verifier
The signature is a contract between two programs. The day you add the channel list to it, every token the server mints changes shape, and a gateway still running the old code will reject all of them — the connection simply fails and realtime goes quiet. It fails safe: pages keep working, only the live updates stop. But it means the gateway has to be redeployed in step with the application whenever the token payload changes. Ship the two together, and say so where an administrator will read it.
Related application: Realtime — Realtime uses exactly this pattern to make private conversations live and typing indicators work over WebSockets, with each conversation's channel signed into the connection token so only its participants can join.
Recommended Comments