openvibers/openvibe.live Commits

Chat bans: a null stream id is site-wide and global staff only — offline channel-room /ban, /timeout, /unban are scoped to the channel's stream, a channel /unban never lifts a site-wide row, and /internal/chat-effects/ban re-checks both; nginx: client address headers only from $remote_addr (realip), never appended to or passed through from the client

OP OpenVibers committed 23 Sept 2026 · 8609fb116c · 4 files, +51 −17


Changes

ddiff --git a/deploy/nginx/openvibe.live.conf b/deploy/nginx/openvibe.live.conf
iindex f33c34d9..e4f370d3 100644
---- a/deploy/nginx/openvibe.live.conf
++++ b/deploy/nginx/openvibe.live.conf
@@@ -81,7 +81,8 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@@ -91,7 +92,8 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@@ -99,6 +101,9 @@ server {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
@@@ -107,6 +112,9 @@ server {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
expires 1h;
add_header Cache-Control "public, max-age=3600";
}
@@@ -118,7 +126,8 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
@@@ -135,7 +144,8 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@@ -159,7 +169,8 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@@ -168,9 +179,9 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
-- proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex ae77d68c..da22031c 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -1647,6 +1647,15 @@ class ChatServer {
const target = args.split(' ')[0];
if (!target) return;
++ // Where the ban applies. In an offline channel room it is that channel's latest 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 ? this._moderationStreamFor(client) : null);
++ if (!scopeStreamId && client.channelUserId) {
++ this.sendTo(ws, { type: 'system', message: 'This channel has no stream to moderate yet.' });
++ return;
++ }
++ const scoped = !!scopeStreamId;
switch (action) {
case 'ban': {
@@@ -1659,24 +1668,24 @@ class ChatServer {
}
db.run(
`INSERT INTO bans (stream_id, user_id, reason, banned_by) VALUES (?, ?, ?, ?)`,
-- [client.streamId, targetUser.id, 'Banned by moderator', client.user.id]
++ [scopeStreamId, targetUser.id, 'Banned by moderator', client.user.id]
);
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
this.broadcastToStream(client.streamId, {
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) {
db.run(
`INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)`,
-- [client.streamId, anonTarget.ip, target, 'Banned by moderator', client.user.id]
++ [scopeStreamId, anonTarget.ip, target, 'Banned by moderator', 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;
}
@@@ -1687,9 +1696,9 @@ class ChatServer {
if (targetUser) {
db.run(
`INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,
-- [client.streamId, targetUser.id, `Timeout ${duration}s`, client.user.id, expires]
++ [scopeStreamId, targetUser.id, `Timeout ${duration}s`, client.user.id, expires]
);
-- this.logChatModeration(client, client.streamId ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
++ 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;
@@@ -1697,9 +1706,10 @@ class ChatServer {
case 'unban': {
const targetUser = db.getUserByUsername(target);
if (targetUser) {
-- db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)',
-- [targetUser.id, client.streamId]);
-- this.logChatModeration(client, client.streamId ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
++ // A channel unban lifts that channel's rows only; site-wide rows are global chat's.
++ if (scoped) db.run('DELETE FROM bans WHERE user_id = ? AND stream_id = ?', [targetUser.id, scopeStreamId]);
++ else db.run('DELETE FROM bans WHERE user_id = ? AND stream_id IS NULL', [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;
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 53a72ce0..f3f80cb1 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -322,10 +322,15 @@ effectsRouter.post('/ban', (req, res) => {
const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));
if (!allowed) return fail(res, 403, 'You do not have permission.');
const streamId = b.stream_id != null ? int(b.stream_id) || null : null;
++ // A null stream id is a SITE-WIDE ban (or lifts one): global staff only. A channel moderator
++ // acts on the stream they moderate, never on another channel's.
++ const staff = permissions.isGlobalModOrAbove(actor);
++ if (!staff && (!streamId || !permissions.canModerateStream(actor, streamId))) return fail(res, 403, 'You do not have permission.');
if (b.action === 'unban') {
const userId = int(b.user_id);
if (!userId) return fail(res, 400, 'user_id required');
-- db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)', [userId, streamId]);
++ if (streamId) db.run('DELETE FROM bans WHERE user_id = ? AND stream_id = ?', [userId, streamId]);
++ else db.run('DELETE FROM bans WHERE user_id = ? AND stream_id IS NULL', [userId]);
return res.json({ ok: true });
}
if (b.action !== 'ban') return fail(res, 400, 'action must be ban or unban');
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 1d935cc5..bad54822 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -181,6 +181,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual(b2.bans.length, 1, 'the version moved with the table');
assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);
assert.ok(!db.isUserBanned(viewer, streamId));
++ // A null stream id is a site-wide ban: global staff only, and a channel unban never lifts one.
++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: null, user_id: viewer })).status, 403, 'a channel mod cannot ban site-wide');
++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: admin, stream_id: null, user_id: viewer, reason: 'site' })).status, 200);
++ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: null, user_id: viewer })).status, 403, 'a channel mod cannot lift a site-wide ban');
++ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);
++ assert.ok(db.get('SELECT 1 FROM bans WHERE user_id = ? AND stream_id IS NULL', [viewer]), 'the channel unban left the site-wide row');
++ assert.strictEqual((await ban({ action: 'unban', actor_user_id: admin, stream_id: null, user_id: viewer })).status, 200);
++ assert.ok(!db.get('SELECT 1 FROM bans WHERE user_id = ?', [viewer]));
// 7. TTS settings: admins; credentials only the owner.
const put = (actor, settingsBody) => call('POST', '/internal/chat-effects/site-settings', { token: WRITE, body: { actor_user_id: actor, settings: settingsBody } });