openvibers/openvibe.chat Commits
Chat protocol security fixes: channel-room bans, room escape, rule bypasses, client address, regex budget
Changes
@@@ -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) {@@@ -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 modethis.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 IDconst 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) {@@@ -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;++ }}/**nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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());@@@ -278,8 +278,8 @@ t('upgrade guards: foreign origin refused, banned address refused (admins exemptawait 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();
@@ -16,6 +16,7 @@ const rateLimit = require('express-rate-limit');
@@ -16,6 +16,7 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const config = require('./config');
const ctx = require('./live-context');
const ctx = require('./live-context');
const { extractToken, extractWsToken, authenticateWs } = require('./auth/auth');
const { extractToken, extractWsToken, authenticateWs } = require('./auth/auth');
+const { trustProxy } = require('./net/client-ip');
function normalizeOrigin(origin) {
function normalizeOrigin(origin) {
if (!origin || typeof origin !== 'string') return null;
if (!origin || typeof origin !== 'string') return null;
@@ -54,7 +55,9 @@ function createApp({ chatServer, bridge, mirror, relay }) {
@@ -54,7 +55,9 @@ function createApp({ chatServer, bridge, mirror, relay }) {
const allowedOrigins = getAllowedOrigins();
const allowedOrigins = getAllowedOrigins();
const app = express();
const app = express();
app.disable('x-powered-by');
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). */
/** Exact allow-list first, then *.openvibe.tools over https (Live's isAllowedOrigin). */
function isAllowedOrigin(origin) {
function isAllowedOrigin(origin) {
@@ -23,6 +23,7 @@ const crypto = require('crypto');
@@ -23,6 +23,7 @@ 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 { extractWsToken, authenticateWs } = require('../auth/auth');
const { extractWsToken, authenticateWs } = require('../auth/auth');
+const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const wordFilter = require('./word-filter');
const wordFilter = require('./word-filter');
const ttsEngine = require('./tts-engine');
const ttsEngine = require('./tts-engine');
@@ -124,12 +125,8 @@ class ChatServer {
@@ -124,12 +125,8 @@ class ChatServer {
_isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && client.user.role === 'admin'); }
_isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && client.user.role === 'admin'); }
getClientIp(req) {
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 {
@@ -369,7 +366,10 @@ class ChatServer {
const now = Date.now();
const now = Date.now();
const rateKey = `${client.ip}:${client.streamId || 'global'}`;
const rateKey = `${client.ip}:${client.streamId || 'global'}`;
const lastMsg = this.rateLimits.get(rateKey) || 0;
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);
const effectiveLimit = Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);
if (now - lastMsg < effectiveLimit) {
if (now - lastMsg < effectiveLimit) {
this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
@@ -637,9 +637,11 @@ class ChatServer {
@@ -637,9 +637,11 @@ class ChatServer {
}
}
// ── IP Approval Mode (Anti-VPN) ─────────────────────
// ── 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 {
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);
const channel = stream?.channel_id ? ctx.getChannelById(stream.channel_id) : (stream ? ctx.getChannelByUserId(stream.user_id) : null);
if (channel) {
if (channel) {
const settings = ctx.getChannelModerationSettings(channel.id);
const settings = ctx.getChannelModerationSettings(channel.id);
@@ -665,7 +667,7 @@ class ChatServer {
@@ -665,7 +667,7 @@ class ChatServer {
const username = client.user ? client.user.display_name : client.anonId;
const username = client.user ? client.user.display_name : client.anonId;
db.holdMessageForApproval({
db.holdMessageForApproval({
channelId: channel.id,
channelId: channel.id,
- streamId: client.streamId,
+ streamId: modStreamId,
ip: client.ip,
ip: client.ip,
userId: client.user?.id || null,
userId: client.user?.id || null,
anonId: client.anonId || null,
anonId: client.anonId || null,
@@ -677,7 +679,7 @@ class ChatServer {
@@ -677,7 +679,7 @@ class ChatServer {
message: 'This channel has IP approval mode enabled. Your message is being held for review by the streamer.',
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
// 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;
return;
}
}
}
}
@@ -688,85 +690,7 @@ class ChatServer {
@@ -688,85 +690,7 @@ class ChatServer {
}
}
// ── Channel moderation settings ──────────────────────
// ── 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 username = client.user ? client.user.display_name : client.anonId;
const coreUsername = client.user ? client.user.username : null;
const coreUsername = client.user ? client.user.username : null;
@@ -995,6 +919,115 @@ class ChatServer {
@@ -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) {
handleBangCommand(ws, client, text) {
const parts = text.trim().split(/\s+/);
const parts = text.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
const cmd = parts[0].toLowerCase();
@@ -1396,6 +1429,7 @@ class ChatServer {
@@ -1396,6 +1429,7 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });
this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });
return;
return;
}
}
+ if (this._commandTextBlocked(ws, client, args)) return;
{
{
const ttsMsg = {
const ttsMsg = {
type: 'tts',
type: 'tts',
@@ -1415,7 +1449,7 @@ class ChatServer {
@@ -1415,7 +1449,7 @@ class ChatServer {
}
}
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
- this.broadcastToStream(client.streamId, ttsMsg);
+ this._broadcastToRoom(client, ttsMsg);
// Also synthesize server-side TTS for site-wide mode
// Also synthesize server-side TTS for site-wide mode
this.synthesizeAndBroadcastTTS(
this.synthesizeAndBroadcastTTS(
@@ -1488,8 +1522,9 @@ class ChatServer {
@@ -1488,8 +1522,9 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });
this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });
break;
break;
}
}
+ if (this._commandTextBlocked(ws, client, args)) break;
const username = client.user?.display_name || client.anonId;
const username = client.user?.display_name || client.anonId;
- this.broadcastToStream(client.streamId, {
+ this._broadcastToRoom(client, {
type: 'chat',
type: 'chat',
username,
username,
core_username: client.user?.username || null,
core_username: client.user?.username || null,
@@ -1515,7 +1550,7 @@ class ChatServer {
@@ -1515,7 +1550,7 @@ class ChatServer {
case 'clear':
case 'clear':
if (this.canModerate(client)) {
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');
this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');
} else {
} else {
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
@@ -1543,14 +1578,14 @@ class ChatServer {
@@ -1543,14 +1578,14 @@ class ChatServer {
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
// Dedicated slowmode event so clients can show/hide UI
// Dedicated slowmode event so clients can show/hide UI
- this.broadcastToStream(client.streamId, {
+ this._broadcastToRoom(client, {
type: 'slowmode',
type: 'slowmode',
seconds,
seconds,
});
});
const msg = seconds > 0
const msg = seconds > 0
? `Slow mode enabled: ${seconds}s between messages`
? `Slow mode enabled: ${seconds}s between messages`
: 'Slow mode disabled.';
: 'Slow mode disabled.';
- this.broadcastToStream(client.streamId, {
+ this._broadcastToRoom(client, {
type: 'system',
type: 'system',
message: msg,
message: msg,
});
});
@@ -1582,7 +1617,7 @@ class ChatServer {
@@ -1582,7 +1617,7 @@ class ChatServer {
const siteUrl = process.env.SITE_URL || '';
const siteUrl = process.env.SITE_URL || '';
const pasteUrl = `${siteUrl}/p/${paste.slug}`;
const pasteUrl = `${siteUrl}/p/${paste.slug}`;
// Show link to everyone in stream
// Show link to everyone in stream
- this.broadcastToStream(client.streamId, {
+ this._broadcastToRoom(client, {
type: 'system',
type: 'system',
message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,
message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,
});
});
@@ -1623,7 +1658,12 @@ class ChatServer {
@@ -1623,7 +1658,12 @@ class ChatServer {
if (!target) return;
if (!target) return;
// The stream whose moderators may do this (offline channel rooms: the latest stream).
// The stream whose moderators may do this (offline channel rooms: the latest stream).
const moderationStreamId = client.streamId || this._moderationStreamFor(client);
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) {
switch (action) {
case 'ban': {
case 'ban': {
@@ -1634,20 +1674,20 @@ class ChatServer {
@@ -1634,20 +1674,20 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });
this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });
return;
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.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
- this.broadcastToStream(client.streamId, {
+ this._broadcastToRoom(client, {
type: 'system', message: `${target} has been banned.`
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 {
} else {
// Ban by anon ID
// Ban by anon ID
const anonTarget = this.findClientByAnonId(target, client.streamId);
const anonTarget = this.findClientByAnonId(target, client.streamId);
if (anonTarget) {
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.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;
break;
}
}
@@ -1656,8 +1696,8 @@ class ChatServer {
@@ -1656,8 +1696,8 @@ class ChatServer {
const targetUser = await ctx.ensureUserByUsername(target);
const targetUser = await ctx.ensureUserByUsername(target);
const expires = new Date(Date.now() + duration * 1000).toISOString();
const expires = new Date(Date.now() + duration * 1000).toISOString();
if (targetUser) {
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.` });
this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });
break;
break;
@@ -1665,8 +1705,8 @@ class ChatServer {
@@ -1665,8 +1705,8 @@ class ChatServer {
case 'unban': {
case 'unban': {
const targetUser = await ctx.ensureUserByUsername(target);
const targetUser = await ctx.ensureUserByUsername(target);
if (targetUser) {
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.` });
this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });
break;
break;
@@ -2036,6 +2076,17 @@ class ChatServer {
@@ -2036,6 +2076,17 @@ class ChatServer {
}).catch(() => { /* best-effort */ });
}).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) {
broadcastToStream(streamId, data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
@@ -152,7 +152,45 @@ function containsRegexSlur(text, regexLines) {
@@ -152,7 +152,45 @@ function containsRegexSlur(text, regexLines) {
if (!normalized) return false;
if (!normalized) return false;
const candidates = [normalized, normalized.replace(/\s+/g, '')];
const candidates = [normalized, normalized.replace(/\s+/g, '')];
const compiled = compileRegexList(regexLines, { forceInsensitive: true });
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;
+ }
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -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());
@@ -278,8 +278,8 @@ t('upgrade guards: foreign origin refused, banned address refused (admins exempt
@@ -278,8 +278,8 @@ t('upgrade guards: foreign origin refused, banned address refused (admins exempt
await h.ctx.invalidateBans();
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.55' }), true, 'CIDR network ban');
assert.strictEqual(await h.wsRefused({ ip: '192.0.2.56', token: admin.token }), false, 'admin passes');
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.strictEqual(r.status, 403);
assert.deepStrictEqual(r.body, { error: 'Access denied' });
assert.deepStrictEqual(r.body, { error: 'Access denied' });
h.live.clearBans();
h.live.clearBans();