openvibers/openvibe.live Commits
Bans: network (CIDR) IP bans with the ban page, owner/admin exemption on shared networks, API-token ban fields
Changes
@@@ -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>@@@ -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 soundsif (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();@@@ -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_reasonFROM api_tokens t JOIN users u ON t.user_id = u.idWHERE 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,// EmotescreateEmote, getEmoteById, getEmotesByUser, getGlobalEmotes, getChannelEmotes, updateEmote,deleteEmote, getEmoteByCode, countUserEmotes, countChannelEmotes, getChannelEmoteByCode,@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 worksif (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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 */ }nnew file mode 100644@@@ -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');
@@ -18,6 +18,9 @@
@@ -18,6 +18,9 @@
h1 { margin: 22px 0 8px; font-size: clamp(1.3rem, 3.6vw, 1.9rem); }
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 { 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; }
.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; }
.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 { margin: 0 0 12px; font-size: 1.02rem; line-height: 1.55; color: #cfc9c4; }
p b { color: #fff; }
p b { color: #fff; }
@@ -32,10 +35,14 @@
@@ -32,10 +35,14 @@
<div class="stamp">BANNED</div>
<div class="stamp">BANNED</div>
<h1 class="plea">Please leave me alone.</h1>
<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>
<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>
<p class="who">{{HEADLINE}}</p>
<div class="reason"><small>Reason on record</small>{{REASON}}</div>
<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><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>
</div>
</body>
</body>
</html>
</html>
@@ -95,6 +95,9 @@ class ChatServer {
@@ -95,6 +95,9 @@ class ChatServer {
* Prefers CF-Connecting-IP (set by Cloudflare, unforgeable through proxy),
* Prefers CF-Connecting-IP (set by Cloudflare, unforgeable through proxy),
* then X-Forwarded-For first entry, then socket remote address.
* 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) {
getClientIp(req) {
const raw = req.headers?.['cf-connecting-ip']
const raw = req.headers?.['cf-connecting-ip']
|| req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
|| req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
@@ -587,7 +590,7 @@ class ChatServer {
@@ -587,7 +590,7 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
return;
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.' });
this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
return;
return;
}
}
@@ -1226,7 +1229,7 @@ class ChatServer {
@@ -1226,7 +1229,7 @@ class ChatServer {
// Banned users can't trigger sounds
// Banned users can't trigger sounds
if (client.user && db.isUserBanned(client.user.id, client.streamId)) return;
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)
// Rate limits — reuse the soundboard limiter (per-user + per-stream window)
const now = Date.now();
const now = Date.now();
@@ -5905,15 +5905,59 @@ function isUserBanned(userId, streamId) {
@@ -5905,15 +5905,59 @@ function isUserBanned(userId, streamId) {
return !!ban;
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(`
const ban = get(`
SELECT * FROM bans
SELECT * FROM bans
- WHERE ip_address = ?
+ WHERE ip_address IN (?, ?)
AND (stream_id = ? OR stream_id IS NULL)
AND (stream_id = ? OR stream_id IS NULL)
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
LIMIT 1
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 ──────────────────────────────────────────────────
// ── Cleanup ──────────────────────────────────────────────────
@@ -7986,7 +8030,7 @@ function revokeApiToken(tokenId, userId) {
@@ -7986,7 +8030,7 @@ function revokeApiToken(tokenId, userId) {
function validateApiToken(rawToken) {
function validateApiToken(rawToken) {
const hash = _hashToken(rawToken);
const hash = _hashToken(rawToken);
const row = get(
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
FROM api_tokens t JOIN users u ON t.user_id = u.id
WHERE t.token_hash = ? AND t.is_active = 1`,
WHERE t.token_hash = ? AND t.is_active = 1`,
[hash]
[hash]
@@ -8000,6 +8044,7 @@ function validateApiToken(rawToken) {
@@ -8000,6 +8044,7 @@ function validateApiToken(rawToken) {
return {
return {
id: row.uid, username: row.username, display_name: row.display_name,
id: row.uid, username: row.username, display_name: row.display_name,
role: row.role, profile_color: row.profile_color, avatar_url: row.avatar_url,
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,
tokenId: row.id, scopes,
};
};
}
}
@@ -8163,7 +8208,7 @@ module.exports = {
@@ -8163,7 +8208,7 @@ module.exports = {
getControlConfigs, getControlConfig, createControlConfig, updateControlConfig, deleteControlConfig,
getControlConfigs, getControlConfig, createControlConfig, updateControlConfig, deleteControlConfig,
getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,
getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,
// Bans
// Bans
- isUserBanned, isIpBanned,
+ isUserBanned, isIpBanned, getIpBan, invalidateIpBanCache,
// Emotes
// Emotes
createEmote, getEmoteById, getEmotesByUser, getGlobalEmotes, getChannelEmotes, updateEmote,
createEmote, getEmoteById, getEmotesByUser, getGlobalEmotes, getChannelEmotes, updateEmote,
deleteEmote, getEmoteByCode, countUserEmotes, countChannelEmotes, getChannelEmoteByCode,
deleteEmote, getEmoteByCode, countUserEmotes, countChannelEmotes, getChannelEmoteByCode,
@@ -350,20 +350,61 @@ app.use('/api/vods/clips', uploadLimiter);
@@ -350,20 +350,61 @@ app.use('/api/vods/clips', uploadLimiter);
// ── IP Ban Enforcement ───────────────────────────────────────
// ── IP Ban Enforcement ───────────────────────────────────────
// Check if the requester's IP is globally banned. If so, return 404 for page requests
// 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.
// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => {
app.use((req, res, next) => {
// Skip health check so monitoring still works
// Skip health check so monitoring still works
if (req.url === '/api/health') return next();
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 {
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(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 */ }
} 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),
// 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.
// 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' });
if (req.path.startsWith('/api/') || req.path.startsWith('/ws/')) return res.status(403).json({ error: 'Account is banned' });
return res.redirect(302, '/banned');
return res.redirect(302, '/banned');
}
}
@@ -734,14 +775,9 @@ app.get(['/popout', '/popout/*', '/popout-chat', '/popout-chat/*'], (req, res) =
@@ -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
// 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.
// reason on record, so they see exactly why every time they open the site.
app.get('/banned', (req, res) => {
app.get('/banned', (req, res) => {
- const escHtml = (t) => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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.
// 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 isSecure = String(config.baseUrl || '').startsWith('https');
const tenYears = 10 * 365 * 24 * 3600 * 1000;
const tenYears = 10 * 365 * 24 * 3600 * 1000;
@@ -749,14 +785,10 @@ app.get('/banned', (req, res) => {
@@ -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', '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 });
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 ─────────────────────────────────────────────
// ── SPA Fallback ─────────────────────────────────────────────
@@ -793,8 +825,10 @@ server.on('upgrade', (req, socket, head) => {
@@ -793,8 +825,10 @@ server.on('upgrade', (req, socket, head) => {
try {
try {
const wsIp = chatServer.getClientIp(req);
const wsIp = chatServer.getClientIp(req);
if (db.isIpBanned(wsIp, null)) {
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 */ }
} catch (e) { /* non-critical — allow through on DB error */ }
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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');