openvibers/openvibe.live Commits

Bans: network (CIDR) IP bans with the ban page, owner/admin exemption on shared networks, API-token ban fields

OP OpenVibers committed 12 Sept 2026 · 12c8c068ee · 5 files, +171 −31

- bans.ip_address may be a CIDR block (home /64, carrier prefix); compiled into net.BlockList, cached 15 s
- banned networks get the ban page (403) instead of a bare 404; name comes from the ban row's user
- admins pass IP/network bans (HTTP, WS upgrade, chat) so the owner is not locked out of a shared home network; SSO/assets/WHIP pass so an admin can sign in
- ban page: 'You have ruined me' block
- validateApiToken returns is_banned/ban_reason

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/banned.html b/public/banned.html
iindex 2930344a..1b194fe9 100644
---- a/public/banned.html
++++ b/public/banned.html
@@@ -18,6 +18,9 @@
h1 { margin: 22px 0 8px; font-size: clamp(1.3rem, 3.6vw, 1.9rem); }
.plea { margin: 26px 0 10px; font-size: clamp(2.2rem, 8.5vw, 4.6rem); line-height: 1.02; font-weight: 900; letter-spacing: -0.02em; color: #fff; text-shadow: 0 6px 30px rgba(0,0,0,0.6); }
.plea-body { font-size: clamp(1.25rem, 3.6vw, 1.9rem); line-height: 1.35; color: #fff; font-weight: 600; margin: 0 0 18px; }
++ .hurt { margin: 22px 0 8px; padding: 18px 16px 20px; border-radius: 16px; background: rgba(231,76,60,0.14); border: 2px solid rgba(231,76,60,0.7); box-shadow: 0 0 40px rgba(231,76,60,0.25) inset, 0 0 30px rgba(231,76,60,0.2); }
++ .hurt-head { font-size: clamp(1.9rem, 7vw, 3.6rem); line-height: 1.05; font-weight: 900; letter-spacing: -0.02em; color: #ff8a7a; text-shadow: 0 0 30px rgba(231,76,60,0.55); margin: 0 0 10px; }
++ .hurt-body { font-size: clamp(1.1rem, 3vw, 1.5rem); line-height: 1.4; color: #fff; font-weight: 700; margin: 0; }
.who { font-size: 1.05rem; color: #cfc9c4; margin: 0 0 6px; }
p { margin: 0 0 12px; font-size: 1.02rem; line-height: 1.55; color: #cfc9c4; }
p b { color: #fff; }
@@@ -32,10 +35,14 @@
<div class="stamp">BANNED</div>
<h1 class="plea">Please leave me alone.</h1>
<p class="plea-body">I'm trying to move forward with my life, and you have been nothing but disrespectful to me.</p>
++ <div class="hurt" role="alert">
++ <h2 class="hurt-head">You have ruined me.</h2>
++ <p class="hurt-body">What you did is hurtful. You did it, and you kept doing it. Please don't come here again.</p>
++ </div>
<p class="who">{{HEADLINE}}</p>
<div class="reason"><small>Reason on record</small>{{REASON}}</div>
<p><b>This ban is permanent.</b> Don't put any effort into coming back — there is nothing here for you, and this decision won't change.</p>
-- <p class="fine"><span class="logo">OpenVibe.Live</span> · you will see this page every time you open the site.</p>
++ <p class="fine"><span class="logo">OpenVibe.Live</span> · you will see this page every time you open the site, on every device and every network you use.</p>
</div>
</body>
</html>
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 3aa913b8..fab56ee7 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -95,6 +95,9 @@ class ChatServer {
* Prefers CF-Connecting-IP (set by Cloudflare, unforgeable through proxy),
* then X-Forwarded-For first entry, then socket remote address.
*/
++ /** Admins (the site owner) pass IP / network bans — they may share a home network with a banned person. */
++ _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()
@@@ -587,7 +590,7 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
return;
}
-- if (db.isIpBanned(client.ip, client.streamId)) {
++ if (!this._isBanExemptAdmin(client) && db.isIpBanned(client.ip, client.streamId)) {
this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
return;
}
@@@ -1226,7 +1229,7 @@ class ChatServer {
// Banned users can't trigger sounds
if (client.user && db.isUserBanned(client.user.id, client.streamId)) return;
-- if (client.ip && db.isIpBanned(client.ip, client.streamId)) return;
++ if (client.ip && !this._isBanExemptAdmin(client) && db.isIpBanned(client.ip, client.streamId)) return;
// Rate limits — reuse the soundboard limiter (per-user + per-stream window)
const now = Date.now();
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 27d031ec..bcd2b3e3 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -5905,15 +5905,59 @@ function isUserBanned(userId, streamId) {
return !!ban;
}
--function isIpBanned(ip, streamId) {
++/**
++ * IP bans: a `bans.ip_address` value is either one address (exact match) or a CIDR block
++ * (`2601:601:9181:bb00::/64`, `203.0.113.0/24`) — a whole home network / carrier prefix.
++ * CIDR rows are compiled into net.BlockList and cached briefly; exact rows stay a lookup.
++ */
++const _net = require('net');
++let _cidrBans = { at: 0, list: [] };
++function _normalizeBanIp(ip) {
++ let s = String(ip || '').trim();
++ if (s.startsWith('::ffff:') && _net.isIP(s.slice(7)) === 4) s = s.slice(7);
++ return s;
++}
++function _cidrBanList() {
++ if (_cidrBans.list && Date.now() - _cidrBans.at < 15000) return _cidrBans.list;
++ const rows = all(`SELECT * FROM bans WHERE ip_address LIKE '%/%' AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)`);
++ const list = [];
++ for (const r of rows) {
++ const [addr, bitsStr] = String(r.ip_address).split('/');
++ const fam = _net.isIP(addr), bits = parseInt(bitsStr, 10);
++ if (!fam || !Number.isFinite(bits)) continue;
++ const bl = new _net.BlockList();
++ try { bl.addSubnet(addr, bits, fam === 6 ? 'ipv6' : 'ipv4'); } catch { continue; }
++ list.push({ bl, fam, row: r });
++ }
++ _cidrBans = { at: Date.now(), list };
++ return list;
++}
++function invalidateIpBanCache() { _cidrBans = { at: 0, list: [] }; }
++
++/** The active site-wide (or this stream's) ban row for an IP, or null. */
++function getIpBan(ip, streamId) {
++ const norm = _normalizeBanIp(ip);
++ if (!norm) return null;
const ban = get(`
SELECT * FROM bans
-- WHERE ip_address = ?
++ WHERE ip_address IN (?, ?)
AND (stream_id = ? OR stream_id IS NULL)
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
LIMIT 1
-- `, [ip, streamId]);
-- return !!ban;
++ `, [String(ip), norm, streamId]);
++ if (ban) return ban;
++ const fam = _net.isIP(norm);
++ if (!fam) return null;
++ for (const e of _cidrBanList()) {
++ if (e.fam !== fam) continue;
++ if (e.row.stream_id !== null && e.row.stream_id !== undefined && e.row.stream_id !== streamId) continue;
++ if (e.bl.check(norm, fam === 6 ? 'ipv6' : 'ipv4')) return e.row;
++ }
++ return null;
++}
++
++function isIpBanned(ip, streamId) {
++ return !!getIpBan(ip, streamId);
}
// ── Cleanup ──────────────────────────────────────────────────
@@@ -7986,7 +8030,7 @@ function revokeApiToken(tokenId, userId) {
function validateApiToken(rawToken) {
const hash = _hashToken(rawToken);
const row = get(
-- `SELECT t.*, u.id as uid, u.username, u.display_name, u.role, u.profile_color, u.avatar_url
++ `SELECT t.*, u.id as uid, u.username, u.display_name, u.role, u.profile_color, u.avatar_url, u.is_banned, u.ban_reason
FROM api_tokens t JOIN users u ON t.user_id = u.id
WHERE t.token_hash = ? AND t.is_active = 1`,
[hash]
@@@ -8000,6 +8044,7 @@ function validateApiToken(rawToken) {
return {
id: row.uid, username: row.username, display_name: row.display_name,
role: row.role, profile_color: row.profile_color, avatar_url: row.avatar_url,
++ is_banned: row.is_banned ? 1 : 0, ban_reason: row.ban_reason || null,
tokenId: row.id, scopes,
};
}
@@@ -8163,7 +8208,7 @@ module.exports = {
getControlConfigs, getControlConfig, createControlConfig, updateControlConfig, deleteControlConfig,
getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,
// Bans
-- isUserBanned, isIpBanned,
++ isUserBanned, isIpBanned, getIpBan, invalidateIpBanCache,
// Emotes
createEmote, getEmoteById, getEmotesByUser, getGlobalEmotes, getChannelEmotes, updateEmote,
deleteEmote, getEmoteByCode, countUserEmotes, countChannelEmotes, getChannelEmoteByCode,
ddiff --git a/server/index.js b/server/index.js
iindex 73b32d7d..d90f7987 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -350,20 +350,61 @@ app.use('/api/vods/clips', uploadLimiter);
// ── IP Ban Enforcement ───────────────────────────────────────
// Check if the requester's IP is globally banned. If so, return 404 for page requests
// and 403 for API requests. This makes the site appear to not exist for banned IPs.
++// ── Ban page + owner exemption ───────────────────────────────
++const escBanHtml = (t) => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++const DEFAULT_BAN_REASON = 'Repeated disrespect toward the owner of this site. Permanent.';
++/** Resolve the signed-in user for an HTTP request (token cookie / Bearer / API token), cached per request. */
++function banRequestUser(req) {
++ if (req._ovBanUser !== undefined) return req._ovBanUser;
++ let user = null;
++ try {
++ const { extractToken, verifyToken, resolveNetworkUser, authenticateApiToken } = require('./auth/auth');
++ const token = extractToken(req);
++ if (token) user = authenticateApiToken(token) || (() => { const d = verifyToken(token); return d ? resolveNetworkUser(d) : null; })();
++ } catch { user = null; }
++ req._ovBanUser = user || null;
++ return req._ovBanUser;
++}
++/** Admins (the site owner) pass IP / network bans — they may live on the same network as a banned person. */
++function isBanExemptAdmin(req) { const u = banRequestUser(req); return !!(u && !u.is_banned && u.role === 'admin'); }
++function isBanExemptAdminUser(u) { return !!(u && !u.is_banned && u.role === 'admin'); }
++/** Paths a banned network may still reach: health, the ban page's assets, SSO (so an admin can sign in), WHIP (stream-key auth, checks bans itself). */
++function banPassPath(p) {
++ return p === '/api/health' || p.startsWith('/assets/') || p.startsWith('/api/auth/sso') || p === '/api/auth/callback' || p === '/api/auth/logout' || p.startsWith('/whip');
++}
++function banNameFor(ban) {
++ try { if (ban && ban.user_id) { const u = db.getUserById(ban.user_id); if (u) return u.display_name || u.username; } } catch { /* */ }
++ return null;
++}
++function renderBannedPage(req, res, { name, reason } = {}) {
++ let html = '';
++ try { html = require('fs').readFileSync(path.join(__dirname, '../public/banned.html'), 'utf8'); } catch { html = '<h1>Banned</h1>'; }
++ html = html
++ .replace('{{HEADLINE}}', name ? `${escBanHtml(name)}, you are banned from OpenVibe.Live.` : 'You are banned from OpenVibe.Live.')
++ .replace('{{REASON}}', escBanHtml(reason || DEFAULT_BAN_REASON));
++ res.status(403).set('Cache-Control', 'no-store').type('html').send(html);
++}
++
app.use((req, res, next) => {
// Skip health check so monitoring still works
if (req.url === '/api/health') return next();
++ // Banned network (single address or a whole home / carrier block). The site owner and admins
++ // may share an address with a banned person, so a request that authenticates as a non-banned
++ // admin passes; SSO + assets pass so an admin can sign in from a banned network and the ban
++ // page can render. Everyone else on that network gets the ban screen (API: 403).
try {
-- if (db.isIpBanned(req.ip, null)) {
-- if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {
++ const ipBan = db.getIpBan(req.ip, null);
++ if (ipBan && !banPassPath(req.path) && !isBanExemptAdmin(req)) {
++ if (req.path.startsWith('/api/') || req.path.startsWith('/ws/')) {
return res.status(403).json({ error: 'Access denied' });
}
-- return res.status(404).send('<!DOCTYPE html><html><head><title>404</title></head><body><h1>404 Not Found</h1></body></html>');
++ return renderBannedPage(req, res, { name: banNameFor(ipBan), reason: ipBan.reason });
}
} catch (e) { /* DB error — let request through rather than block everyone */ }
// A browser that has been shown the ban screen keeps seeing it (cookie set by GET /banned),
// signed in or not. Assets still load so the page itself can render.
-- if (req.cookies && req.cookies.ov_banned === '1' && !req.path.startsWith('/banned') && !req.path.startsWith('/assets/') && req.path !== '/api/health') {
++ if (req.cookies && req.cookies.ov_banned === '1' && !req.path.startsWith('/banned') && !banPassPath(req.path)) {
++ if (isBanExemptAdmin(req)) { res.clearCookie('ov_banned'); res.clearCookie('ov_banned_name'); return next(); }
if (req.path.startsWith('/api/') || req.path.startsWith('/ws/')) return res.status(403).json({ error: 'Account is banned' });
return res.redirect(302, '/banned');
}
@@@ -734,14 +775,9 @@ app.get(['/popout', '/popout/*', '/popout-chat', '/popout-chat/*'], (req, res) =
// loadUser in public/js/app.js). The page is rendered with the banned user's name and the
// reason on record, so they see exactly why every time they open the site.
app.get('/banned', (req, res) => {
-- const escHtml = (t) => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
-- let user = null;
-- try {
-- const { extractToken, verifyToken, resolveNetworkUser, authenticateApiToken } = require('./auth/auth');
-- const token = extractToken(req);
-- if (token) { user = authenticateApiToken(token) || (() => { const d = verifyToken(token); return d ? resolveNetworkUser(d) : null; })(); }
-- } catch { user = null; }
-- if (user && !user.is_banned) { res.clearCookie('ov_banned'); res.clearCookie('ov_banned_name'); return res.redirect('/'); }
++ const user = banRequestUser(req);
++ const ipBan = (() => { try { return db.getIpBan(req.ip, null); } catch { return null; } })();
++ if (user && !user.is_banned && (!ipBan || isBanExemptAdminUser(user))) { res.clearCookie('ov_banned'); res.clearCookie('ov_banned_name'); return res.redirect('/'); }
// Make the ban stick to this browser: from now on every visit — signed in or not — lands here.
const isSecure = String(config.baseUrl || '').startsWith('https');
const tenYears = 10 * 365 * 24 * 3600 * 1000;
@@@ -749,14 +785,10 @@ app.get('/banned', (req, res) => {
res.cookie('ov_banned', '1', { httpOnly: true, maxAge: tenYears, sameSite: 'Lax', secure: isSecure });
res.cookie('ov_banned_name', String(user.display_name || user.username).slice(0, 60), { httpOnly: true, maxAge: tenYears, sameSite: 'Lax', secure: isSecure });
}
-- const name = user ? (user.display_name || user.username) : (req.cookies && req.cookies.ov_banned_name ? String(req.cookies.ov_banned_name) : null);
-- const reason = (user && user.ban_reason) || 'Repeated disrespect toward the owner of this site. Permanent.';
-- let html = '';
-- try { html = require('fs').readFileSync(path.join(__dirname, '../public/banned.html'), 'utf8'); } catch { html = '<h1>Banned</h1>'; }
-- html = html
-- .replace('{{HEADLINE}}', name ? `${escHtml(name)}, you are banned from OpenVibe.Live.` : 'You are banned from OpenVibe.Live.')
-- .replace('{{REASON}}', escHtml(reason));
-- res.status(403).set('Cache-Control', 'no-store').type('html').send(html);
++ const name = (user && user.is_banned) ? (user.display_name || user.username)
++ : (req.cookies && req.cookies.ov_banned_name ? String(req.cookies.ov_banned_name) : banNameFor(ipBan));
++ const reason = (user && user.is_banned && user.ban_reason) || (ipBan && ipBan.reason) || DEFAULT_BAN_REASON;
++ renderBannedPage(req, res, { name, reason });
});
// ── SPA Fallback ─────────────────────────────────────────────
@@@ -793,8 +825,10 @@ server.on('upgrade', (req, socket, head) => {
try {
const wsIp = chatServer.getClientIp(req);
if (db.isIpBanned(wsIp, null)) {
-- socket.destroy();
-- return;
++ // Admins pass network bans (shared home network) — see isBanExemptAdmin.
++ let exempt = false;
++ try { const { extractWsToken, authenticateWs } = require('./auth/auth'); exempt = isBanExemptAdminUser(authenticateWs(extractWsToken(req))); } catch { exempt = false; }
++ if (!exempt) { socket.destroy(); return; }
}
} catch (e) { /* non-critical — allow through on DB error */ }
ddiff --git a/test/ip-ban.test.js b/test/ip-ban.test.js
nnew file mode 100644
iindex 00000000..385a161b
---- /dev/null
++++ b/test/ip-ban.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++// IP / network bans: a bans.ip_address row is one address (exact) or a CIDR block (a whole
++// home network), and the site owner (admin) passes both so a shared home network can be banned
++// without locking the owner out. Runs on a temp DB.
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ipban-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++const db = require('../server/db/database');
++db.initDb();
++
++const uid = Number(db.createUser({ username: 'meanie', email: 'm@x', password_hash: 'x', display_name: 'Meanie', stream_key: 'k'.repeat(32) }).lastInsertRowid);
++db.run(`INSERT INTO bans (user_id, ip_address, reason) VALUES (?, ?, ?)`, [uid, '2601:601:9181:bb00::/64', 'home network']);
++db.run(`INSERT INTO bans (ip_address, reason) VALUES (?, ?)`, ['67.170.61.128', 'home v4']);
++db.run(`INSERT INTO bans (ip_address, reason) VALUES (?, ?)`, ['2600:387:15:3737::/64', 'phone']);
++db.run(`INSERT INTO bans (ip_address, reason, expires_at) VALUES (?, ?, datetime('now', '-1 day'))`, ['10.0.0.0/8', 'expired']);
++db.invalidateIpBanCache();
++
++// CIDR: every address in the home /64 is banned, the neighbouring /64 is not.
++assert.ok(db.isIpBanned('2601:601:9181:bb00:c023:ff0f:38ee:1559', null));
++assert.ok(db.isIpBanned('2601:601:9181:bb00::1', null));
++assert.ok(!db.isIpBanned('2601:601:9181:bb01::1', null));
++assert.ok(db.isIpBanned('2600:387:15:3737::5', null));
++assert.ok(!db.isIpBanned('2600:387:15:3738::5', null));
++// Exact v4, plus the IPv4-mapped form a proxy may hand us.
++assert.ok(db.isIpBanned('67.170.61.128', null));
++assert.ok(db.isIpBanned('::ffff:67.170.61.128', null));
++assert.ok(!db.isIpBanned('67.170.61.129', null));
++// Expired CIDR rows don't count; garbage input never throws.
++assert.ok(!db.isIpBanned('10.1.2.3', null));
++assert.ok(!db.isIpBanned('', null));
++assert.ok(!db.isIpBanned('not-an-ip', null));
++assert.ok(!db.isIpBanned(undefined, null));
++// The ban row (and so the name on the ban page) comes back for CIDR hits.
++assert.strictEqual(db.getIpBan('2601:601:9181:bb00::abcd', null).user_id, uid);
++assert.strictEqual(db.getIpBan('67.170.61.128', null).reason, 'home v4');
++
++// Stream-scoped CIDR ban only applies to that stream.
++const sid = Number(db.createStream({ user_id: uid, title: 't', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
++db.run(`INSERT INTO bans (ip_address, stream_id, reason) VALUES (?, ?, ?)`, ['203.0.113.0/24', sid, 'one stream']);
++db.invalidateIpBanCache();
++assert.ok(db.isIpBanned('203.0.113.9', sid));
++assert.ok(!db.isIpBanned('203.0.113.9', null));
++assert.ok(!db.isIpBanned('203.0.113.9', sid + 1));
++
++console.log('ip-ban: OK');