From 12696452aa97d60a2c23a17670e89758cd58f6ec Mon Sep 17 00:00:00 2001 From: Speng Reb Date: Thu, 21 May 2026 16:13:56 +0200 Subject: [PATCH 1/3] Fix shows/bot API auth gaps, handle missing channels as 404, make recurrence DST-safe, and clear lint regressions --- src/database.js | 6 +-- src/shows.js | 88 ++++++++++++++++++++++++++++++-- src/web/routes/api/emotes.js | 8 +-- src/web/routes/api/middleware.js | 1 + src/web/routes/api/shows.js | 5 ++ 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/src/database.js b/src/database.js index 53c6a5d9..01e3e2be 100644 --- a/src/database.js +++ b/src/database.js @@ -39,7 +39,7 @@ class Database { database: Config.get('mysql.database'), multipleStatements: true, // Legacy thing charset: 'utf8mb4' - } + }; } else { connection = { host: Config.get('mysql.server'), @@ -49,9 +49,9 @@ class Database { database: Config.get('mysql.database'), multipleStatements: true, // Legacy thing charset: 'utf8mb4' - } + }; } - + knexConfig = { client: 'mysql', connection, diff --git a/src/shows.js b/src/shows.js index b7d0d3fe..7fa04864 100644 --- a/src/shows.js +++ b/src/shows.js @@ -22,15 +22,93 @@ function makeSystemProxy(name) { function computeNextRunAt(show) { const base = Number(show.next_run_at || show.scheduled_for || Date.now()); - if (show.recurrence === 'daily') { - return base + 24 * 60 * 60 * 1000; + const recurrence = show.recurrence; + const timezone = show.timezone || 'UTC'; + + if (recurrence !== 'daily' && recurrence !== 'weekly') { + return base; } - if (show.recurrence === 'weekly') { - return base + 7 * 24 * 60 * 60 * 1000; + const daysToAdd = recurrence === 'weekly' ? 7 : 1; + const source = new Date(base); + const local = toZonedParts(source, timezone); + if (!local) { + return base + (daysToAdd * 24 * 60 * 60 * 1000); } - return base; + const targetDate = addDaysUTC(local.year, local.month, local.day, daysToAdd); + const zonedTarget = { + year: targetDate.year, + month: targetDate.month, + day: targetDate.day, + hour: local.hour, + minute: local.minute, + second: local.second + }; + + const next = zonedDateTimeToUtc(zonedTarget, timezone); + return next || (base + (daysToAdd * 24 * 60 * 60 * 1000)); +} + +function toZonedParts(date, timezone) { + try { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + const parts = dtf.formatToParts(date); + const out = {}; + for (const part of parts) { + if (part.type === 'literal') continue; + out[part.type] = parseInt(part.value, 10); + } + + return { + year: out.year, + month: out.month, + day: out.day, + hour: out.hour, + minute: out.minute, + second: out.second + }; + } catch (_err) { + return null; + } +} + +function addDaysUTC(year, month, day, days) { + const d = new Date(Date.UTC(year, month - 1, day + days)); + return { + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate() + }; +} + +function zonedDateTimeToUtc(local, timezone) { + let guess = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second); + + // Iterate to resolve timezone offset for the target wall-clock time (handles DST shifts). + for (let i = 0; i < 4; i++) { + const zoned = toZonedParts(new Date(guess), timezone); + if (!zoned) return null; + + const desired = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second); + const current = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, zoned.second); + const delta = desired - current; + if (delta === 0) { + return guess; + } + guess += delta; + } + + return guess; } function normalizePlaylist(rawPlaylist) { diff --git a/src/web/routes/api/emotes.js b/src/web/routes/api/emotes.js index 7f8b2dd0..183e2c61 100644 --- a/src/web/routes/api/emotes.js +++ b/src/web/routes/api/emotes.js @@ -26,8 +26,11 @@ async function getChannelEmotes(channelId) { (err, rows) => { if (err) return reject(new Error(err)); if (!rows || rows.length === 0) return resolve([]); - try { resolve(JSON.parse(rows[0].value)); } - catch (e) { resolve([]); } + try { + resolve(JSON.parse(rows[0].value)); + } catch (e) { + resolve([]); + } } ); }); @@ -92,7 +95,6 @@ router.put('/:name', botAuth, requireRank(4), async (req, res) => { return res.status(409).json({ error: 'An emote with that name already exists' }); } - const old = emotes[idx]; emotes[idx] = validated; await saveChannelEmotes(req.bot.channel_id, emotes); diff --git a/src/web/routes/api/middleware.js b/src/web/routes/api/middleware.js index 229ce475..8c947aec 100644 --- a/src/web/routes/api/middleware.js +++ b/src/web/routes/api/middleware.js @@ -63,6 +63,7 @@ async function getChannelRow(channelName) { return new Promise((resolve, reject) => { db.channels.lookup(channelName, (err, row) => { if (err) reject(new Error(err)); + else if (!row) reject(new Error('Channel not found')); else resolve(row); }); }); diff --git a/src/web/routes/api/shows.js b/src/web/routes/api/shows.js index d66fad49..b4ada722 100644 --- a/src/web/routes/api/shows.js +++ b/src/web/routes/api/shows.js @@ -138,6 +138,11 @@ async function authorizeChannel(req, res) { return null; } + if (bot.rank < 2) { + res.status(403).json({ error: 'Insufficient rank' }); + return null; + } + return { actorName: bot.name, rank: bot.rank, From 6eeee342d7138e9d7f2023dfcae29c13ee7ff745 Mon Sep 17 00:00:00 2001 From: Speng Reb Date: Thu, 21 May 2026 16:23:30 +0200 Subject: [PATCH 2/3] Protect /api/v1 mutations with CSRF for cookie auth while exempting cbt_ bearer bot tokens and wiring UI X-CSRF-Token headers --- src/web/csrf.js | 4 +++- src/web/routes/api/index.js | 23 +++++++++++++++++++++++ templates/head.pug | 2 ++ www/js/ui.js | 21 +++++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/web/csrf.js b/src/web/csrf.js index 2fd6fa66..9c2e8cd3 100644 --- a/src/web/csrf.js +++ b/src/web/csrf.js @@ -37,7 +37,9 @@ exports.init = function csrfInit (domain) { exports.verify = function csrfVerify(req) { var secret = req.signedCookies._csrf; - var token = req.body._csrf || req.query._csrf; + var token = (req.body && req.body._csrf) || + (req.query && req.query._csrf) || + req.header('x-csrf-token'); if (!tokens.verify(secret, token)) { throw new CSRFError('Invalid CSRF token'); diff --git a/src/web/routes/api/index.js b/src/web/routes/api/index.js index 79d8aee4..044c1b62 100644 --- a/src/web/routes/api/index.js +++ b/src/web/routes/api/index.js @@ -1,7 +1,30 @@ const express = require('express'); +const csrf = require('../../csrf'); const router = express.Router(); +function isMutatingMethod(method) { + return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE'; +} + +function hasBotBearerToken(req) { + const authHeader = req.headers && req.headers.authorization; + return typeof authHeader === 'string' && /^Bearer\s+cbt_/.test(authHeader); +} + +router.use((req, res, next) => { + if (!isMutatingMethod(req.method) || hasBotBearerToken(req)) { + return next(); + } + + try { + csrf.verify(req); + next(); + } catch (_err) { + return res.status(403).json({ error: 'Invalid CSRF token' }); + } +}); + router.use('/channels/:channel/bots', require('./bots')); router.use('/channels/:channel/emotes', require('./emotes')); router.use('/channels/:channel/playlist', require('./playlist')); diff --git a/templates/head.pug b/templates/head.pug index 597e977b..df82619b 100644 --- a/templates/head.pug +++ b/templates/head.pug @@ -15,10 +15,12 @@ mixin head() var DEFAULT_THEME = '#{DEFAULT_THEME}'; var CHANNELPATH = '#{channelPath}'; var CHANNELNAME = '#{channelName}'; + var CSRF_TOKEN = '#{csrfToken}'; else script(type="text/javascript"). var DEFAULT_THEME = '#{DEFAULT_THEME}'; var CHANNELPATH = '#{channelPath}'; + var CSRF_TOKEN = '#{csrfToken}'; script(src="/js/theme.js") //[if lt IE 9] diff --git a/www/js/ui.js b/www/js/ui.js index 665fe1b0..ae14b22a 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -1257,6 +1257,27 @@ $("#resize-video-smaller").on('click', function () { } }); +$.ajaxPrefilter(function (options, _originalOptions, _jqXHR) { + var url = String(options.url || ''); + if (!/\/api\/v1\//.test(url)) { + return; + } + + var method = String(options.type || options.method || 'GET').toUpperCase(); + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { + return; + } + + options.headers = options.headers || {}; + if (options.headers.Authorization || options.headers.authorization) { + return; + } + + if (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) { + options.headers['X-CSRF-Token'] = CSRF_TOKEN; + } +}); + var CSTBots = (function () { function apiBase() { return '/api/v1/channels/' + CHANNEL.name; From 36da4bdff10d0d1e6bb2b4ca930efee620e019ee Mon Sep 17 00:00:00 2001 From: Speng Reb Date: Thu, 21 May 2026 16:25:34 +0200 Subject: [PATCH 3/3] Harden API and session security: enforce CSRF on cookie-auth /api/v1 writes, exempt bot bearer tokens, and set SameSite=Lax + conditional Secure on auth/CSRF/ip-session cookies --- src/web/csrf.js | 5 ++++- src/web/middleware/ipsessioncookie.js | 5 ++++- src/web/webserver.js | 22 ++++++++++++---------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/web/csrf.js b/src/web/csrf.js index 9c2e8cd3..b37ceafc 100644 --- a/src/web/csrf.js +++ b/src/web/csrf.js @@ -13,10 +13,13 @@ exports.init = function csrfInit (domain) { var secret = req.signedCookies._csrf; if (!secret) { secret = tokens.secretSync(); + const secure = req.realProtocol === 'https' || req.secure === true; res.cookie("_csrf", secret, { domain: domain, signed: true, - httpOnly: true + httpOnly: true, + sameSite: 'lax', + secure }); } diff --git a/src/web/middleware/ipsessioncookie.js b/src/web/middleware/ipsessioncookie.js index be0041b0..9dcdfe7b 100644 --- a/src/web/middleware/ipsessioncookie.js +++ b/src/web/middleware/ipsessioncookie.js @@ -38,10 +38,13 @@ export function ipSessionCookieMiddleware(req, res, next) { } if (!hasSession) { + const secure = req.realProtocol === 'https' || req.secure === true; res.cookie('ip-session', createIPSessionCookie(req.realIP, firstSeen), { signed: true, httpOnly: true, - expires: NO_EXPIRATION + expires: NO_EXPIRATION, + sameSite: 'lax', + secure }); } diff --git a/src/web/webserver.js b/src/web/webserver.js index f6f739c9..8d9ac9c6 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -241,21 +241,23 @@ module.exports = { }, setAuthCookie: function setAuthCookie(req, res, expiration, auth) { + const secure = req.realProtocol === 'https' || req.secure === true; + const baseCookieOptions = { + expires: expiration, + httpOnly: true, + signed: true, + sameSite: 'lax', + secure + }; + if (req.hostname.indexOf(Config.get("http.root-domain")) >= 0) { // Prevent non-root cookie from screwing things up res.clearCookie("auth"); - res.cookie("auth", auth, { + res.cookie("auth", auth, Object.assign({}, baseCookieOptions, { domain: Config.get("http.root-domain-dotted"), - expires: expiration, - httpOnly: true, - signed: true - }); + })); } else { - res.cookie("auth", auth, { - expires: expiration, - httpOnly: true, - signed: true - }); + res.cookie("auth", auth, baseCookieOptions); } } };