openvibers/openvibe.chat Commits
Count cookie-reliant sockets and have clients present their token
Changes
@@@ -527,6 +527,11 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:Live; WebSocket upgrades use the session cookie or Authorization bearer (Live's legacy `token`cookie is ignored there); `?token=` on `/ws/chat` is deprecated (C-05), counted in`chat_ws_url_token_uses`, and stays until Live's bot guide and call client stop sending it.++ The browser clients present the token in the first message of every socket (`join`,++ `join_room`, the call's `auth-update`); J7 removes the cookie branch from `extractWsToken` once++ `chat_ws_cookie_reliant{via="jwt"|"api_token"}` (cookie-authenticated sockets whose first++ handled auth message lacked a valid token, or call sockets silent past the token grace period)++ stays 0 for one full release after that ships (`chat_ws_cookie_uses` only counts the cookie seen).Services use client-credentials tokens for audience`openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,`chat.presence.read`). Staff gates ask the contracts staff map.@@@ -31,8 +31,13 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L> session cookie or an `Authorization` bearer, and Live's legacy `token` cookie is ignored there. The> deprecated `?token=` query param is still honoured for Live's bot guide and call client, counted in> `chat_ws_url_token_uses`. C-06 — dropping the `ov_token` cookie fallback so upgrades authenticate by--> bearer only — is gated on `chat_ws_cookie_uses` reading 0 for a full release together with the--> browser client sending its token in the join message.++> bearer only — is gated on `chat_ws_cookie_reliant`: the browser clients now send their token in the++> first message of every socket (`join`, `join_room`, the call's `auth-update`), and a socket the++> cookie signed in whose first handled auth message has no valid token counts there, once; call++> sockets silent past the token grace period count too. J7 removes the cookie++> branch from `extractWsToken` once `chat_ws_cookie_reliant` stays 0 for one full release after this++> ships. `chat_ws_cookie_uses` is only the raw "cookie seen" series: browsers send the cookie on every++> same-origin upgrade, so it never reads 0.Stay on Live (do not route): `/api/chat-ai/*` (the trailing slash in `/api/chat/` keeps it out),`/api/emotes/*`, `/api/channels/*` (channel moderation dashboard), `/api/mod/*`, `/api/media/*`,@@@ -294,4 +299,3 @@ written on PostgreSQL after the switch to Chat's own tables (rooms, calls, the astate) are not in the file — export them from PostgreSQL first if the rollback comes after real traffic.Keep the SQLite file read-only for the rollback window; do not delete it until the PostgreSQL deploy hasserved a full release.--@@@ -21,6 +21,11 @@function status(text) { if (statusEl) statusEl.textContent = text || ''; }function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }++ // The session token (the ov_token cookie is JS-readable), presented in the socket's first message.++ function sessionToken() {++ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');++ return m ? decodeURIComponent(m[1]) : null;++ }function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }function render() {@@@ -173,6 +178,7 @@Promise.all([ice, mic]).then(function () {if (mine !== attempt) return;ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(cfg.call.channel));++ ws.onopen = function () { var t = sessionToken(); if (t) send({ type: 'auth-update', token: t }); };ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };var sock = ws;ws.onclose = function () { if (sock === ws && mine === attempt) teardown(joined ? 'The call was disconnected. Join again to reconnect.' : 'Could not reach the call.'); };@@@ -11,6 +11,11 @@var latest = Number(cfg.latest) || 0;var LIVE = 'https://openvibe.live';++ // The session token (the ov_token cookie is JS-readable), presented in each socket's first message.++ function sessionToken() {++ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');++ return m ? decodeURIComponent(m[1]) : undefined;++ }function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }@@@ -88,7 +93,7 @@ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');ws.onopen = function () {delay = 1000;-- ws.send(JSON.stringify({ type: 'join' }));++ ws.send(JSON.stringify({ type: 'join', token: sessionToken() }));if (live) live.hidden = false;if (opened) catchUp();opened = true;@@@ -197,7 +202,7 @@}function connect() {ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');-- ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: cfg.room })); if (opened) catchUpRoom(); opened = true; };++ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: cfg.room, token: sessionToken() })); if (opened) catchUpRoom(); opened = true; };ws.onmessage = function (ev) {var m; try { m = JSON.parse(ev.data); } catch (e) { return; }if (m.type === 'room_joined') { if (live) live.hidden = false; }@@@ -84,10 +84,15 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe// Deprecated ?token= on /ws/chat (C-05), by kind, since start: when both stay at 0, the shim goes.metrics.registry.gauge({ name: 'chat_ws_url_token_uses', help: 'WebSocket upgrades that carried the token in the URL (deprecated), by token kind', labelNames: ['kind'],collect: () => Object.entries(require('./auth/auth').urlTokenUses()).map(([kind, n]) => ({ labels: { kind }, value: n })) });-- // /ws/chat upgrades that authenticated from the ov_token cookie (the C-06 removal gate), by kind,-- // since start: the browser's cookie fallback goes once both stay at 0 for a release.++ // /ws/chat upgrades that authenticated from the ov_token cookie, by kind, since start: the raw++ // "cookie seen" series. Browsers send the cookie on every same-origin upgrade, so it never reads 0.metrics.registry.gauge({ name: 'chat_ws_cookie_uses', help: '/ws/chat upgrades whose ov_token cookie resolved to an account, by token kind', labelNames: ['kind'],collect: () => Object.entries(require('./auth/auth').wsCookieUses()).map(([kind, n]) => ({ labels: { kind }, value: n })) });++ // The C-06 removal gate (J7): /ws/chat and /ws/call sockets the cookie authenticated whose first++ // accepted auth message did not present a valid token, by via, since start. J7 removes the cookie branch++ // from extractWsToken once both stay at 0 for one full release.++ metrics.registry.gauge({ name: 'chat_ws_cookie_reliant', help: 'WebSockets that relied on the ov_token cookie, by token kind', labelNames: ['via'],++ collect: () => Object.entries(require('./auth/auth').wsCookieReliant()).map(([via, n]) => ({ labels: { via }, value: n })) });/** Exact allow-list only (no subdomain wildcard). */function isAllowedOrigin(origin) {@@@ -83,10 +83,14 @@ function extractToken(req, opts) {* 'ov_token_cookie' or null)*/const urlTokenUses = { jwt: 0, api_token: 0 };--// /ws/chat upgrades that authenticated from the Network `ov_token` cookie (the C-06 removal gate),--// by token kind, counted once the token resolved to an account (countWsCookieUse): when both stay--// at 0 for a release the cookie fallback can be deleted.++// /ws/chat upgrades authenticated from the Network `ov_token` cookie. This is the raw++// "cookie seen" series; browsers send the cookie on every same-origin upgrade, so it cannot gate J7.const wsCookieUses = { jwt: 0, api_token: 0 };++// /ws/chat and /ws/call sockets that the ov_token cookie authenticated and that did not present the++// token themselves in an accepted auth message (wsCookieReliance), by token kind: J7 removes the cookie++// branch from extractWsToken once both stay at 0 for one full release.++const wsCookieReliant = { jwt: 0, api_token: 0 };++const tokenKind = (token) => (token.startsWith('hbt_') ? 'api_token' : 'jwt');function extractWsTokenFrom(req) {// DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message// and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and@@@ -110,7 +114,22 @@ function extractWsToken(req) {/** Count a /ws/chat upgrade that the ov_token cookie authenticated (call once the user resolved). */function countWsCookieUse(token) {-- wsCookieUses[token.startsWith('hbt_') ? 'api_token' : 'jwt']++;++ wsCookieUses[tokenKind(token)]++;++}++++/**++ * Track one socket whose upgrade the ov_token cookie authenticated (call once the user resolved).++ * Returns settle(accepted), called for the first auth message this endpoint handles, or with no++ * argument after a bounded grace period for a silent call socket. A rejected, missing, or mismatched++ * token leaves the socket dependent on its cookie. Later calls do nothing.++ */++function wsCookieReliance(token) {++ let settled = false;++ return (accepted = false) => {++ if (settled) return;++ settled = true;++ if (!accepted) wsCookieReliant[tokenKind(token)]++;++ };}/**@@@ -215,10 +234,12 @@ module.exports = {extractWsToken,extractWsTokenFrom,countWsCookieUse,++ wsCookieReliance,authenticateWs,requireAuth,optionalAuth,requireAdmin,urlTokenUses: () => ({ ...urlTokenUses }),wsCookieUses: () => ({ ...wsCookieUses }),++ wsCookieReliant: () => ({ ...wsCookieReliant }),};@@@ -42,7 +42,7 @@const WebSocket = require('ws');const config = require('../config');const ctx = require('../live-context');--const { extractWsToken, authenticateWs } = require('../auth/auth');++const { extractWsTokenFrom, wsCookieReliance, authenticateWs } = require('../auth/auth');const permissions = require('../auth/permissions');const chatServer = require('../chat/chat-server');const lifecycle = require('./lifecycle');@@@ -53,6 +53,7 @@ const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userIconst WS_HEARTBEAT_MS = 30000;const MAX_PARTICIPANTS = 8;const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never one host filling it++const FIRST_TOKEN_WAIT_MS = 1000; // how long a refused anonymous socket waits for its auth-updateconst KICK_COOLDOWN_MS = 60 * 1000;const PUBLIC_CHANNEL_ID = 'public';const MODES = ['mic', 'mic+cam', 'cam+mic'];@@@ -298,7 +299,7 @@ class CallServer {async _handleConnection(ws, req) {const url = new URL(req.url, 'http://localhost');const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');-- const token = extractWsToken(req);++ const { token, from: tokenFrom } = extractWsTokenFrom(req);// CF-Connecting-IP / X-Forwarded-For only when nginx's peer is Cloudflare (net/client-ip.js), as on /ws/chat.const ip = chatServer.getClientIp(req);@@@ -330,11 +331,12 @@ class CallServer {const user = await authenticateWs(token).catch(() => null);if (!user) await chatServer._resolveUnifiedAnonNum(chatServer.normalizeIp(ip)).catch(() => {});await ctx.warm({ user, streamId: first && first.streamId ? first.streamId : null, ip }).catch(() => {});-- ws.off('message', onEarlyMessage);ws.off('close', onEarlyClose);ws.off('error', onEarlyClose);if (closedEarly || ws.readyState !== WebSocket.OPEN) return;-- await this._admit(ws, { resolvedId, ip, user, stream, early });++ // Messages keep buffering until _admit attaches the socket's handler: none falls in between.++ await this._admit(ws, { resolvedId, ip, user, stream, early, stopBuffering: () => ws.off('message', onEarlyMessage),++ cookieReliance: user && tokenFrom === 'ov_token_cookie' ? wsCookieReliance(token) : null });})().catch((err) => {console.warn('[Call] connection setup failed:', err.message);try { ws.close(1011, 'setup failed'); } catch {}@@@ -342,7 +344,7 @@ class CallServer {}/** The rest of Live's _handleConnection, once the socket's identity is known. */-- async _admit(ws, { resolvedId, ip, user, stream, early }) {++ async _admit(ws, { resolvedId, ip, user, stream, early, stopBuffering, cookieReliance = null }) {// The channel may have gone while the socket signed in.const channel = this.channels.get(resolvedId);if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }@@@ -353,16 +355,24 @@ class CallServer {if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }}-- if (channel.private && !await this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }---- // A call room: the room's roles decide who is in and who talks.-- let roomAccess = null;-- if (channel.roomSlug) {-- const room = await this._roomOf(resolvedId);-- roomAccess = room ? await rooms.access(room, user) : null;-- if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }-- if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }++ // Admission counts the token the client presents in its first message (auth-update) too: once++ // J7 drops the cookie fallback a browser's upgrade is anonymous, and a private call or a++ // members-only room must still let it in. A socket refused while anonymous waits briefly for++ // that message.++ user = await this._presentedUser(early, user);++ let admission = await this._admission(channel, resolvedId, user);++ if (admission.error && !user) {++ // Admission itself may have awaited Live while auth-update arrived in the buffer.++ user = await this._presentedUser(early, user);++ if (!user) {++ await this._firstAuthMessage(ws, FIRST_TOKEN_WAIT_MS);++ if (ws.readyState !== WebSocket.OPEN) return;++ user = await this._presentedUser(early, user);++ }++ if (user) admission = await this._admission(channel, resolvedId, user);}++ if (admission.error) { ws.send(JSON.stringify({ type: 'error', message: admission.error })); ws.close(); return; }++ const roomAccess = admission.roomAccess;const listenOnly = !!(roomAccess && !roomAccess.talk);const peerId = this._generatePeerId();@@@ -449,21 +459,76 @@ class CallServer {// One message at a time per socket, in arrival order (auth-update resolves a token first).let queue = Promise.resolve();++ const relianceTimer = cookieReliance ? setTimeout(() => {++ // Give an auth-update already queued for processing a chance to resolve first.++ queue.finally(() => cookieReliance());++ }, FIRST_TOKEN_WAIT_MS) : null;++ if (relianceTimer) ws.once('close', () => { clearTimeout(relianceTimer); queue.finally(() => cookieReliance()); });const onMessage = (data) => {// Rate limit: 200/s. A newcomer to a full room sends seven offers and their candidates in// a burst; the old 50/s silently dropped some of them and the join stalled.const now = Date.now();if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }if (++clientInfo._msgCount > 200) return;-- queue = queue.then(async () => await this._handleMessage(ws, JSON.parse(data), resolvedId, peerId))++ let msg;++ try { msg = JSON.parse(data); } catch (err) { console.warn('[Call] Message error for peer', peerId, ':', err.message); return; }++ queue = queue.then(async () => {++ const accepted = await this._handleMessage(ws, msg, resolvedId, peerId);++ if (cookieReliance && msg.type === 'auth-update') cookieReliance(accepted);++ }).catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });};++ stopBuffering();ws.on('message', onMessage);ws.on('close', async () => await this._handleDisconnect(ws, resolvedId, peerId));ws.on('error', async () => await this._handleDisconnect(ws, resolvedId, peerId));for (const data of early) onMessage(data);}++ /** Whether `user` may enter `channel`: { error } or { roomAccess } (null outside call rooms). */++ async _admission(channel, channelId, user) {++ if (channel.private && !await this._canSeePrivate(channel, user)) return { error: 'This is a private call' };++ // A call room: the room's roles decide who is in and who talks.++ if (!channel.roomSlug) return { roomAccess: null };++ const room = await this._roomOf(channelId);++ const roomAccess = room ? await rooms.access(room, user) : null;++ if (!roomAccess || !roomAccess.read) return { error: 'Voice channel not found' };++ if (!roomAccess.join) return { error: 'You cannot join this call' };++ return { roomAccess };++ }++++ /**++ * The account of the token in the socket's first message when that is an auth-update carrying++ * one, else `user`. As auth-update: an invalid token changes nothing, and it never replaces the++ * upgrade's account with another.++ */++ async _presentedUser(early, user) {++ let first = null;++ for (const data of early) {++ let msg;++ try { msg = JSON.parse(data); } catch { continue; }++ if (msg?.type === 'auth-update') { first = msg; break; }++ }++ if (!first || typeof first.token !== 'string' || !first.token.trim()) return user;++ const next = await authenticateWs(first.token).catch(() => null);++ if (!next || (user && user.id !== next.id)) return user;++ await ctx.ensureDecor([next.id]).catch(() => {});++ return next;++ }++++ /** Resolves on the next auth-update (buffered by then), close, or timeout. */++ _firstAuthMessage(ws, ms) {++ return new Promise((resolve) => {++ const done = () => { clearTimeout(timer); ws.off('message', onMessage); ws.off('close', done); resolve(); };++ const onMessage = (data) => {++ try { if (JSON.parse(data)?.type === 'auth-update') done(); } catch { /* wait */ }++ };++ const timer = setTimeout(done, ms);++ ws.on('message', onMessage);++ ws.on('close', done);++ });++ }++async _canModerate(user, channelId) {if (!user) return false;if (permissions.can(user, 'staff.moderation.calls')) return true;@@@ -514,6 +579,7 @@ class CallServer {case 'auth-update': {const c = room.get(peerId); if (!c) break;let user = c.user || null;++ let acceptedToken = false;if (typeof msg.token === 'string' && msg.token.trim()) {const nextUser = await authenticateWs(msg.token).catch(() => null);if (!nextUser) {@@@ -522,6 +588,7 @@ class CallServer {console.warn(`[Call] auth-update identity mismatch for peer ${peerId}: keeping existing user ${user.username}, ignoring ${nextUser.username}`);} else {user = nextUser;++ acceptedToken = true;}if (user) await ctx.ensureDecor([user.id]).catch(() => {});// The socket may have left while the token was resolved.@@@ -550,7 +617,7 @@ class CallServer {}));const u = JSON.stringify({ type: 'peer-updated', ...pInfo });for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(u); }-- break;++ return acceptedToken;}case 'force-mute': {const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;@@@ -22,7 +22,7 @@ const fs = require('fs');const crypto = require('crypto');const db = require('../db/database');const ctx = require('../live-context');--const { extractWsTokenFrom, countWsCookieUse, authenticateWs } = require('../auth/auth');++const { extractWsTokenFrom, countWsCookieUse, wsCookieReliance, authenticateWs } = require('../auth/auth');const session = require('../auth/network-session');const { clientIpOf } = require('../net/client-ip');const permissions = require('../auth/permissions');@@@ -296,7 +296,8 @@ class ChatServer {ws.off('close', onEarlyClose);ws.off('error', onEarlyClose);if (closedEarly || ws.readyState !== WebSocket.OPEN) return;-- this._registerConnection(ws, req, { ip, streamId, user, early, tokenIat: user ? session.tokenIat(token) : null });++ this._registerConnection(ws, req, { ip, streamId, user, early, tokenIat: user ? session.tokenIat(token) : null,++ cookieReliance: user && tokenFrom === 'ov_token_cookie' ? wsCookieReliance(token) : null });})().catch((err) => {console.warn('[Chat] connection setup failed:', err.message);try { ws.close(1011, 'setup failed'); } catch {}@@@ -304,7 +305,7 @@ class ChatServer {}/** The rest of Live's handleConnection, once the socket's identity is known. */-- _registerConnection(ws, req, { ip, streamId, user, early, tokenIat = null }) {++ _registerConnection(ws, req, { ip, streamId, user, early, tokenIat = null, cookieReliance = null }) {const perIp = this._ipSockets.get(ip) || 0;if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {ws.close(4029, 'Too many connections');@@@ -359,7 +360,7 @@ class ChatServer {console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);return;}-- chain = chain.then(async () => await this.handleMessage(ws, msg)).catch((err) => {++ chain = chain.then(async () => await this.handleMessage(ws, msg, cookieReliance)).catch((err) => {console.warn('[Chat] message handling failed:', err.message);});};@@@ -386,23 +387,24 @@ class ChatServer {* the socket already carries is ignored: a socket cannot change identity mid-session.*/async _applyJoinToken(client, msg) {-- if (!msg.token) return;++ if (!msg.token) return false;const user = await authenticateWs(msg.token).catch(() => null);-- if (!user) return;++ if (!user) return false;if (client.user && client.user.id !== user.id) {console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);-- return;++ return false;}client.user = user;await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user listclient.tokenIat = session.tokenIat(msg.token);client.anonId = null; // no longer anonymous++ return true;}/*** Handle incoming chat message*/-- async handleMessage(ws, msg) {++ async handleMessage(ws, msg, cookieReliance = null) {const client = this.clients.get(ws);if (!client) return;@@@ -437,7 +439,8 @@ class ChatServer {case 'join':case 'join_stream': {// (Re-)authenticate if a token is provided-- await this._applyJoinToken(client, msg);++ const acceptedToken = await this._applyJoinToken(client, msg);++ if (cookieReliance) cookieReliance(acceptedToken);const oldStream = client.streamId;const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;@@@ -520,7 +523,8 @@ class ChatServer {case 'join_room': {// A room socket can still arrive anonymous or as a guest: authenticate a token the// same way as join, so room access is resolved with the member's identity.-- await this._applyJoinToken(client, msg);++ const acceptedToken = await this._applyJoinToken(client, msg);++ if (cookieReliance) cookieReliance(acceptedToken);const rooms = require('../rooms/rooms');const room = await rooms.bySlug(msg.room);const a = room ? await rooms.access(room, client.user) : null;@@@ -276,6 +276,8 @@ async function hydrateReplies(messages) {}// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──++// The browser posts over /ws/chat and presents its token in the join; J7 removes the cookie branch++// from extractWsToken once chat_ws_cookie_reliant{via} stays 0 for one full release after that shipped.// Every line reaches every global chat socket: a bot posts at most one every 3 s (20 a minute, 300// an hour), refusals included; the word and spam filters below still decide each line.router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {@@@ -18,11 +18,11 @@ let h, config, callServer, lifecycle;let ann, bob, cat, dan, eve, kay, staff, streamer, liveStream, offStream;/** Open /ws/call with a bearer (through nginx: the address in CF-Connecting-IP). */--function callWs({ channelId, token = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {++function callWs({ channelId, token = null, cookie = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {return new Promise((resolve, reject) => {const qs = new URLSearchParams();if (channelId != null) qs.set('channelId', channelId);-- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin, ...(token ? { authorization: `Bearer ${token}` } : {}) } });++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin, ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookie ? { cookie } : {}) } });const all = [];const waiters = [];ws.on('message', (d) => {@@@ -182,21 +182,74 @@ t('signalling: offer / answer / ice-candidate relayed between two signed-in sockawait bye(a2, b);});--t('auth-update: an anonymous socket signs in (sent before its welcome, handled after, in order)', async () => {++t('auth-update: sent before its welcome, the token admits the socket as that account (then handled, in order)', async () => {const other = await join({ channelId: 'public', token: bob.token, ip: '198.51.100.2' });const s = await callWs({ channelId: 'public', ip: '198.51.100.4', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: cat.token }) });const w = await s.next((m) => m.type === 'welcome');-- assert.ok(w.participants.find((x) => x.peerId === w.peerId).anonId, 'joined as anon');++ assert.deepStrictEqual([w.participants.find((x) => x.peerId === w.peerId).username, w.participants.find((x) => x.peerId === w.peerId).anonId], ['cat', null], 'joined as cat');const self = await s.next((m) => m.type === 'self-updated');assert.deepStrictEqual([self.participant.username, self.participant.userId, self.participant.anonId, self.canModerate], ['cat', cat.id, null, false]);-- const upd = await other.next((m) => m.type === 'peer-updated');-- assert.deepStrictEqual([upd.peerId, upd.username], [w.peerId, 'cat']);++ const joined = await other.next((m) => m.type === 'peer-joined');++ assert.deepStrictEqual([joined.peerId, joined.username], [w.peerId, 'cat']);s.sendJson({ type: 'auth-update', token: dan.token });const again = await s.next((m) => m.type === 'self-updated');assert.strictEqual(again.participant.username, 'cat', 'a different account cannot take over the socket');await bye(s, other);});++t('a cookie call socket is cookie-reliant unless its first message is auth-update with the token (the J7 gate)', async () => {++ const auth = require('../server/auth/auth');++ const before = auth.wsCookieReliant();++ // The page's client: auth-update with the token first. Not counted.++ const s = await callWs({ channelId: 'public', cookie: `ov_token=${cat.token}`, ip: '198.51.100.5', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: cat.token }) });++ assert.strictEqual((await s.next((m) => m.type === 'self-updated')).participant.username, 'cat');++ assert.deepStrictEqual(auth.wsCookieReliant(), before);++ // A client that never presents its token: its first auth-update counts it, once.++ const old = await callWs({ channelId: 'public', cookie: `ov_token=${dan.token}`, ip: '198.51.100.6' });++ await old.next((m) => m.type === 'welcome');++ old.sendJson({ type: 'mute', muted: true });++ old.sendJson({ type: 'auth-update' });++ old.sendJson({ type: 'mute', muted: false });++ await old.next((m) => m.type === 'self-updated');++ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });++ // A slow admission: the auth-update that arrives while the socket is being admitted still counts.++ const canModerate = callServer._canModerate;++ callServer._canModerate = async (...a) => { await h.sleep(300); return canModerate.apply(callServer, a); };++ let slow;++ try {++ slow = await callWs({ channelId: 'public', cookie: `ov_token=${eve.token}`, ip: '198.51.100.7', onOpen: (ws) => setTimeout(() => ws.sendJson({ type: 'auth-update', token: eve.token }), 100) });++ await slow.next((m) => m.type === 'welcome');++ assert.strictEqual((await slow.next((m) => m.type === 'self-updated')).participant.username, 'eve');++ } finally { callServer._canModerate = canModerate; }++ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });++ await bye(s, old, slow);++});++++t('call reliance waits for auth-update and counts silent or rejected-token sockets', async () => {++ const auth = require('../server/auth/auth');++ const before = auth.wsCookieReliant().jwt;++ const wrongType = await callWs({ channelId: 'public', cookie: `ov_token=${cat.token}`, ip: '198.51.100.10', onOpen: (ws) => {++ ws.sendJson({ type: 'join', token: cat.token });++ ws.sendJson({ type: 'auth-update', token: cat.token });++ } });++ await wrongType.next((m) => m.type === 'self-updated');++ assert.strictEqual(auth.wsCookieReliant().jwt, before, 'a join frame cannot settle call auth');++ await bye(wrongType);++++ const rejected = await callWs({ channelId: 'public', cookie: `ov_token=${dan.token}`, ip: '198.51.100.11', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: 'invalid-token' }) });++ assert.strictEqual((await rejected.next((m) => m.type === 'self-updated')).participant.username, 'dan');++ assert.strictEqual(auth.wsCookieReliant().jwt, before + 1);++ await bye(rejected);++++ const silent = await callWs({ channelId: 'public', cookie: `ov_token=${eve.token}`, ip: '198.51.100.12' });++ await silent.next((m) => m.type === 'welcome');++ await until(() => auth.wsCookieReliant().jwt === before + 2, 2000);++ silent.sendJson({ type: 'auth-update', token: eve.token });++ await silent.next((m) => m.type === 'self-updated');++ assert.strictEqual(auth.wsCookieReliant().jwt, before + 2, 'a later message does not double count');++ await bye(silent);++});++t('limits: 3 sockets per address, 8 per channel', async () => {const sockets = [];for (let i = 0; i < 3; i++) sockets.push(await join({ channelId: 'public', ip: '192.0.2.10' }));@@@ -332,8 +385,16 @@ t('call-user: ringing → accepted → active → ended; private call; Live askering = await lifecycle.get(ring.id);assert.deepStrictEqual([ring.state, ring.end_reason], ['active', null]);assert.ok(ring.answered_at >= ring.started_at);-- const c = await join({ channelId: annCall, token: cat.token, ip: '198.51.100.43' });++ // No cookie and no bearer on the upgrade (a browser after J7): the token in the first message,++ // sent a moment after the socket opened, is what admits the invitee.++ const admission = callServer._admission;++ callServer._admission = async (...args) => { if (!args[2]) await h.sleep(300); return admission.apply(callServer, args); };++ let c;++ try {++ c = await join({ channelId: annCall, ip: '198.51.100.43', onOpen: (ws) => setTimeout(() => ws.sendJson({ type: 'auth-update', token: cat.token }), 100) });++ } finally { callServer._admission = admission; }assert.ok(c.welcome, c.error);++ assert.strictEqual(c.welcome.participants.find((p) => p.peerId === c.welcome.peerId).username, 'cat');await h.sleep(1400);assert.strictEqual((await lifecycle.get(ring.id)).state, 'active', 'an answered call is not missed');await bye(a, c, annChat, catChat, anon, outsider);@@@ -150,4 +150,58 @@ t('bots authenticate the upgrade with an Authorization header, no token in the Uassert.deepStrictEqual(require('../server/auth/auth').wsCookieUses(), cookieBefore, 'a bearer upgrade is not counted as a cookie use');});++t('a cookie socket that presents its token in the join is not cookie-reliant (the J7 gate)', async () => {++ const auth = require('../server/auth/auth');++ const token = jwt(claimsFor(alice));++ const before = auth.wsCookieReliant();++ const ws = await h.ws({ cookie: `ov_token=${token}` });++ ws.sendJson({ type: 'join', token });++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');++ ws.sendJson({ type: 'join' }); // later messages never count: the first one settled it++ await ws.next((m) => m.type === 'auth');++ ws.close();++ assert.deepStrictEqual(auth.wsCookieReliant(), before);++});++++t('a cookie socket whose first join presents no token is counted once (the J7 gate)', async () => {++ const auth = require('../server/auth/auth');++ const before = auth.wsCookieReliant();++ const usesBefore = auth.wsCookieUses().jwt;++ const ws = await h.ws({ cookie: `ov_token=${jwt(claimsFor(alice))}` });++ ws.sendJson({ type: 'join' });++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');++ ws.sendJson({ type: 'join' });++ await ws.next((m) => m.type === 'auth');++ ws.sendJson({ type: 'join_room', room: 'nowhere' });++ await ws.next((m) => m.type === 'room_error');++ ws.close();++ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 }, 'once per socket');++ assert.strictEqual(auth.wsCookieUses().jwt, usesBefore + 1, 'the raw series still counts the cookie seen');++ assert.match((await h.http('GET', '/metrics')).text, /chat_ws_cookie_reliant\{[^}]*via="jwt"[^}]*\} [1-9]/);++ // A bearer upgrade without a token in the join did not rely on the cookie.++ const bot = await h.ws({ bearer: jwt(claimsFor(carol)) });++ bot.sendJson({ type: 'join' });++ await bot.next((m) => m.type === 'auth');++ bot.close();++ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });++});++++t('cookie reliance ignores unrelated frames but counts a rejected join token', async () => {++ const auth = require('../server/auth/auth');++ const token = jwt(claimsFor(alice));++ const before = auth.wsCookieReliant().jwt;++ const ws = await h.ws({ cookie: `ov_token=${token}` });++ ws.sendJson({ type: 'ignored', token });++ ws.sendJson({ type: 'join', token });++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');++ assert.strictEqual(auth.wsCookieReliant().jwt, before);++ ws.close();++++ const rejected = await h.ws({ cookie: `ov_token=${token}` });++ rejected.sendJson({ type: 'join', token: 'invalid-token' });++ assert.strictEqual((await rejected.next((m) => m.type === 'auth')).core_username, 'alice');++ rejected.close();++ assert.strictEqual(auth.wsCookieReliant().jwt, before + 1);++});++t.run(async () => { if (h && h.close) await h.close(); });@@@ -334,6 +334,26 @@ t('chat sockets following a room learn their new role at once, or leave', asyncws.close();});++t('join_room: a token re-authenticates an anonymous socket, never switches a signed-in one (private rooms: members only)', async () => {++ assert.strictEqual((await api('POST', '/', own, { name: 'Inner Circle', kind: 'community', visibility: 'private' })).body.room.slug, 'inner-circle');++ // A guest socket presents the owner's token in join_room: signed in, and the private room opens.++ const guest = await h.ws({ ip: '203.0.113.41' });++ guest.sendJson({ type: 'join_room', room: 'inner-circle', token: own.token });++ assert.strictEqual((await guest.next((m) => m.type === 'room_joined')).role, 'owner');++ guest.close();++ // An outsider's socket presents the owner's token: ignored, the room stays missing, still the outsider.++ const ws = await h.ws({ token: out.token, ip: '203.0.113.42' });++ ws.sendJson({ type: 'join_room', room: 'inner-circle', token: own.token });++ assert.strictEqual((await ws.next((m) => m.type === 'room_error')).code, 'rooms.not_found');++ ws.sendJson({ type: 'join' });++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'outsider1', 'the socket keeps its identity');++ // An invalid token is refused as in join: the guest stays a guest.++ const anon = await h.ws({ ip: '203.0.113.43' });++ anon.sendJson({ type: 'join_room', room: 'inner-circle', token: 'forged.or.expired' });++ assert.strictEqual((await anon.next((m) => m.type === 'room_error')).code, 'rooms.not_found');++ ws.close(); anon.close();++});++t('ICE servers for calls: STUN, and TURN only with credentials (short-lived with TURN_AUTH_SECRET)', async () => {const crypto = require('crypto');const turn = require('../server/net/turn');@@@ -181,4 +181,41 @@ t('unknown pages: a browser gets the site\'s HTML 404 page, the API and other classert.match(post.headers.get('content-type'), /^application\/json/, 'a form post to nowhere keeps JSON');});++// Run a page script (public/web/) in a sandbox with fake elements and sockets: the sockets it opened++// (each with what it sent) and the listeners it added to elements ('<id>:<event>').++const runClient = (file, page, docCookie) => {++ const sockets = [];++ const listeners = {};++ const element = (id) => ({ hidden: false, textContent: '', appendChild() {}, setAttribute() {}, removeAttribute() {}, querySelector: () => null, addEventListener: (ev, fn) => { listeners[`${id}:${ev}`] = fn; } });++ function WebSocket(url) { this.url = url; this.readyState = 1; this.sent = []; sockets.push(this); }++ WebSocket.prototype.send = function (d) { this.sent.push(JSON.parse(d)); };++ WebSocket.prototype.close = function () {};++ require('vm').runInNewContext(require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', file), 'utf8'), {++ window: { __OV_PAGE: { chat: page }, RTCPeerConnection: function () {}, WebSocket, addEventListener() {} },++ WebSocket, navigator: {}, location: { protocol: 'https:', host: 'openvibe.chat' },++ document: { cookie: docCookie, hidden: false, getElementById: element, createElement: () => element(null), addEventListener() {} },++ fetch: async () => ({ ok: false }), setTimeout: () => 0, setInterval: () => 0,++ });++ return { sockets, listeners };++};++++t('the page scripts present the session token in the first message of every socket (C-06)', async () => {++ const signedIn = `ov_sso_hint=account; ov_token=${encodeURIComponent(alice.token)}`;++ for (const [page, first] of [[{ view: 'global' }, { type: 'join' }], [{ view: 'room', room: 'lobby' }, { type: 'join_room', room: 'lobby' }]]) {++ for (const [docCookie, token] of [[signedIn, alice.token], ['ov_sso_hint=guest', undefined]]) {++ const { sockets } = runClient('chat.js', page, docCookie);++ sockets[0].onopen();++ assert.deepStrictEqual(sockets[0].sent[0], { ...first, ...(token ? { token } : {}) }, `${page.view}, ${token ? 'signed in' : 'guest'}`);++ }++ }++ // The call socket opens on Join; a member's first message is auth-update with the token.++ for (const [docCookie, sent] of [[signedIn, [{ type: 'auth-update', token: alice.token }]], ['', []]]) {++ const { sockets, listeners } = runClient('call.js', { view: 'room', room: 'lobby', call: { join: true, talk: false, channel: 'room-lobby' } }, docCookie);++ listeners['oc-call-join:click']();++ await new Promise((r) => setImmediate(r));++ sockets[0].onopen();++ assert.deepStrictEqual(sockets[0].sent, sent, docCookie ? 'call, signed in' : 'call, guest');++ }++});++t.run(async () => { if (h && h.close) await h.close(); });
@@ -527,6 +527,11 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -527,6 +527,11 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
Live; WebSocket upgrades use the session cookie or Authorization bearer (Live's legacy `token`
Live; WebSocket upgrades use the session cookie or Authorization bearer (Live's legacy `token`
cookie is ignored there); `?token=` on `/ws/chat` is deprecated (C-05), counted in
cookie is ignored there); `?token=` on `/ws/chat` is deprecated (C-05), counted in
`chat_ws_url_token_uses`, and stays until Live's bot guide and call client stop sending it.
`chat_ws_url_token_uses`, and stays until Live's bot guide and call client stop sending it.
+ The browser clients present the token in the first message of every socket (`join`,
+ `join_room`, the call's `auth-update`); J7 removes the cookie branch from `extractWsToken` once
+ `chat_ws_cookie_reliant{via="jwt"|"api_token"}` (cookie-authenticated sockets whose first
+ handled auth message lacked a valid token, or call sockets silent past the token grace period)
+ stays 0 for one full release after that ships (`chat_ws_cookie_uses` only counts the cookie seen).
Services use client-credentials tokens for audience
Services use client-credentials tokens for audience
`openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,
`openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,
`chat.presence.read`). Staff gates ask the contracts staff map.
`chat.presence.read`). Staff gates ask the contracts staff map.
@@ -31,8 +31,13 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L
@@ -31,8 +31,13 @@ nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in L
> session cookie or an `Authorization` bearer, and Live's legacy `token` cookie is ignored there. The
> session cookie or an `Authorization` bearer, and Live's legacy `token` cookie is ignored there. The
> deprecated `?token=` query param is still honoured for Live's bot guide and call client, counted in
> deprecated `?token=` query param is still honoured for Live's bot guide and call client, counted in
> `chat_ws_url_token_uses`. C-06 — dropping the `ov_token` cookie fallback so upgrades authenticate by
> `chat_ws_url_token_uses`. C-06 — dropping the `ov_token` cookie fallback so upgrades authenticate by
-> bearer only — is gated on `chat_ws_cookie_uses` reading 0 for a full release together with the
-> browser client sending its token in the join message.
+> bearer only — is gated on `chat_ws_cookie_reliant`: the browser clients now send their token in the
+> first message of every socket (`join`, `join_room`, the call's `auth-update`), and a socket the
+> cookie signed in whose first handled auth message has no valid token counts there, once; call
+> sockets silent past the token grace period count too. J7 removes the cookie
+> branch from `extractWsToken` once `chat_ws_cookie_reliant` stays 0 for one full release after this
+> ships. `chat_ws_cookie_uses` is only the raw "cookie seen" series: browsers send the cookie on every
+> same-origin upgrade, so it never reads 0.
Stay on Live (do not route): `/api/chat-ai/*` (the trailing slash in `/api/chat/` keeps it out),
Stay on Live (do not route): `/api/chat-ai/*` (the trailing slash in `/api/chat/` keeps it out),
`/api/emotes/*`, `/api/channels/*` (channel moderation dashboard), `/api/mod/*`, `/api/media/*`,
`/api/emotes/*`, `/api/channels/*` (channel moderation dashboard), `/api/mod/*`, `/api/media/*`,
@@ -294,4 +299,3 @@ written on PostgreSQL after the switch to Chat's own tables (rooms, calls, the a
@@ -294,4 +299,3 @@ written on PostgreSQL after the switch to Chat's own tables (rooms, calls, the a
state) are not in the file — export them from PostgreSQL first if the rollback comes after real traffic.
state) are not in the file — export them from PostgreSQL first if the rollback comes after real traffic.
Keep the SQLite file read-only for the rollback window; do not delete it until the PostgreSQL deploy has
Keep the SQLite file read-only for the rollback window; do not delete it until the PostgreSQL deploy has
served a full release.
served a full release.
-
@@ -21,6 +21,11 @@
@@ -21,6 +21,11 @@
function status(text) { if (statusEl) statusEl.textContent = text || ''; }
function status(text) { if (statusEl) statusEl.textContent = text || ''; }
function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
+ // The session token (the ov_token cookie is JS-readable), presented in the socket's first message.
+ function sessionToken() {
+ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
+ return m ? decodeURIComponent(m[1]) : null;
+ }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function render() {
function render() {
@@ -173,6 +178,7 @@
@@ -173,6 +178,7 @@
Promise.all([ice, mic]).then(function () {
Promise.all([ice, mic]).then(function () {
if (mine !== attempt) return;
if (mine !== attempt) return;
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(cfg.call.channel));
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(cfg.call.channel));
+ ws.onopen = function () { var t = sessionToken(); if (t) send({ type: 'auth-update', token: t }); };
ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };
ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };
var sock = ws;
var sock = ws;
ws.onclose = function () { if (sock === ws && mine === attempt) teardown(joined ? 'The call was disconnected. Join again to reconnect.' : 'Could not reach the call.'); };
ws.onclose = function () { if (sock === ws && mine === attempt) teardown(joined ? 'The call was disconnected. Join again to reconnect.' : 'Could not reach the call.'); };
@@ -11,6 +11,11 @@
@@ -11,6 +11,11 @@
var latest = Number(cfg.latest) || 0;
var latest = Number(cfg.latest) || 0;
var LIVE = 'https://openvibe.live';
var LIVE = 'https://openvibe.live';
+ // The session token (the ov_token cookie is JS-readable), presented in each socket's first message.
+ function sessionToken() {
+ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
+ return m ? decodeURIComponent(m[1]) : undefined;
+ }
function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }
function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }
function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }
function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
@@ -88,7 +93,7 @@
@@ -88,7 +93,7 @@
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
ws.onopen = function () {
ws.onopen = function () {
delay = 1000;
delay = 1000;
- ws.send(JSON.stringify({ type: 'join' }));
+ ws.send(JSON.stringify({ type: 'join', token: sessionToken() }));
if (live) live.hidden = false;
if (live) live.hidden = false;
if (opened) catchUp();
if (opened) catchUp();
opened = true;
opened = true;
@@ -197,7 +202,7 @@
@@ -197,7 +202,7 @@
}
}
function connect() {
function connect() {
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
- ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: cfg.room })); if (opened) catchUpRoom(); opened = true; };
+ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: cfg.room, token: sessionToken() })); if (opened) catchUpRoom(); opened = true; };
ws.onmessage = function (ev) {
ws.onmessage = function (ev) {
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.type === 'room_joined') { if (live) live.hidden = false; }
if (m.type === 'room_joined') { if (live) live.hidden = false; }
@@ -84,10 +84,15 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -84,10 +84,15 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
// Deprecated ?token= on /ws/chat (C-05), by kind, since start: when both stay at 0, the shim goes.
// Deprecated ?token= on /ws/chat (C-05), by kind, since start: when both stay at 0, the shim goes.
metrics.registry.gauge({ name: 'chat_ws_url_token_uses', help: 'WebSocket upgrades that carried the token in the URL (deprecated), by token kind', labelNames: ['kind'],
metrics.registry.gauge({ name: 'chat_ws_url_token_uses', help: 'WebSocket upgrades that carried the token in the URL (deprecated), by token kind', labelNames: ['kind'],
collect: () => Object.entries(require('./auth/auth').urlTokenUses()).map(([kind, n]) => ({ labels: { kind }, value: n })) });
collect: () => Object.entries(require('./auth/auth').urlTokenUses()).map(([kind, n]) => ({ labels: { kind }, value: n })) });
- // /ws/chat upgrades that authenticated from the ov_token cookie (the C-06 removal gate), by kind,
- // since start: the browser's cookie fallback goes once both stay at 0 for a release.
+ // /ws/chat upgrades that authenticated from the ov_token cookie, by kind, since start: the raw
+ // "cookie seen" series. Browsers send the cookie on every same-origin upgrade, so it never reads 0.
metrics.registry.gauge({ name: 'chat_ws_cookie_uses', help: '/ws/chat upgrades whose ov_token cookie resolved to an account, by token kind', labelNames: ['kind'],
metrics.registry.gauge({ name: 'chat_ws_cookie_uses', help: '/ws/chat upgrades whose ov_token cookie resolved to an account, by token kind', labelNames: ['kind'],
collect: () => Object.entries(require('./auth/auth').wsCookieUses()).map(([kind, n]) => ({ labels: { kind }, value: n })) });
collect: () => Object.entries(require('./auth/auth').wsCookieUses()).map(([kind, n]) => ({ labels: { kind }, value: n })) });
+ // The C-06 removal gate (J7): /ws/chat and /ws/call sockets the cookie authenticated whose first
+ // accepted auth message did not present a valid token, by via, since start. J7 removes the cookie branch
+ // from extractWsToken once both stay at 0 for one full release.
+ metrics.registry.gauge({ name: 'chat_ws_cookie_reliant', help: 'WebSockets that relied on the ov_token cookie, by token kind', labelNames: ['via'],
+ collect: () => Object.entries(require('./auth/auth').wsCookieReliant()).map(([via, n]) => ({ labels: { via }, value: n })) });
/** Exact allow-list only (no subdomain wildcard). */
/** Exact allow-list only (no subdomain wildcard). */
function isAllowedOrigin(origin) {
function isAllowedOrigin(origin) {
@@ -83,10 +83,14 @@ function extractToken(req, opts) {
@@ -83,10 +83,14 @@ function extractToken(req, opts) {
* 'ov_token_cookie' or null)
* 'ov_token_cookie' or null)
*/
*/
const urlTokenUses = { jwt: 0, api_token: 0 };
const urlTokenUses = { jwt: 0, api_token: 0 };
-// /ws/chat upgrades that authenticated from the Network `ov_token` cookie (the C-06 removal gate),
-// by token kind, counted once the token resolved to an account (countWsCookieUse): when both stay
-// at 0 for a release the cookie fallback can be deleted.
+// /ws/chat upgrades authenticated from the Network `ov_token` cookie. This is the raw
+// "cookie seen" series; browsers send the cookie on every same-origin upgrade, so it cannot gate J7.
const wsCookieUses = { jwt: 0, api_token: 0 };
const wsCookieUses = { jwt: 0, api_token: 0 };
+// /ws/chat and /ws/call sockets that the ov_token cookie authenticated and that did not present the
+// token themselves in an accepted auth message (wsCookieReliance), by token kind: J7 removes the cookie
+// branch from extractWsToken once both stay at 0 for one full release.
+const wsCookieReliant = { jwt: 0, api_token: 0 };
+const tokenKind = (token) => (token.startsWith('hbt_') ? 'api_token' : 'jwt');
function extractWsTokenFrom(req) {
function extractWsTokenFrom(req) {
// DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message
// DEPRECATED (C-05): a ?token= query param. Browsers send the token in their first join message
// and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and
// and bots should use the Authorization header; URLs end up in proxy logs. Still honoured (and
@@ -110,7 +114,22 @@ function extractWsToken(req) {
@@ -110,7 +114,22 @@ function extractWsToken(req) {
/** Count a /ws/chat upgrade that the ov_token cookie authenticated (call once the user resolved). */
/** Count a /ws/chat upgrade that the ov_token cookie authenticated (call once the user resolved). */
function countWsCookieUse(token) {
function countWsCookieUse(token) {
- wsCookieUses[token.startsWith('hbt_') ? 'api_token' : 'jwt']++;
+ wsCookieUses[tokenKind(token)]++;
+}
+
+/**
+ * Track one socket whose upgrade the ov_token cookie authenticated (call once the user resolved).
+ * Returns settle(accepted), called for the first auth message this endpoint handles, or with no
+ * argument after a bounded grace period for a silent call socket. A rejected, missing, or mismatched
+ * token leaves the socket dependent on its cookie. Later calls do nothing.
+ */
+function wsCookieReliance(token) {
+ let settled = false;
+ return (accepted = false) => {
+ if (settled) return;
+ settled = true;
+ if (!accepted) wsCookieReliant[tokenKind(token)]++;
+ };
}
}
/**
/**
@@ -215,10 +234,12 @@ module.exports = {
@@ -215,10 +234,12 @@ module.exports = {
extractWsToken,
extractWsToken,
extractWsTokenFrom,
extractWsTokenFrom,
countWsCookieUse,
countWsCookieUse,
+ wsCookieReliance,
authenticateWs,
authenticateWs,
requireAuth,
requireAuth,
optionalAuth,
optionalAuth,
requireAdmin,
requireAdmin,
urlTokenUses: () => ({ ...urlTokenUses }),
urlTokenUses: () => ({ ...urlTokenUses }),
wsCookieUses: () => ({ ...wsCookieUses }),
wsCookieUses: () => ({ ...wsCookieUses }),
+ wsCookieReliant: () => ({ ...wsCookieReliant }),
};
};
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
const WebSocket = require('ws');
const WebSocket = require('ws');
const config = require('../config');
const config = require('../config');
const ctx = require('../live-context');
const ctx = require('../live-context');
-const { extractWsToken, authenticateWs } = require('../auth/auth');
+const { extractWsTokenFrom, wsCookieReliance, authenticateWs } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const chatServer = require('../chat/chat-server');
const chatServer = require('../chat/chat-server');
const lifecycle = require('./lifecycle');
const lifecycle = require('./lifecycle');
@@ -53,6 +53,7 @@ const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userI
@@ -53,6 +53,7 @@ const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userI
const WS_HEARTBEAT_MS = 30000;
const WS_HEARTBEAT_MS = 30000;
const MAX_PARTICIPANTS = 8;
const MAX_PARTICIPANTS = 8;
const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never one host filling it
const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never one host filling it
+const FIRST_TOKEN_WAIT_MS = 1000; // how long a refused anonymous socket waits for its auth-update
const KICK_COOLDOWN_MS = 60 * 1000;
const KICK_COOLDOWN_MS = 60 * 1000;
const PUBLIC_CHANNEL_ID = 'public';
const PUBLIC_CHANNEL_ID = 'public';
const MODES = ['mic', 'mic+cam', 'cam+mic'];
const MODES = ['mic', 'mic+cam', 'cam+mic'];
@@ -298,7 +299,7 @@ class CallServer {
@@ -298,7 +299,7 @@ class CallServer {
async _handleConnection(ws, req) {
async _handleConnection(ws, req) {
const url = new URL(req.url, 'http://localhost');
const url = new URL(req.url, 'http://localhost');
const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
- const token = extractWsToken(req);
+ const { token, from: tokenFrom } = extractWsTokenFrom(req);
// CF-Connecting-IP / X-Forwarded-For only when nginx's peer is Cloudflare (net/client-ip.js), as on /ws/chat.
// CF-Connecting-IP / X-Forwarded-For only when nginx's peer is Cloudflare (net/client-ip.js), as on /ws/chat.
const ip = chatServer.getClientIp(req);
const ip = chatServer.getClientIp(req);
@@ -330,11 +331,12 @@ class CallServer {
@@ -330,11 +331,12 @@ class CallServer {
const user = await authenticateWs(token).catch(() => null);
const user = await authenticateWs(token).catch(() => null);
if (!user) await chatServer._resolveUnifiedAnonNum(chatServer.normalizeIp(ip)).catch(() => {});
if (!user) await chatServer._resolveUnifiedAnonNum(chatServer.normalizeIp(ip)).catch(() => {});
await ctx.warm({ user, streamId: first && first.streamId ? first.streamId : null, ip }).catch(() => {});
await ctx.warm({ user, streamId: first && first.streamId ? first.streamId : null, ip }).catch(() => {});
- ws.off('message', onEarlyMessage);
ws.off('close', onEarlyClose);
ws.off('close', onEarlyClose);
ws.off('error', onEarlyClose);
ws.off('error', onEarlyClose);
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
- await this._admit(ws, { resolvedId, ip, user, stream, early });
+ // Messages keep buffering until _admit attaches the socket's handler: none falls in between.
+ await this._admit(ws, { resolvedId, ip, user, stream, early, stopBuffering: () => ws.off('message', onEarlyMessage),
+ cookieReliance: user && tokenFrom === 'ov_token_cookie' ? wsCookieReliance(token) : null });
})().catch((err) => {
})().catch((err) => {
console.warn('[Call] connection setup failed:', err.message);
console.warn('[Call] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
try { ws.close(1011, 'setup failed'); } catch {}
@@ -342,7 +344,7 @@ class CallServer {
@@ -342,7 +344,7 @@ class CallServer {
}
}
/** The rest of Live's _handleConnection, once the socket's identity is known. */
/** The rest of Live's _handleConnection, once the socket's identity is known. */
- async _admit(ws, { resolvedId, ip, user, stream, early }) {
+ async _admit(ws, { resolvedId, ip, user, stream, early, stopBuffering, cookieReliance = null }) {
// The channel may have gone while the socket signed in.
// The channel may have gone while the socket signed in.
const channel = this.channels.get(resolvedId);
const channel = this.channels.get(resolvedId);
if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
@@ -353,16 +355,24 @@ class CallServer {
@@ -353,16 +355,24 @@ class CallServer {
if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }
if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }
}
}
- if (channel.private && !await this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
-
- // A call room: the room's roles decide who is in and who talks.
- let roomAccess = null;
- if (channel.roomSlug) {
- const room = await this._roomOf(resolvedId);
- roomAccess = room ? await rooms.access(room, user) : null;
- if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
- if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }
+ // Admission counts the token the client presents in its first message (auth-update) too: once
+ // J7 drops the cookie fallback a browser's upgrade is anonymous, and a private call or a
+ // members-only room must still let it in. A socket refused while anonymous waits briefly for
+ // that message.
+ user = await this._presentedUser(early, user);
+ let admission = await this._admission(channel, resolvedId, user);
+ if (admission.error && !user) {
+ // Admission itself may have awaited Live while auth-update arrived in the buffer.
+ user = await this._presentedUser(early, user);
+ if (!user) {
+ await this._firstAuthMessage(ws, FIRST_TOKEN_WAIT_MS);
+ if (ws.readyState !== WebSocket.OPEN) return;
+ user = await this._presentedUser(early, user);
+ }
+ if (user) admission = await this._admission(channel, resolvedId, user);
}
}
+ if (admission.error) { ws.send(JSON.stringify({ type: 'error', message: admission.error })); ws.close(); return; }
+ const roomAccess = admission.roomAccess;
const listenOnly = !!(roomAccess && !roomAccess.talk);
const listenOnly = !!(roomAccess && !roomAccess.talk);
const peerId = this._generatePeerId();
const peerId = this._generatePeerId();
@@ -449,21 +459,76 @@ class CallServer {
@@ -449,21 +459,76 @@ class CallServer {
// One message at a time per socket, in arrival order (auth-update resolves a token first).
// One message at a time per socket, in arrival order (auth-update resolves a token first).
let queue = Promise.resolve();
let queue = Promise.resolve();
+ const relianceTimer = cookieReliance ? setTimeout(() => {
+ // Give an auth-update already queued for processing a chance to resolve first.
+ queue.finally(() => cookieReliance());
+ }, FIRST_TOKEN_WAIT_MS) : null;
+ if (relianceTimer) ws.once('close', () => { clearTimeout(relianceTimer); queue.finally(() => cookieReliance()); });
const onMessage = (data) => {
const onMessage = (data) => {
// Rate limit: 200/s. A newcomer to a full room sends seven offers and their candidates in
// Rate limit: 200/s. A newcomer to a full room sends seven offers and their candidates in
// a burst; the old 50/s silently dropped some of them and the join stalled.
// a burst; the old 50/s silently dropped some of them and the join stalled.
const now = Date.now();
const now = Date.now();
if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }
if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }
if (++clientInfo._msgCount > 200) return;
if (++clientInfo._msgCount > 200) return;
- queue = queue.then(async () => await this._handleMessage(ws, JSON.parse(data), resolvedId, peerId))
+ let msg;
+ try { msg = JSON.parse(data); } catch (err) { console.warn('[Call] Message error for peer', peerId, ':', err.message); return; }
+ queue = queue.then(async () => {
+ const accepted = await this._handleMessage(ws, msg, resolvedId, peerId);
+ if (cookieReliance && msg.type === 'auth-update') cookieReliance(accepted);
+ })
.catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });
.catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });
};
};
+ stopBuffering();
ws.on('message', onMessage);
ws.on('message', onMessage);
ws.on('close', async () => await this._handleDisconnect(ws, resolvedId, peerId));
ws.on('close', async () => await this._handleDisconnect(ws, resolvedId, peerId));
ws.on('error', async () => await this._handleDisconnect(ws, resolvedId, peerId));
ws.on('error', async () => await this._handleDisconnect(ws, resolvedId, peerId));
for (const data of early) onMessage(data);
for (const data of early) onMessage(data);
}
}
+ /** Whether `user` may enter `channel`: { error } or { roomAccess } (null outside call rooms). */
+ async _admission(channel, channelId, user) {
+ if (channel.private && !await this._canSeePrivate(channel, user)) return { error: 'This is a private call' };
+ // A call room: the room's roles decide who is in and who talks.
+ if (!channel.roomSlug) return { roomAccess: null };
+ const room = await this._roomOf(channelId);
+ const roomAccess = room ? await rooms.access(room, user) : null;
+ if (!roomAccess || !roomAccess.read) return { error: 'Voice channel not found' };
+ if (!roomAccess.join) return { error: 'You cannot join this call' };
+ return { roomAccess };
+ }
+
+ /**
+ * The account of the token in the socket's first message when that is an auth-update carrying
+ * one, else `user`. As auth-update: an invalid token changes nothing, and it never replaces the
+ * upgrade's account with another.
+ */
+ async _presentedUser(early, user) {
+ let first = null;
+ for (const data of early) {
+ let msg;
+ try { msg = JSON.parse(data); } catch { continue; }
+ if (msg?.type === 'auth-update') { first = msg; break; }
+ }
+ if (!first || typeof first.token !== 'string' || !first.token.trim()) return user;
+ const next = await authenticateWs(first.token).catch(() => null);
+ if (!next || (user && user.id !== next.id)) return user;
+ await ctx.ensureDecor([next.id]).catch(() => {});
+ return next;
+ }
+
+ /** Resolves on the next auth-update (buffered by then), close, or timeout. */
+ _firstAuthMessage(ws, ms) {
+ return new Promise((resolve) => {
+ const done = () => { clearTimeout(timer); ws.off('message', onMessage); ws.off('close', done); resolve(); };
+ const onMessage = (data) => {
+ try { if (JSON.parse(data)?.type === 'auth-update') done(); } catch { /* wait */ }
+ };
+ const timer = setTimeout(done, ms);
+ ws.on('message', onMessage);
+ ws.on('close', done);
+ });
+ }
+
async _canModerate(user, channelId) {
async _canModerate(user, channelId) {
if (!user) return false;
if (!user) return false;
if (permissions.can(user, 'staff.moderation.calls')) return true;
if (permissions.can(user, 'staff.moderation.calls')) return true;
@@ -514,6 +579,7 @@ class CallServer {
@@ -514,6 +579,7 @@ class CallServer {
case 'auth-update': {
case 'auth-update': {
const c = room.get(peerId); if (!c) break;
const c = room.get(peerId); if (!c) break;
let user = c.user || null;
let user = c.user || null;
+ let acceptedToken = false;
if (typeof msg.token === 'string' && msg.token.trim()) {
if (typeof msg.token === 'string' && msg.token.trim()) {
const nextUser = await authenticateWs(msg.token).catch(() => null);
const nextUser = await authenticateWs(msg.token).catch(() => null);
if (!nextUser) {
if (!nextUser) {
@@ -522,6 +588,7 @@ class CallServer {
@@ -522,6 +588,7 @@ class CallServer {
console.warn(`[Call] auth-update identity mismatch for peer ${peerId}: keeping existing user ${user.username}, ignoring ${nextUser.username}`);
console.warn(`[Call] auth-update identity mismatch for peer ${peerId}: keeping existing user ${user.username}, ignoring ${nextUser.username}`);
} else {
} else {
user = nextUser;
user = nextUser;
+ acceptedToken = true;
}
}
if (user) await ctx.ensureDecor([user.id]).catch(() => {});
if (user) await ctx.ensureDecor([user.id]).catch(() => {});
// The socket may have left while the token was resolved.
// The socket may have left while the token was resolved.
@@ -550,7 +617,7 @@ class CallServer {
@@ -550,7 +617,7 @@ class CallServer {
}));
}));
const u = JSON.stringify({ type: 'peer-updated', ...pInfo });
const u = JSON.stringify({ type: 'peer-updated', ...pInfo });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(u); }
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(u); }
- break;
+ return acceptedToken;
}
}
case 'force-mute': {
case 'force-mute': {
const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;
const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;
@@ -22,7 +22,7 @@ const fs = require('fs');
@@ -22,7 +22,7 @@ const fs = require('fs');
const crypto = require('crypto');
const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
const ctx = require('../live-context');
const ctx = require('../live-context');
-const { extractWsTokenFrom, countWsCookieUse, authenticateWs } = require('../auth/auth');
+const { extractWsTokenFrom, countWsCookieUse, wsCookieReliance, authenticateWs } = require('../auth/auth');
const session = require('../auth/network-session');
const session = require('../auth/network-session');
const { clientIpOf } = require('../net/client-ip');
const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
@@ -296,7 +296,8 @@ class ChatServer {
@@ -296,7 +296,8 @@ class ChatServer {
ws.off('close', onEarlyClose);
ws.off('close', onEarlyClose);
ws.off('error', onEarlyClose);
ws.off('error', onEarlyClose);
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
- this._registerConnection(ws, req, { ip, streamId, user, early, tokenIat: user ? session.tokenIat(token) : null });
+ this._registerConnection(ws, req, { ip, streamId, user, early, tokenIat: user ? session.tokenIat(token) : null,
+ cookieReliance: user && tokenFrom === 'ov_token_cookie' ? wsCookieReliance(token) : null });
})().catch((err) => {
})().catch((err) => {
console.warn('[Chat] connection setup failed:', err.message);
console.warn('[Chat] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
try { ws.close(1011, 'setup failed'); } catch {}
@@ -304,7 +305,7 @@ class ChatServer {
@@ -304,7 +305,7 @@ class ChatServer {
}
}
/** The rest of Live's handleConnection, once the socket's identity is known. */
/** The rest of Live's handleConnection, once the socket's identity is known. */
- _registerConnection(ws, req, { ip, streamId, user, early, tokenIat = null }) {
+ _registerConnection(ws, req, { ip, streamId, user, early, tokenIat = null, cookieReliance = null }) {
const perIp = this._ipSockets.get(ip) || 0;
const perIp = this._ipSockets.get(ip) || 0;
if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {
if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {
ws.close(4029, 'Too many connections');
ws.close(4029, 'Too many connections');
@@ -359,7 +360,7 @@ class ChatServer {
@@ -359,7 +360,7 @@ class ChatServer {
console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
return;
return;
}
}
- chain = chain.then(async () => await this.handleMessage(ws, msg)).catch((err) => {
+ chain = chain.then(async () => await this.handleMessage(ws, msg, cookieReliance)).catch((err) => {
console.warn('[Chat] message handling failed:', err.message);
console.warn('[Chat] message handling failed:', err.message);
});
});
};
};
@@ -386,23 +387,24 @@ class ChatServer {
@@ -386,23 +387,24 @@ class ChatServer {
* the socket already carries is ignored: a socket cannot change identity mid-session.
* the socket already carries is ignored: a socket cannot change identity mid-session.
*/
*/
async _applyJoinToken(client, msg) {
async _applyJoinToken(client, msg) {
- if (!msg.token) return;
+ if (!msg.token) return false;
const user = await authenticateWs(msg.token).catch(() => null);
const user = await authenticateWs(msg.token).catch(() => null);
- if (!user) return;
+ if (!user) return false;
if (client.user && client.user.id !== user.id) {
if (client.user && client.user.id !== user.id) {
console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
- return;
+ return false;
}
}
client.user = user;
client.user = user;
await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
client.tokenIat = session.tokenIat(msg.token);
client.tokenIat = session.tokenIat(msg.token);
client.anonId = null; // no longer anonymous
client.anonId = null; // no longer anonymous
+ return true;
}
}
/**
/**
* Handle incoming chat message
* Handle incoming chat message
*/
*/
- async handleMessage(ws, msg) {
+ async handleMessage(ws, msg, cookieReliance = null) {
const client = this.clients.get(ws);
const client = this.clients.get(ws);
if (!client) return;
if (!client) return;
@@ -437,7 +439,8 @@ class ChatServer {
@@ -437,7 +439,8 @@ class ChatServer {
case 'join':
case 'join':
case 'join_stream': {
case 'join_stream': {
// (Re-)authenticate if a token is provided
// (Re-)authenticate if a token is provided
- await this._applyJoinToken(client, msg);
+ const acceptedToken = await this._applyJoinToken(client, msg);
+ if (cookieReliance) cookieReliance(acceptedToken);
const oldStream = client.streamId;
const oldStream = client.streamId;
const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
@@ -520,7 +523,8 @@ class ChatServer {
@@ -520,7 +523,8 @@ class ChatServer {
case 'join_room': {
case 'join_room': {
// A room socket can still arrive anonymous or as a guest: authenticate a token the
// A room socket can still arrive anonymous or as a guest: authenticate a token the
// same way as join, so room access is resolved with the member's identity.
// same way as join, so room access is resolved with the member's identity.
- await this._applyJoinToken(client, msg);
+ const acceptedToken = await this._applyJoinToken(client, msg);
+ if (cookieReliance) cookieReliance(acceptedToken);
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
const room = await rooms.bySlug(msg.room);
const room = await rooms.bySlug(msg.room);
const a = room ? await rooms.access(room, client.user) : null;
const a = room ? await rooms.access(room, client.user) : null;
@@ -276,6 +276,8 @@ async function hydrateReplies(messages) {
@@ -276,6 +276,8 @@ async function hydrateReplies(messages) {
}
}
// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──
// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──
+// The browser posts over /ws/chat and presents its token in the join; J7 removes the cookie branch
+// from extractWsToken once chat_ws_cookie_reliant{via} stays 0 for one full release after that shipped.
// Every line reaches every global chat socket: a bot posts at most one every 3 s (20 a minute, 300
// Every line reaches every global chat socket: a bot posts at most one every 3 s (20 a minute, 300
// an hour), refusals included; the word and spam filters below still decide each line.
// an hour), refusals included; the word and spam filters below still decide each line.
router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {
router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {
@@ -18,11 +18,11 @@ let h, config, callServer, lifecycle;
@@ -18,11 +18,11 @@ let h, config, callServer, lifecycle;
let ann, bob, cat, dan, eve, kay, staff, streamer, liveStream, offStream;
let ann, bob, cat, dan, eve, kay, staff, streamer, liveStream, offStream;
/** Open /ws/call with a bearer (through nginx: the address in CF-Connecting-IP). */
/** Open /ws/call with a bearer (through nginx: the address in CF-Connecting-IP). */
-function callWs({ channelId, token = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {
+function callWs({ channelId, token = null, cookie = null, ip = '203.0.113.20', origin = 'https://openvibe.live', onOpen = null } = {}) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const qs = new URLSearchParams();
const qs = new URLSearchParams();
if (channelId != null) qs.set('channelId', channelId);
if (channelId != null) qs.set('channelId', channelId);
- const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin, ...(token ? { authorization: `Bearer ${token}` } : {}) } });
+ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin, ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookie ? { cookie } : {}) } });
const all = [];
const all = [];
const waiters = [];
const waiters = [];
ws.on('message', (d) => {
ws.on('message', (d) => {
@@ -182,21 +182,74 @@ t('signalling: offer / answer / ice-candidate relayed between two signed-in sock
@@ -182,21 +182,74 @@ t('signalling: offer / answer / ice-candidate relayed between two signed-in sock
await bye(a2, b);
await bye(a2, b);
});
});
-t('auth-update: an anonymous socket signs in (sent before its welcome, handled after, in order)', async () => {
+t('auth-update: sent before its welcome, the token admits the socket as that account (then handled, in order)', async () => {
const other = await join({ channelId: 'public', token: bob.token, ip: '198.51.100.2' });
const other = await join({ channelId: 'public', token: bob.token, ip: '198.51.100.2' });
const s = await callWs({ channelId: 'public', ip: '198.51.100.4', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: cat.token }) });
const s = await callWs({ channelId: 'public', ip: '198.51.100.4', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: cat.token }) });
const w = await s.next((m) => m.type === 'welcome');
const w = await s.next((m) => m.type === 'welcome');
- assert.ok(w.participants.find((x) => x.peerId === w.peerId).anonId, 'joined as anon');
+ assert.deepStrictEqual([w.participants.find((x) => x.peerId === w.peerId).username, w.participants.find((x) => x.peerId === w.peerId).anonId], ['cat', null], 'joined as cat');
const self = await s.next((m) => m.type === 'self-updated');
const self = await s.next((m) => m.type === 'self-updated');
assert.deepStrictEqual([self.participant.username, self.participant.userId, self.participant.anonId, self.canModerate], ['cat', cat.id, null, false]);
assert.deepStrictEqual([self.participant.username, self.participant.userId, self.participant.anonId, self.canModerate], ['cat', cat.id, null, false]);
- const upd = await other.next((m) => m.type === 'peer-updated');
- assert.deepStrictEqual([upd.peerId, upd.username], [w.peerId, 'cat']);
+ const joined = await other.next((m) => m.type === 'peer-joined');
+ assert.deepStrictEqual([joined.peerId, joined.username], [w.peerId, 'cat']);
s.sendJson({ type: 'auth-update', token: dan.token });
s.sendJson({ type: 'auth-update', token: dan.token });
const again = await s.next((m) => m.type === 'self-updated');
const again = await s.next((m) => m.type === 'self-updated');
assert.strictEqual(again.participant.username, 'cat', 'a different account cannot take over the socket');
assert.strictEqual(again.participant.username, 'cat', 'a different account cannot take over the socket');
await bye(s, other);
await bye(s, other);
});
});
+t('a cookie call socket is cookie-reliant unless its first message is auth-update with the token (the J7 gate)', async () => {
+ const auth = require('../server/auth/auth');
+ const before = auth.wsCookieReliant();
+ // The page's client: auth-update with the token first. Not counted.
+ const s = await callWs({ channelId: 'public', cookie: `ov_token=${cat.token}`, ip: '198.51.100.5', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: cat.token }) });
+ assert.strictEqual((await s.next((m) => m.type === 'self-updated')).participant.username, 'cat');
+ assert.deepStrictEqual(auth.wsCookieReliant(), before);
+ // A client that never presents its token: its first auth-update counts it, once.
+ const old = await callWs({ channelId: 'public', cookie: `ov_token=${dan.token}`, ip: '198.51.100.6' });
+ await old.next((m) => m.type === 'welcome');
+ old.sendJson({ type: 'mute', muted: true });
+ old.sendJson({ type: 'auth-update' });
+ old.sendJson({ type: 'mute', muted: false });
+ await old.next((m) => m.type === 'self-updated');
+ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });
+ // A slow admission: the auth-update that arrives while the socket is being admitted still counts.
+ const canModerate = callServer._canModerate;
+ callServer._canModerate = async (...a) => { await h.sleep(300); return canModerate.apply(callServer, a); };
+ let slow;
+ try {
+ slow = await callWs({ channelId: 'public', cookie: `ov_token=${eve.token}`, ip: '198.51.100.7', onOpen: (ws) => setTimeout(() => ws.sendJson({ type: 'auth-update', token: eve.token }), 100) });
+ await slow.next((m) => m.type === 'welcome');
+ assert.strictEqual((await slow.next((m) => m.type === 'self-updated')).participant.username, 'eve');
+ } finally { callServer._canModerate = canModerate; }
+ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });
+ await bye(s, old, slow);
+});
+
+t('call reliance waits for auth-update and counts silent or rejected-token sockets', async () => {
+ const auth = require('../server/auth/auth');
+ const before = auth.wsCookieReliant().jwt;
+ const wrongType = await callWs({ channelId: 'public', cookie: `ov_token=${cat.token}`, ip: '198.51.100.10', onOpen: (ws) => {
+ ws.sendJson({ type: 'join', token: cat.token });
+ ws.sendJson({ type: 'auth-update', token: cat.token });
+ } });
+ await wrongType.next((m) => m.type === 'self-updated');
+ assert.strictEqual(auth.wsCookieReliant().jwt, before, 'a join frame cannot settle call auth');
+ await bye(wrongType);
+
+ const rejected = await callWs({ channelId: 'public', cookie: `ov_token=${dan.token}`, ip: '198.51.100.11', onOpen: (ws) => ws.sendJson({ type: 'auth-update', token: 'invalid-token' }) });
+ assert.strictEqual((await rejected.next((m) => m.type === 'self-updated')).participant.username, 'dan');
+ assert.strictEqual(auth.wsCookieReliant().jwt, before + 1);
+ await bye(rejected);
+
+ const silent = await callWs({ channelId: 'public', cookie: `ov_token=${eve.token}`, ip: '198.51.100.12' });
+ await silent.next((m) => m.type === 'welcome');
+ await until(() => auth.wsCookieReliant().jwt === before + 2, 2000);
+ silent.sendJson({ type: 'auth-update', token: eve.token });
+ await silent.next((m) => m.type === 'self-updated');
+ assert.strictEqual(auth.wsCookieReliant().jwt, before + 2, 'a later message does not double count');
+ await bye(silent);
+});
+
t('limits: 3 sockets per address, 8 per channel', async () => {
t('limits: 3 sockets per address, 8 per channel', async () => {
const sockets = [];
const sockets = [];
for (let i = 0; i < 3; i++) sockets.push(await join({ channelId: 'public', ip: '192.0.2.10' }));
for (let i = 0; i < 3; i++) sockets.push(await join({ channelId: 'public', ip: '192.0.2.10' }));
@@ -332,8 +385,16 @@ t('call-user: ringing → accepted → active → ended; private call; Live aske
@@ -332,8 +385,16 @@ t('call-user: ringing → accepted → active → ended; private call; Live aske
ring = await lifecycle.get(ring.id);
ring = await lifecycle.get(ring.id);
assert.deepStrictEqual([ring.state, ring.end_reason], ['active', null]);
assert.deepStrictEqual([ring.state, ring.end_reason], ['active', null]);
assert.ok(ring.answered_at >= ring.started_at);
assert.ok(ring.answered_at >= ring.started_at);
- const c = await join({ channelId: annCall, token: cat.token, ip: '198.51.100.43' });
+ // No cookie and no bearer on the upgrade (a browser after J7): the token in the first message,
+ // sent a moment after the socket opened, is what admits the invitee.
+ const admission = callServer._admission;
+ callServer._admission = async (...args) => { if (!args[2]) await h.sleep(300); return admission.apply(callServer, args); };
+ let c;
+ try {
+ c = await join({ channelId: annCall, ip: '198.51.100.43', onOpen: (ws) => setTimeout(() => ws.sendJson({ type: 'auth-update', token: cat.token }), 100) });
+ } finally { callServer._admission = admission; }
assert.ok(c.welcome, c.error);
assert.ok(c.welcome, c.error);
+ assert.strictEqual(c.welcome.participants.find((p) => p.peerId === c.welcome.peerId).username, 'cat');
await h.sleep(1400);
await h.sleep(1400);
assert.strictEqual((await lifecycle.get(ring.id)).state, 'active', 'an answered call is not missed');
assert.strictEqual((await lifecycle.get(ring.id)).state, 'active', 'an answered call is not missed');
await bye(a, c, annChat, catChat, anon, outsider);
await bye(a, c, annChat, catChat, anon, outsider);
@@ -150,4 +150,58 @@ t('bots authenticate the upgrade with an Authorization header, no token in the U
@@ -150,4 +150,58 @@ t('bots authenticate the upgrade with an Authorization header, no token in the U
assert.deepStrictEqual(require('../server/auth/auth').wsCookieUses(), cookieBefore, 'a bearer upgrade is not counted as a cookie use');
assert.deepStrictEqual(require('../server/auth/auth').wsCookieUses(), cookieBefore, 'a bearer upgrade is not counted as a cookie use');
});
});
+t('a cookie socket that presents its token in the join is not cookie-reliant (the J7 gate)', async () => {
+ const auth = require('../server/auth/auth');
+ const token = jwt(claimsFor(alice));
+ const before = auth.wsCookieReliant();
+ const ws = await h.ws({ cookie: `ov_token=${token}` });
+ ws.sendJson({ type: 'join', token });
+ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
+ ws.sendJson({ type: 'join' }); // later messages never count: the first one settled it
+ await ws.next((m) => m.type === 'auth');
+ ws.close();
+ assert.deepStrictEqual(auth.wsCookieReliant(), before);
+});
+
+t('a cookie socket whose first join presents no token is counted once (the J7 gate)', async () => {
+ const auth = require('../server/auth/auth');
+ const before = auth.wsCookieReliant();
+ const usesBefore = auth.wsCookieUses().jwt;
+ const ws = await h.ws({ cookie: `ov_token=${jwt(claimsFor(alice))}` });
+ ws.sendJson({ type: 'join' });
+ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
+ ws.sendJson({ type: 'join' });
+ await ws.next((m) => m.type === 'auth');
+ ws.sendJson({ type: 'join_room', room: 'nowhere' });
+ await ws.next((m) => m.type === 'room_error');
+ ws.close();
+ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 }, 'once per socket');
+ assert.strictEqual(auth.wsCookieUses().jwt, usesBefore + 1, 'the raw series still counts the cookie seen');
+ assert.match((await h.http('GET', '/metrics')).text, /chat_ws_cookie_reliant\{[^}]*via="jwt"[^}]*\} [1-9]/);
+ // A bearer upgrade without a token in the join did not rely on the cookie.
+ const bot = await h.ws({ bearer: jwt(claimsFor(carol)) });
+ bot.sendJson({ type: 'join' });
+ await bot.next((m) => m.type === 'auth');
+ bot.close();
+ assert.deepStrictEqual(auth.wsCookieReliant(), { ...before, jwt: before.jwt + 1 });
+});
+
+t('cookie reliance ignores unrelated frames but counts a rejected join token', async () => {
+ const auth = require('../server/auth/auth');
+ const token = jwt(claimsFor(alice));
+ const before = auth.wsCookieReliant().jwt;
+ const ws = await h.ws({ cookie: `ov_token=${token}` });
+ ws.sendJson({ type: 'ignored', token });
+ ws.sendJson({ type: 'join', token });
+ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'alice');
+ assert.strictEqual(auth.wsCookieReliant().jwt, before);
+ ws.close();
+
+ const rejected = await h.ws({ cookie: `ov_token=${token}` });
+ rejected.sendJson({ type: 'join', token: 'invalid-token' });
+ assert.strictEqual((await rejected.next((m) => m.type === 'auth')).core_username, 'alice');
+ rejected.close();
+ assert.strictEqual(auth.wsCookieReliant().jwt, before + 1);
+});
+
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });
@@ -334,6 +334,26 @@ t('chat sockets following a room learn their new role at once, or leave', async
@@ -334,6 +334,26 @@ t('chat sockets following a room learn their new role at once, or leave', async
ws.close();
ws.close();
});
});
+t('join_room: a token re-authenticates an anonymous socket, never switches a signed-in one (private rooms: members only)', async () => {
+ assert.strictEqual((await api('POST', '/', own, { name: 'Inner Circle', kind: 'community', visibility: 'private' })).body.room.slug, 'inner-circle');
+ // A guest socket presents the owner's token in join_room: signed in, and the private room opens.
+ const guest = await h.ws({ ip: '203.0.113.41' });
+ guest.sendJson({ type: 'join_room', room: 'inner-circle', token: own.token });
+ assert.strictEqual((await guest.next((m) => m.type === 'room_joined')).role, 'owner');
+ guest.close();
+ // An outsider's socket presents the owner's token: ignored, the room stays missing, still the outsider.
+ const ws = await h.ws({ token: out.token, ip: '203.0.113.42' });
+ ws.sendJson({ type: 'join_room', room: 'inner-circle', token: own.token });
+ assert.strictEqual((await ws.next((m) => m.type === 'room_error')).code, 'rooms.not_found');
+ ws.sendJson({ type: 'join' });
+ assert.strictEqual((await ws.next((m) => m.type === 'auth')).core_username, 'outsider1', 'the socket keeps its identity');
+ // An invalid token is refused as in join: the guest stays a guest.
+ const anon = await h.ws({ ip: '203.0.113.43' });
+ anon.sendJson({ type: 'join_room', room: 'inner-circle', token: 'forged.or.expired' });
+ assert.strictEqual((await anon.next((m) => m.type === 'room_error')).code, 'rooms.not_found');
+ ws.close(); anon.close();
+});
+
t('ICE servers for calls: STUN, and TURN only with credentials (short-lived with TURN_AUTH_SECRET)', async () => {
t('ICE servers for calls: STUN, and TURN only with credentials (short-lived with TURN_AUTH_SECRET)', async () => {
const crypto = require('crypto');
const crypto = require('crypto');
const turn = require('../server/net/turn');
const turn = require('../server/net/turn');
@@ -181,4 +181,41 @@ t('unknown pages: a browser gets the site\'s HTML 404 page, the API and other cl
@@ -181,4 +181,41 @@ t('unknown pages: a browser gets the site\'s HTML 404 page, the API and other cl
assert.match(post.headers.get('content-type'), /^application\/json/, 'a form post to nowhere keeps JSON');
assert.match(post.headers.get('content-type'), /^application\/json/, 'a form post to nowhere keeps JSON');
});
});
+// Run a page script (public/web/) in a sandbox with fake elements and sockets: the sockets it opened
+// (each with what it sent) and the listeners it added to elements ('<id>:<event>').
+const runClient = (file, page, docCookie) => {
+ const sockets = [];
+ const listeners = {};
+ const element = (id) => ({ hidden: false, textContent: '', appendChild() {}, setAttribute() {}, removeAttribute() {}, querySelector: () => null, addEventListener: (ev, fn) => { listeners[`${id}:${ev}`] = fn; } });
+ function WebSocket(url) { this.url = url; this.readyState = 1; this.sent = []; sockets.push(this); }
+ WebSocket.prototype.send = function (d) { this.sent.push(JSON.parse(d)); };
+ WebSocket.prototype.close = function () {};
+ require('vm').runInNewContext(require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', file), 'utf8'), {
+ window: { __OV_PAGE: { chat: page }, RTCPeerConnection: function () {}, WebSocket, addEventListener() {} },
+ WebSocket, navigator: {}, location: { protocol: 'https:', host: 'openvibe.chat' },
+ document: { cookie: docCookie, hidden: false, getElementById: element, createElement: () => element(null), addEventListener() {} },
+ fetch: async () => ({ ok: false }), setTimeout: () => 0, setInterval: () => 0,
+ });
+ return { sockets, listeners };
+};
+
+t('the page scripts present the session token in the first message of every socket (C-06)', async () => {
+ const signedIn = `ov_sso_hint=account; ov_token=${encodeURIComponent(alice.token)}`;
+ for (const [page, first] of [[{ view: 'global' }, { type: 'join' }], [{ view: 'room', room: 'lobby' }, { type: 'join_room', room: 'lobby' }]]) {
+ for (const [docCookie, token] of [[signedIn, alice.token], ['ov_sso_hint=guest', undefined]]) {
+ const { sockets } = runClient('chat.js', page, docCookie);
+ sockets[0].onopen();
+ assert.deepStrictEqual(sockets[0].sent[0], { ...first, ...(token ? { token } : {}) }, `${page.view}, ${token ? 'signed in' : 'guest'}`);
+ }
+ }
+ // The call socket opens on Join; a member's first message is auth-update with the token.
+ for (const [docCookie, sent] of [[signedIn, [{ type: 'auth-update', token: alice.token }]], ['', []]]) {
+ const { sockets, listeners } = runClient('call.js', { view: 'room', room: 'lobby', call: { join: true, talk: false, channel: 'room-lobby' } }, docCookie);
+ listeners['oc-call-join:click']();
+ await new Promise((r) => setImmediate(r));
+ sockets[0].onopen();
+ assert.deepStrictEqual(sockets[0].sent, sent, docCookie ? 'call, signed in' : 'call, guest');
+ }
+});
+
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });