openvibers/openvibe.chat Commits

Chat protocol security fixes: channel-room bans, room escape, rule bypasses, client address, regex budget

OP OpenVibers committed 23 Sept 2026 · 9cdea206d6 · 6 files, +480 −110

- /ban, /timeout and /unban typed in an offline channel room were sent with stream_id null,
  which Live writes as a SITE-WIDE ban (and unban lifts site-wide rows): any streamer or
  channel mod could ban any non-admin account or address from the whole site. They are now
  scoped to that channel's moderation stream; only global chat (global staff) writes null.
- /me, /tts, /clear, /slow, /paste and ban notices used broadcastToStream(null) outside a
  stream, which reaches global chat and every other channel's offline room (including rooms
  the sender is banned from). They now go to the sender's own room.
- /me and /tts skipped the channel's chat rules (followers-only, anonymous, account age,
  links, length, anti-slur filter, IP approval); they now pass the same checks.
- Joining the channel room without the stream id skipped slow mode and IP approval while
  still posting into the live room; both now use the room's moderation stream.
- CF-Connecting-IP and X-Forwarded-For were believed from any peer; hosts that reach nginx
  without Cloudflare (DNS-only names) let a client pick its address and evade IP bans, the
  per-address socket cap and rate limits. They are now used only when nginx's peer is a
  Cloudflare address (WebSocket and Express trust proxy).
- Streamer-written slur regexes ran unbounded on every chat line; a catastrophic pattern froze
  the chat process for every room. They now run under a 25 ms budget and are disabled when
  they exceed it.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 07d15e7..11f8dbf 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -16,6 +16,7 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const ctx = require('./live-context');
const { extractToken, extractWsToken, authenticateWs } = require('./auth/auth');
++const { trustProxy } = require('./net/client-ip');
function normalizeOrigin(origin) {
if (!origin || typeof origin !== 'string') return null;
@@@ -54,7 +55,9 @@ function createApp({ chatServer, bridge, mirror, relay }) {
const allowedOrigins = getAllowedOrigins();
const app = express();
app.disable('x-powered-by');
-- app.set('trust proxy', config.trustProxy); // Cloudflare → nginx → Node
++ // Cloudflare → nginx → Node: TRUST_PROXY hops, but a hop past nginx only when it is a
++ // Cloudflare address (DNS-only hosts reach nginx directly with a client-written X-Forwarded-For).
++ app.set('trust proxy', trustProxy(config.trustProxy));
/** Exact allow-list first, then *.openvibe.tools over https (Live's isAllowedOrigin). */
function isAllowedOrigin(origin) {
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex f20982a..33e9868 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -23,6 +23,7 @@ const crypto = require('crypto');
const db = require('../db/database');
const ctx = require('../live-context');
const { extractWsToken, authenticateWs } = require('../auth/auth');
++const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
const wordFilter = require('./word-filter');
const ttsEngine = require('./tts-engine');
@@@ -124,12 +125,8 @@ class ChatServer {
_isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && client.user.role === 'admin'); }
getClientIp(req) {
-- const raw = req.headers?.['cf-connecting-ip']
-- || req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
-- || req.socket?.remoteAddress
-- || req.connection?.remoteAddress
-- || 'unknown';
-- return this.normalizeIp(raw);
++ // CF-Connecting-IP / X-Forwarded-For only when nginx's peer is Cloudflare (net/client-ip.js).
++ return this.normalizeIp(clientIpOf(req));
}
/**
@@@ -369,7 +366,10 @@ class ChatServer {
const now = Date.now();
const rateKey = `${client.ip}:${client.streamId || 'global'}`;
const lastMsg = this.rateLimits.get(rateKey) || 0;
-- const streamSlowMs = this.slowModeByStream.get(client.streamId) || 0;
++ // Slow mode of the stream that governs the room (an offline/channel-room join has no
++ // stream id of its own but posts into the live room).
++ const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
++ const streamSlowMs = (slowStreamId && this.slowModeByStream.get(slowStreamId)) || 0;
const effectiveLimit = Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);
if (now - lastMsg < effectiveLimit) {
this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
@@@ -637,9 +637,11 @@ class ChatServer {
}
// ── IP Approval Mode (Anti-VPN) ─────────────────────
-- if (client.streamId && client.ip) {
++ // modStreamId, not client.streamId: a viewer who joins the channel room without the
++ // stream id still posts into the live room (broadcastToChannelRoom).
++ if (modStreamId && client.ip) {
try {
-- const stream = ctx.getStreamById(client.streamId);
++ const stream = ctx.getStreamById(modStreamId);
const channel = stream?.channel_id ? ctx.getChannelById(stream.channel_id) : (stream ? ctx.getChannelByUserId(stream.user_id) : null);
if (channel) {
const settings = ctx.getChannelModerationSettings(channel.id);
@@@ -665,7 +667,7 @@ class ChatServer {
const username = client.user ? client.user.display_name : client.anonId;
db.holdMessageForApproval({
channelId: channel.id,
-- streamId: client.streamId,
++ streamId: modStreamId,
ip: client.ip,
userId: client.user?.id || null,
anonId: client.anonId || null,
@@@ -677,7 +679,7 @@ class ChatServer {
message: 'This channel has IP approval mode enabled. Your message is being held for review by the streamer.',
});
// Notify the streamer that a new IP needs approval
-- this._notifyStreamerPendingIp(client.streamId, stream.user_id, username, client.ip);
++ this._notifyStreamerPendingIp(modStreamId, stream.user_id, username, client.ip);
return;
}
}
@@@ -688,85 +690,7 @@ class ChatServer {
}
// ── Channel moderation settings ──────────────────────
-- if (modStreamId) {
-- const chatSettings = this._getChannelChatSettings(modStreamId);
-- const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);
-- const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);
--
-- // Max message length
-- const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));
-- if (text.length > maxLen) {
-- this.sendTo(ws, { type: 'system', message: `Message too long. Max ${maxLen} characters.` });
-- return;
-- }
--
-- // Anonymous not allowed
-- if (!client.user && !chatSettings.allow_anonymous) {
-- this.sendTo(ws, { type: 'system', message: 'This channel requires a logged-in account to chat.' });
-- return;
-- }
--
-- // Links disabled — exempt [gif:url] tags (validated separately)
-- if (chatSettings.links_allowed === 0 && !isStaff) {
-- const textWithoutGifs = text.replace(/\[gif:https?:\/\/[^\]]+\]/gi, '');
-- if (/(https?:\/\/|www\.)/i.test(textWithoutGifs)) {
-- this.sendTo(ws, { type: 'system', message: 'Links are disabled in this channel chat.' });
-- return;
-- }
-- }
--
-- // Validate [gif:url] — only allow trusted domains
-- const gifTagMatch = text.match(/\[gif:(https?:\/\/[^\]]+)\]/i);
-- if (gifTagMatch) {
-- const ALLOWED_GIF_DOMAINS = ['tenor.com', 'media.tenor.com', 'media1.tenor.com', 'c.tenor.com', 'giphy.com', 'media.giphy.com', 'media0.giphy.com', 'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com', 'i.giphy.com'];
-- try {
-- const gifUrl = new URL(gifTagMatch[1]);
-- if (!ALLOWED_GIF_DOMAINS.includes(gifUrl.hostname)) {
-- this.sendTo(ws, { type: 'system', message: 'Only Tenor and Giphy GIFs are allowed.' });
-- return;
-- }
-- } catch {
-- this.sendTo(ws, { type: 'system', message: 'Invalid GIF URL.' });
-- return;
-- }
-- }
--
-- // Followers only
-- if (chatSettings.followers_only && client.user && !isStaff) {
-- const stream = ctx.getStreamById(modStreamId);
-- if (stream && stream.user_id !== client.user.id && !ctx.isFollowing(client.user.id, stream.user_id)) {
-- this.sendTo(ws, { type: 'system', message: 'This chat is currently followers-only.' });
-- return;
-- }
-- }
--
-- // Account age gate
-- if (chatSettings.account_age_gate_hours && client.user && !isStaff) {
-- const ageMs = Date.now() - new Date(client.user.created_at).getTime();
-- if (ageMs < Number(chatSettings.account_age_gate_hours) * 3600000) {
-- this.sendTo(ws, { type: 'system', message: `This chat requires accounts older than ${chatSettings.account_age_gate_hours} hour(s).` });
-- return;
-- }
-- }
--
-- // Optional per-streamer anti-slur filter
-- if (chatSettings.slur_filter_enabled && !isStaff && !canModerateThisStream) {
-- const blockedTerms = this._parseSlurFilterTerms(chatSettings.slur_filter_terms);
-- const configuredRegexLines = this._parseRegexLines(chatSettings.slur_filter_regexes);
-- const hitConfigured = blockedTerms.length && this._containsConfiguredSlur(text, blockedTerms);
-- const hitCore = chatSettings.slur_filter_use_builtin !== 0 && this._containsCoreSlur(text, (() => { try { return JSON.parse(chatSettings.slur_filter_disabled_categories || '[]') || []; } catch { return []; } })());
-- const hitRegex = configuredRegexLines.length && this._containsRegexSlur(text, configuredRegexLines);
-- if (hitConfigured || hitCore || hitRegex) {
-- this.sendTo(ws, {
-- type: 'slur-blocked',
-- message: String(chatSettings.slur_filter_nudge_message || '').trim() || DEFAULT_SLUR_NUDGE,
-- streamer_enabled: true,
-- });
-- return;
-- }
-- }
--
-- }
++ if (this._chatRulesBlock(ws, client, text, modStreamId)) return;
const username = client.user ? client.user.display_name : client.anonId;
const coreUsername = client.user ? client.user.username : null;
@@@ -995,6 +919,115 @@ class ChatServer {
}
}
++ /**
++ * The channel's chat rules for a line of text a viewer puts in the room (a chat message, and
++ * the text of /me and /tts): length, anonymous, links, GIF hosts, followers-only, account age,
++ * the anti-slur filter. Tells the sender and returns true when the line is refused.
++ */
++ _chatRulesBlock(ws, client, text, modStreamId) {
++ if (!modStreamId) return false;
++ const chatSettings = this._getChannelChatSettings(modStreamId);
++ const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);
++ const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);
++
++ // Max message length
++ const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));
++ if (text.length > maxLen) {
++ this.sendTo(ws, { type: 'system', message: `Message too long. Max ${maxLen} characters.` });
++ return true;
++ }
++
++ // Anonymous not allowed
++ if (!client.user && !chatSettings.allow_anonymous) {
++ this.sendTo(ws, { type: 'system', message: 'This channel requires a logged-in account to chat.' });
++ return true;
++ }
++
++ // Links disabled — exempt [gif:url] tags (validated separately)
++ if (chatSettings.links_allowed === 0 && !isStaff) {
++ const textWithoutGifs = text.replace(/\[gif:https?:\/\/[^\]]+\]/gi, '');
++ if (/(https?:\/\/|www\.)/i.test(textWithoutGifs)) {
++ this.sendTo(ws, { type: 'system', message: 'Links are disabled in this channel chat.' });
++ return true;
++ }
++ }
++
++ // Validate [gif:url] — only allow trusted domains
++ const gifTagMatch = text.match(/\[gif:(https?:\/\/[^\]]+)\]/i);
++ if (gifTagMatch) {
++ const ALLOWED_GIF_DOMAINS = ['tenor.com', 'media.tenor.com', 'media1.tenor.com', 'c.tenor.com', 'giphy.com', 'media.giphy.com', 'media0.giphy.com', 'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com', 'i.giphy.com'];
++ try {
++ const gifUrl = new URL(gifTagMatch[1]);
++ if (!ALLOWED_GIF_DOMAINS.includes(gifUrl.hostname)) {
++ this.sendTo(ws, { type: 'system', message: 'Only Tenor and Giphy GIFs are allowed.' });
++ return true;
++ }
++ } catch {
++ this.sendTo(ws, { type: 'system', message: 'Invalid GIF URL.' });
++ return true;
++ }
++ }
++
++ // Followers only
++ if (chatSettings.followers_only && client.user && !isStaff) {
++ const stream = ctx.getStreamById(modStreamId);
++ if (stream && stream.user_id !== client.user.id && !ctx.isFollowing(client.user.id, stream.user_id)) {
++ this.sendTo(ws, { type: 'system', message: 'This chat is currently followers-only.' });
++ return true;
++ }
++ }
++
++ // Account age gate
++ if (chatSettings.account_age_gate_hours && client.user && !isStaff) {
++ const ageMs = Date.now() - new Date(client.user.created_at).getTime();
++ if (ageMs < Number(chatSettings.account_age_gate_hours) * 3600000) {
++ this.sendTo(ws, { type: 'system', message: `This chat requires accounts older than ${chatSettings.account_age_gate_hours} hour(s).` });
++ return true;
++ }
++ }
++
++ // Optional per-streamer anti-slur filter
++ if (chatSettings.slur_filter_enabled && !isStaff && !canModerateThisStream) {
++ const blockedTerms = this._parseSlurFilterTerms(chatSettings.slur_filter_terms);
++ const configuredRegexLines = this._parseRegexLines(chatSettings.slur_filter_regexes);
++ const hitConfigured = blockedTerms.length && this._containsConfiguredSlur(text, blockedTerms);
++ const hitCore = chatSettings.slur_filter_use_builtin !== 0 && this._containsCoreSlur(text, (() => { try { return JSON.parse(chatSettings.slur_filter_disabled_categories || '[]') || []; } catch { return []; } })());
++ const hitRegex = configuredRegexLines.length && this._containsRegexSlur(text, configuredRegexLines);
++ if (hitConfigured || hitCore || hitRegex) {
++ this.sendTo(ws, {
++ type: 'slur-blocked',
++ message: String(chatSettings.slur_filter_nudge_message || '').trim() || DEFAULT_SLUR_NUDGE,
++ streamer_enabled: true,
++ });
++ return true;
++ }
++ }
++ return false;
++ }
++
++ /**
++ * IP approval mode for text that is not a plain chat line (/me, /tts): refused until the
++ * streamer approves the address (plain chat lines are held for review in handleChatMessage).
++ */
++ _awaitingIpApproval(ws, client, modStreamId) {
++ if (!modStreamId || !client.ip) return false;
++ try {
++ const stream = ctx.getStreamById(modStreamId);
++ const channel = stream?.channel_id ? ctx.getChannelById(stream.channel_id) : (stream ? ctx.getChannelByUserId(stream.user_id) : null);
++ if (!channel || !ctx.getChannelModerationSettings(channel.id)?.ip_approval_mode) return false;
++ if (client.user && (permissions.isGlobalModOrAbove(client.user) || stream.user_id === client.user.id)) return false;
++ if (ctx.isIpApproved(channel.id, client.ip)) return false;
++ } catch { return false; }
++ this.sendTo(ws, { type: 'system', message: 'This channel has IP approval mode enabled. Commands work once the streamer approves you.' });
++ return true;
++ }
++
++ /** /me and /tts put text in the room: the same rules as a chat line. */
++ _commandTextBlocked(ws, client, text) {
++ const modStreamId = client.streamId || this._moderationStreamFor(client);
++ return this._awaitingIpApproval(ws, client, modStreamId) || this._chatRulesBlock(ws, client, text, modStreamId);
++ }
++
handleBangCommand(ws, client, text) {
const parts = text.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
@@@ -1396,6 +1429,7 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });
return;
}
++ if (this._commandTextBlocked(ws, client, args)) return;
{
const ttsMsg = {
type: 'tts',
@@@ -1415,7 +1449,7 @@ class ChatServer {
}
} catch { /* non-critical */ }
}
-- this.broadcastToStream(client.streamId, ttsMsg);
++ this._broadcastToRoom(client, ttsMsg);
// Also synthesize server-side TTS for site-wide mode
this.synthesizeAndBroadcastTTS(
@@@ -1488,8 +1522,9 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });
break;
}
++ if (this._commandTextBlocked(ws, client, args)) break;
const username = client.user?.display_name || client.anonId;
-- this.broadcastToStream(client.streamId, {
++ this._broadcastToRoom(client, {
type: 'chat',
username,
core_username: client.user?.username || null,
@@@ -1515,7 +1550,7 @@ class ChatServer {
case 'clear':
if (this.canModerate(client)) {
-- this.broadcastToStream(client.streamId, { type: 'clear' });
++ this._broadcastToRoom(client, { type: 'clear' });
this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');
} else {
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
@@@ -1543,14 +1578,14 @@ class ChatServer {
} catch { /* non-critical */ }
}
// Dedicated slowmode event so clients can show/hide UI
-- this.broadcastToStream(client.streamId, {
++ this._broadcastToRoom(client, {
type: 'slowmode',
seconds,
});
const msg = seconds > 0
? `Slow mode enabled: ${seconds}s between messages`
: 'Slow mode disabled.';
-- this.broadcastToStream(client.streamId, {
++ this._broadcastToRoom(client, {
type: 'system',
message: msg,
});
@@@ -1582,7 +1617,7 @@ class ChatServer {
const siteUrl = process.env.SITE_URL || '';
const pasteUrl = `${siteUrl}/p/${paste.slug}`;
// Show link to everyone in stream
-- this.broadcastToStream(client.streamId, {
++ this._broadcastToRoom(client, {
type: 'system',
message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,
});
@@@ -1623,7 +1658,12 @@ class ChatServer {
if (!target) return;
// The stream whose moderators may do this (offline channel rooms: the latest stream).
const moderationStreamId = client.streamId || this._moderationStreamFor(client);
-- const banEffect = (row) => ctx.effects.ban({ ...row, actor_user_id: client.user.id, moderation_stream_id: moderationStreamId });
++ // Where the ban applies. In an offline channel room it is that channel's stream: a null
++ // stream id is a SITE-WIDE ban row (and /unban with null lifts site-wide rows), which
++ // only global chat — where canModerate() already requires global staff — may write.
++ const scopeStreamId = client.streamId || (client.channelUserId ? moderationStreamId : null);
++ const banEffect = (row) => ctx.effects.ban({ ...row, stream_id: scopeStreamId, actor_user_id: client.user.id, moderation_stream_id: moderationStreamId });
++ const scoped = !!scopeStreamId;
switch (action) {
case 'ban': {
@@@ -1634,20 +1674,20 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });
return;
}
-- await banEffect({ action: 'ban', stream_id: client.streamId, user_id: targetUser.id, reason: 'Banned by moderator', banned_by: client.user.id });
++ await banEffect({ action: 'ban', user_id: targetUser.id, reason: 'Banned by moderator', banned_by: client.user.id });
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
-- this.broadcastToStream(client.streamId, {
++ this._broadcastToRoom(client, {
type: 'system', message: `${target} has been banned.`
});
-- this.logChatModeration(client, client.streamId ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);
++ this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);
} else {
// Ban by anon ID
const anonTarget = this.findClientByAnonId(target, client.streamId);
if (anonTarget) {
-- await banEffect({ action: 'ban', stream_id: client.streamId, ip_address: anonTarget.ip, anon_id: target, reason: 'Banned by moderator', banned_by: client.user.id });
++ await banEffect({ action: 'ban', ip_address: anonTarget.ip, anon_id: target, reason: 'Banned by moderator', banned_by: client.user.id });
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
}
-- this.logChatModeration(client, client.streamId ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });
++ this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });
}
break;
}
@@@ -1656,8 +1696,8 @@ class ChatServer {
const targetUser = await ctx.ensureUserByUsername(target);
const expires = new Date(Date.now() + duration * 1000).toISOString();
if (targetUser) {
-- await banEffect({ action: 'ban', stream_id: client.streamId, user_id: targetUser.id, reason: `Timeout ${duration}s`, banned_by: client.user.id, expires_at: expires });
-- this.logChatModeration(client, client.streamId ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
++ await banEffect({ action: 'ban', user_id: targetUser.id, reason: `Timeout ${duration}s`, banned_by: client.user.id, expires_at: expires });
++ this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
}
this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });
break;
@@@ -1665,8 +1705,8 @@ class ChatServer {
case 'unban': {
const targetUser = await ctx.ensureUserByUsername(target);
if (targetUser) {
-- await banEffect({ action: 'unban', stream_id: client.streamId, user_id: targetUser.id });
-- this.logChatModeration(client, client.streamId ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
++ await banEffect({ action: 'unban', user_id: targetUser.id });
++ this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
}
this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });
break;
@@@ -2036,6 +2076,17 @@ class ChatServer {
}).catch(() => { /* best-effort */ });
}
++ /**
++ * Deliver to the room a client is in: its stream room when in a stream, its channel room when
++ * in an offline channel chat, else pure global chat. broadcastToStream(null) would reach every
++ * client without a stream — global chat AND every other channel's offline room.
++ */
++ _broadcastToRoom(client, data) {
++ if (client.streamId) this.broadcastToStream(client.streamId, data);
++ else if (client.channelUserId) this.broadcastToChannelRoom(client.channelUserId, null, data);
++ else this.broadcastGlobal(data);
++ }
++
broadcastToStream(streamId, data) {
const msg = JSON.stringify(data);
for (const [ws, client] of this.clients) {
ddiff --git a/server/chat/moderation-utils.js b/server/chat/moderation-utils.js
iindex 3f708f4..6a901ec 100644
---- a/server/chat/moderation-utils.js
++++ b/server/chat/moderation-utils.js
@@@ -152,7 +152,45 @@ function containsRegexSlur(text, regexLines) {
if (!normalized) return false;
const candidates = [normalized, normalized.replace(/\s+/g, '')];
const compiled = compileRegexList(regexLines, { forceInsensitive: true });
-- return compiled.some((pat) => candidates.some((candidate) => pat.test(candidate)));
++ return compiled.some((pat) => testUserRegex(pat, candidates));
++}
++
++// Streamer-written patterns run on every viewer line in the one chat process: a catastrophic
++// pattern (nested or overlapping quantifiers) would freeze chat for every room. They run in a
++// vm context with a time budget; a pattern that exceeds it is switched off for this process.
++const USER_REGEX_BUDGET_MS = 25;
++const _slowUserPatterns = new Set();
++let _regexSandbox = null;
++function regexSandbox() {
++ if (!_regexSandbox) {
++ const vm = require('vm');
++ const context = vm.createContext(Object.create(null));
++ context.__test = vm.runInContext(
++ '(function (src, flags, texts) { var r = new RegExp(src, flags); for (var i = 0; i < texts.length; i++) { r.lastIndex = 0; if (r.test(texts[i])) return true; } return false; })',
++ context
++ );
++ _regexSandbox = { context, script: new vm.Script('__test(__src, __flags, __texts)') };
++ }
++ return _regexSandbox;
++}
++function testUserRegex(pat, texts) {
++ const key = `${pat.flags}/${pat.source}`;
++ if (_slowUserPatterns.has(key)) return false;
++ const box = regexSandbox();
++ box.context.__src = pat.source;
++ box.context.__flags = pat.flags;
++ box.context.__texts = texts.slice();
++ try {
++ return box.script.runInContext(box.context, { timeout: USER_REGEX_BUDGET_MS }) === true;
++ } catch (err) {
++ if (err && err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
++ _slowUserPatterns.add(key);
++ console.warn(`[Moderation] custom slur regex exceeded ${USER_REGEX_BUDGET_MS}ms and is disabled: /${pat.source.slice(0, 80)}/`);
++ }
++ return false;
++ } finally {
++ box.context.__texts = null;
++ }
}
/**
ddiff --git a/server/net/client-ip.js b/server/net/client-ip.js
nnew file mode 100644
iindex 0000000..7560be3
---- /dev/null
++++ b/server/net/client-ip.js
@@@ -0,0 +1,87 @@
++/**
++ * OpenVibe.Chat — which address a request comes from.
++ *
++ * Browsers reach Chat through Cloudflare → nginx → Chat. nginx forwards the visitor's
++ * CF-Connecting-IP and appends its own peer to X-Forwarded-For (and sets X-Real-IP to it). But
++ * the same nginx server block also answers hosts that are NOT behind Cloudflare (DNS-only names
++ * such as ingest.openvibe.live point straight at the origin), and there CF-Connecting-IP and the
++ * left of X-Forwarded-For are whatever the client wrote. So those headers are believed only when
++ * nginx's peer is one of Cloudflare's addresses; otherwise the peer nginx saw is the client.
++ * Without that, IP bans, the per-address socket cap and chat rate limits (and anon numbers, which
++ * are per address) could be set by the client.
++ *
++ * Cloudflare's published ranges (https://www.cloudflare.com/ips/); CLOUDFLARE_IP_RANGES
++ * (comma-separated CIDRs) replaces the list if Cloudflare changes it.
++ */
++'use strict';
++
++const net = require('net');
++
++const DEFAULT_CLOUDFLARE_RANGES = [
++ '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18',
++ '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17',
++ '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
++ '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32', '2405:8100::/32',
++ '2a06:98c0::/29', '2c0f:f248::/32',
++];
++
++function normalizeIp(ip) {
++ let s = String(ip || '').trim();
++ if (s === '::1') return '127.0.0.1';
++ if (s.startsWith('::ffff:')) s = s.slice(7);
++ return s;
++}
++
++const cloudflare = new net.BlockList();
++const ranges = String(process.env.CLOUDFLARE_IP_RANGES || '').split(',').map((r) => r.trim()).filter(Boolean);
++for (const cidr of (ranges.length ? ranges : DEFAULT_CLOUDFLARE_RANGES)) {
++ const [addr, bits] = cidr.split('/');
++ const fam = net.isIP(addr);
++ if (!fam || !Number.isFinite(Number(bits))) continue;
++ try { cloudflare.addSubnet(addr, Number(bits), fam === 6 ? 'ipv6' : 'ipv4'); } catch { /* skip a bad entry */ }
++}
++
++function isCloudflare(ip) {
++ const s = normalizeIp(ip);
++ const fam = net.isIP(s);
++ if (!fam) return false;
++ try { return cloudflare.check(s, fam === 6 ? 'ipv6' : 'ipv4'); } catch { return false; }
++}
++
++function isLoopback(ip) {
++ const s = normalizeIp(ip);
++ return s === '127.0.0.1' || s.startsWith('127.') || s === '::1';
++}
++
++/**
++ * The client address of a raw (upgrade) request. Chat listens on loopback behind nginx; a peer
++ * that is not loopback is the client itself. From nginx: X-Real-IP (else the last
++ * X-Forwarded-For entry) is nginx's peer — the visitor's CF-Connecting-IP is used only when that
++ * peer is Cloudflare. A loopback request with no nginx headers (local tools, tests) keeps the
++ * old order: CF-Connecting-IP, X-Forwarded-For, socket.
++ */
++function clientIpOf(req) {
++ const h = req.headers || {};
++ const peer = normalizeIp(req.socket?.remoteAddress || req.connection?.remoteAddress || '');
++ if (peer && !isLoopback(peer)) return peer;
++ const xff = String(h['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean);
++ const edge = String(h['x-real-ip'] || '').trim() || xff[xff.length - 1] || '';
++ if (!edge) return h['cf-connecting-ip'] || xff[0] || peer || 'unknown';
++ if (!isCloudflare(edge)) return edge;
++ const viaCf = String(h['cf-connecting-ip'] || '').trim();
++ if (viaCf) return viaCf;
++ // Cloudflare without the header: the entry Cloudflare appended before nginx's.
++ const idx = xff.lastIndexOf(edge);
++ return (idx > 0 ? xff[idx - 1] : '') || edge;
++}
++
++/**
++ * Express 'trust proxy' for `hops` proxies (TRUST_PROXY, 2 = Cloudflare → nginx): the socket peer
++ * (nginx) is trusted, a further hop only when it is a Cloudflare address.
++ */
++function trustProxy(hops) {
++ const n = Number.isFinite(Number(hops)) ? Number(hops) : 2;
++ return (addr, i) => i < n && (i === 0 ? true : isCloudflare(addr));
++}
++
++module.exports = { clientIpOf, trustProxy, isCloudflare, normalizeIp, DEFAULT_CLOUDFLARE_RANGES };
ddiff --git a/test/security.test.js b/test/security.test.js
nnew file mode 100644
iindex 0000000..5f8b86f
---- /dev/null
++++ b/test/security.test.js
@@@ -0,0 +1,191 @@
++'use strict';
++/**
++ * Security regressions on the /ws/chat protocol and the client-address rules:
++ * - a moderator in an OFFLINE channel room bans/unbans in that channel, never site-wide;
++ * - command output (/me, /tts, /clear, /slow, ban notices) stays in the sender's room;
++ * - /me and /tts obey the channel's chat rules (followers-only, slur filter, IP approval);
++ * - joining the channel room without the stream id does not skip slow mode or IP approval;
++ * - CF-Connecting-IP / X-Forwarded-For are only believed when the request came from Cloudflare.
++ */
++const assert = require('assert');
++const WebSocket = require('ws');
++const { boot, suite } = require('./helpers');
++
++const t = suite('security');
++let h, streamer, other, mod, alice, bob, chanX, chanY, streamX;
++
++async function joined(opts, join) {
++ const ws = await h.ws(opts);
++ ws.sendJson({ type: 'join', ...(join || {}), ...(opts.token ? { token: opts.token } : {}) });
++ await ws.next((m) => m.type === 'auth');
++ return ws;
++}
++
++t('boot', async () => {
++ h = await boot();
++ streamer = h.addUser('xstreamer', { role: 'streamer' });
++ other = h.addUser('ystreamer', { role: 'streamer' });
++ mod = h.addUser('xmod');
++ alice = h.addUser('alice');
++ bob = h.addUser('bob');
++ chanX = h.addChannel(streamer.id, { moderators: [mod.id] });
++ chanY = h.addChannel(other.id);
++ streamX = h.addStream(streamer.id, chanX);
++ h.addStream(other.id, chanY, { is_live: 0 });
++ await h.ctx.sync();
++});
++
++t('offline channel room: a streamer\'s /ban and /timeout stay in their channel (never a site ban)', async () => {
++ const own = await joined({ ip: '198.51.100.1', token: streamer.token }, { channelUserId: streamer.id });
++ own.sendJson({ type: 'chat', message: '/ban bob' });
++ await own.next((m) => m.type === 'system' && m.message === 'bob has been banned.');
++ const ban = h.live.effects.filter((e) => e.name === 'ban' && e.body.user_id === bob.id).pop();
++ assert.strictEqual(ban.body.stream_id, streamX, 'channel-room ban is scoped to the channel\'s stream, not site-wide (null)');
++ await h.sleep(1100);
++ own.sendJson({ type: 'chat', message: '/timeout alice 60' });
++ await own.next((m) => m.type === 'system' && /alice timed out/.test(m.message));
++ const to = h.live.effects.filter((e) => e.name === 'ban' && e.body.user_id === alice.id).pop();
++ assert.strictEqual(to.body.stream_id, streamX);
++ // /unban from the channel room must not reach for site-wide rows either.
++ await h.sleep(1100);
++ own.sendJson({ type: 'chat', message: '/unban bob' });
++ await own.next((m) => m.type === 'system' && m.message === 'bob has been unbanned.');
++ const unban = h.live.effects.filter((e) => e.name === 'ban' && e.body.action === 'unban').pop();
++ assert.strictEqual(unban.body.stream_id, streamX);
++ h.live.clearBans();
++ await h.ctx.invalidateBans();
++ own.close();
++});
++
++t('/me, /clear and ban notices from global chat or a channel room stay in that room', async () => {
++ const globalWs = await joined({ ip: '198.51.100.10' });
++ const roomY = await joined({ ip: '198.51.100.11', token: bob.token }, { channelUserId: other.id });
++ const fromGlobal = await joined({ ip: '198.51.100.12', token: alice.token });
++ fromGlobal.sendJson({ type: 'chat', message: '/me waves from global' });
++ await globalWs.next((m) => m.type === 'chat' && /waves from global/.test(m.message));
++ assert.ok(await roomY.none((m) => /waves from global/.test(m.message || '')), '/me from global chat leaked into channel Y');
++
++ const roomX = await joined({ ip: '198.51.100.13', token: streamer.token }, { channelUserId: streamer.id });
++ const roomXviewer = await joined({ ip: '198.51.100.14', token: alice.token }, { channelUserId: streamer.id });
++ await h.sleep(1100);
++ roomX.sendJson({ type: 'chat', message: '/me says hi in X' });
++ await roomXviewer.next((m) => m.type === 'chat' && /says hi in X/.test(m.message));
++ assert.ok(await roomY.none((m) => /says hi in X/.test(m.message || '')), '/me leaked into channel Y');
++ assert.ok(await globalWs.none((m) => /says hi in X/.test(m.message || '')), '/me leaked into global chat');
++ await h.sleep(1100);
++ roomX.sendJson({ type: 'chat', message: '/clear' });
++ await roomXviewer.next((m) => m.type === 'clear');
++ assert.ok(await roomY.none((m) => m.type === 'clear'), 'channel X\'s /clear cleared channel Y');
++ assert.ok(await globalWs.none((m) => m.type === 'clear'), 'channel X\'s /clear cleared global chat');
++ await h.sleep(1100);
++ roomX.sendJson({ type: 'chat', message: '/ban bob' });
++ await roomXviewer.next((m) => m.type === 'system' && m.message === 'bob has been banned.');
++ assert.ok(await globalWs.none((m) => m.message === 'bob has been banned.'), 'ban notice leaked into global chat');
++ h.live.clearBans();
++ await h.ctx.invalidateBans();
++ for (const w of [globalWs, roomY, fromGlobal, roomX, roomXviewer]) w.close();
++});
++
++t('/me and /tts obey followers-only and the slur filter', async () => {
++ h.live.policies.get(chanX).settings = { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' };
++ h.ctx.invalidateChannel(chanX);
++ const viewer = await joined({ ip: '198.51.100.20', token: streamer.token, stream: streamX }, { streamId: streamX });
++ const bobWs = await joined({ ip: '198.51.100.21', token: bob.token, stream: streamX }, { streamId: streamX });
++ bobWs.sendJson({ type: 'chat', message: 'plain line' });
++ await bobWs.next((m) => m.type === 'system' && /followers-only/.test(m.message));
++ await h.sleep(1100);
++ bobWs.sendJson({ type: 'chat', message: '/me sneaks past followers-only' });
++ await h.sleep(1100);
++ bobWs.sendJson({ type: 'chat', message: '/tts sneaks past followers-only' });
++ assert.ok(await viewer.none((m) => /sneaks past/.test(m.message || ''), 500), '/me or /tts bypassed followers-only');
++
++ h.live.follows.set(bob.id, [streamer.id]);
++ h.live.policies.get(chanX).settings = { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' };
++ h.ctx.invalidateChannel(chanX);
++ const bob2 = await joined({ ip: '198.51.100.22', token: bob.token, stream: streamX }, { streamId: streamX });
++ bob2.sendJson({ type: 'chat', message: '/me says bannedword' });
++ await bob2.next((m) => m.type === 'slur-blocked');
++ await h.sleep(1100);
++ bob2.sendJson({ type: 'chat', message: '/tts bannedword' });
++ await bob2.next((m) => m.type === 'slur-blocked');
++ assert.ok(await viewer.none((m) => /bannedword/.test(m.message || ''), 300), 'slur filter bypassed by /me or /tts');
++ h.live.policies.get(chanX).settings = null;
++ h.ctx.invalidateChannel(chanX);
++ for (const w of [viewer, bobWs, bob2]) w.close();
++});
++
++t('joining the channel room without the stream id does not skip slow mode or IP approval', async () => {
++ const own = await joined({ ip: '198.51.100.30', token: streamer.token, stream: streamX }, { streamId: streamX });
++ own.sendJson({ type: 'chat', message: '/slow 30' });
++ await own.next((m) => m.type === 'slowmode');
++ const sneaky = await joined({ ip: '198.51.100.31', token: alice.token }, { channelUserId: streamer.id });
++ sneaky.sendJson({ type: 'chat', message: 'first' });
++ await own.next((m) => m.type === 'chat' && m.message === 'first');
++ await h.sleep(1100);
++ sneaky.sendJson({ type: 'chat', message: 'second within slow mode' });
++ await sneaky.next((m) => m.type === 'system' && /too fast/.test(m.message));
++ assert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');
++ h.chatServer.slowModeByStream.set(streamX, 0); // (the streamer is inside their own 30s window)
++
++ h.live.policies.get(chanX).settings = { ip_approval_mode: 1 };
++ h.ctx.invalidateChannel(chanX);
++ const fresh = await joined({ ip: '198.51.100.32' }, { channelUserId: streamer.id });
++ fresh.sendJson({ type: 'chat', message: 'unapproved hello' });
++ await fresh.next((m) => m.type === 'system' && /IP approval mode/.test(m.message));
++ assert.ok(await own.none((m) => m.message === 'unapproved hello'), 'IP approval bypassed from the channel room');
++ assert.ok(h.db.get("SELECT 1 FROM pending_ip_messages WHERE message = 'unapproved hello'"), 'held for review');
++ await h.sleep(1100);
++ fresh.sendJson({ type: 'chat', message: '/me unapproved action' });
++ assert.ok(await own.none((m) => /unapproved action/.test(m.message || ''), 400), '/me bypassed IP approval');
++ h.live.policies.get(chanX).settings = null;
++ h.ctx.invalidateChannel(chanX);
++ for (const w of [own, sneaky, fresh]) w.close();
++});
++
++t('a catastrophic custom slur regex cannot freeze the chat server', async () => {
++ h.live.policies.get(chanX).settings = { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' };
++ h.ctx.invalidateChannel(chanX);
++ const viewer = await joined({ ip: '198.51.100.40', token: streamer.token, stream: streamX }, { streamId: streamX });
++ const bobWs = await joined({ ip: '198.51.100.41', token: bob.token, stream: streamX }, { streamId: streamX });
++ const line = 'a'.repeat(30) + 'b';
++ const started = Date.now();
++ bobWs.sendJson({ type: 'chat', message: line });
++ await viewer.next((m) => m.type === 'chat' && m.message === line, 20000);
++ assert.ok(Date.now() - started < 1500, `chat blocked for ${Date.now() - started}ms by a streamer regex`);
++ h.live.policies.get(chanX).settings = null;
++ h.ctx.invalidateChannel(chanX);
++ for (const w of [viewer, bobWs]) w.close();
++});
++
++function rawWs(headers) {
++ return new Promise((resolve) => {
++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/chat`, { headers: { origin: 'https://openvibe.live', ...headers } });
++ ws.on('open', () => setTimeout(() => resolve({ ws, open: ws.readyState === WebSocket.OPEN }), 400));
++ ws.on('close', () => resolve({ ws, open: false }));
++ ws.on('error', () => resolve({ ws, open: false }));
++ });
++}
++
++t('CF-Connecting-IP / X-Forwarded-For are believed only from Cloudflare', async () => {
++ // The attacker reaches nginx directly (a DNS-only host such as ingest.openvibe.live): nginx
++ // sets X-Real-IP / appends X-Forwarded-For with the real peer; CF-Connecting-IP is forged.
++ h.live.addBan({ ip_address: '192.0.2.66' });
++ await h.ctx.invalidateBans();
++ const direct = await rawWs({ 'x-real-ip': '192.0.2.66', 'x-forwarded-for': '203.0.113.200, 192.0.2.66', 'cf-connecting-ip': '203.0.113.200' });
++ assert.strictEqual(direct.open, false, 'banned address evaded the ban with a forged CF-Connecting-IP');
++ // Through Cloudflare (peer in Cloudflare's ranges) the header is the visitor.
++ const viaCf = await rawWs({ 'x-real-ip': '104.16.0.9', 'x-forwarded-for': '203.0.113.201, 104.16.0.9', 'cf-connecting-ip': '203.0.113.201' });
++ assert.strictEqual(viaCf.open, true);
++ const ips = [...h.chatServer.clients.values()].map((c) => c.ip);
++ assert.ok(ips.includes('203.0.113.201'), `Cloudflare visitor address used (${ips})`);
++ viaCf.ws.close();
++ // REST: the ban middleware and the rate limiter key on the same rule.
++ const r = await h.http('GET', '/api/chat/filters/friendly', { headers: { 'X-Forwarded-For': '203.0.113.200, 192.0.2.66', 'X-Real-IP': '192.0.2.66' } });
++ assert.strictEqual(r.status, 403, 'REST IP ban evaded with a forged X-Forwarded-For');
++ const ok = await h.http('GET', '/api/chat/filters/friendly', { headers: { 'X-Forwarded-For': '203.0.113.202, 104.16.0.9', 'X-Real-IP': '104.16.0.9' } });
++ assert.strictEqual(ok.status, 200);
++ h.live.clearBans();
++ await h.ctx.invalidateBans();
++});
++
++t.run(() => h && h.close());
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
iindex 921e766..92d302b 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -278,8 +278,8 @@ t('upgrade guards: foreign origin refused, banned address refused (admins exempt
await h.ctx.invalidateBans();
assert.strictEqual(await h.wsRefused({ ip: '192.0.2.55' }), true, 'CIDR network ban');
assert.strictEqual(await h.wsRefused({ ip: '192.0.2.56', token: admin.token }), false, 'admin passes');
-- // And REST from a banned network.
-- const r = await h.http('GET', '/api/chat/global/history', { headers: { 'X-Forwarded-For': '192.0.2.9, 10.0.0.1' } });
++ // And REST from a banned network (through Cloudflare: the hop before nginx is a Cloudflare edge).
++ const r = await h.http('GET', '/api/chat/global/history', { headers: { 'X-Forwarded-For': '192.0.2.9, 104.16.0.1' } });
assert.strictEqual(r.status, 403);
assert.deepStrictEqual(r.body, { error: 'Access denied' });
h.live.clearBans();