Merge pull request #14 from Spengreb/broadspectrum-code-analysis

Update to node 20 and jQuery 3
This commit is contained in:
Spengreb 2026-05-21 21:18:27 +02:00 committed by GitHub
commit 2788dae3c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 166 additions and 24 deletions

View file

@ -39,7 +39,7 @@ class Database {
database: Config.get('mysql.database'), database: Config.get('mysql.database'),
multipleStatements: true, // Legacy thing multipleStatements: true, // Legacy thing
charset: 'utf8mb4' charset: 'utf8mb4'
} };
} else { } else {
connection = { connection = {
host: Config.get('mysql.server'), host: Config.get('mysql.server'),
@ -49,9 +49,9 @@ class Database {
database: Config.get('mysql.database'), database: Config.get('mysql.database'),
multipleStatements: true, // Legacy thing multipleStatements: true, // Legacy thing
charset: 'utf8mb4' charset: 'utf8mb4'
} };
} }
knexConfig = { knexConfig = {
client: 'mysql', client: 'mysql',
connection, connection,

View file

@ -22,15 +22,93 @@ function makeSystemProxy(name) {
function computeNextRunAt(show) { function computeNextRunAt(show) {
const base = Number(show.next_run_at || show.scheduled_for || Date.now()); const base = Number(show.next_run_at || show.scheduled_for || Date.now());
if (show.recurrence === 'daily') { const recurrence = show.recurrence;
return base + 24 * 60 * 60 * 1000; const timezone = show.timezone || 'UTC';
if (recurrence !== 'daily' && recurrence !== 'weekly') {
return base;
} }
if (show.recurrence === 'weekly') { const daysToAdd = recurrence === 'weekly' ? 7 : 1;
return base + 7 * 24 * 60 * 60 * 1000; 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) { function normalizePlaylist(rawPlaylist) {

View file

@ -13,10 +13,13 @@ exports.init = function csrfInit (domain) {
var secret = req.signedCookies._csrf; var secret = req.signedCookies._csrf;
if (!secret) { if (!secret) {
secret = tokens.secretSync(); secret = tokens.secretSync();
const secure = req.realProtocol === 'https' || req.secure === true;
res.cookie("_csrf", secret, { res.cookie("_csrf", secret, {
domain: domain, domain: domain,
signed: true, signed: true,
httpOnly: true httpOnly: true,
sameSite: 'lax',
secure
}); });
} }
@ -37,7 +40,9 @@ exports.init = function csrfInit (domain) {
exports.verify = function csrfVerify(req) { exports.verify = function csrfVerify(req) {
var secret = req.signedCookies._csrf; 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)) { if (!tokens.verify(secret, token)) {
throw new CSRFError('Invalid CSRF token'); throw new CSRFError('Invalid CSRF token');

View file

@ -38,10 +38,13 @@ export function ipSessionCookieMiddleware(req, res, next) {
} }
if (!hasSession) { if (!hasSession) {
const secure = req.realProtocol === 'https' || req.secure === true;
res.cookie('ip-session', createIPSessionCookie(req.realIP, firstSeen), { res.cookie('ip-session', createIPSessionCookie(req.realIP, firstSeen), {
signed: true, signed: true,
httpOnly: true, httpOnly: true,
expires: NO_EXPIRATION expires: NO_EXPIRATION,
sameSite: 'lax',
secure
}); });
} }

View file

@ -26,8 +26,11 @@ async function getChannelEmotes(channelId) {
(err, rows) => { (err, rows) => {
if (err) return reject(new Error(err)); if (err) return reject(new Error(err));
if (!rows || rows.length === 0) return resolve([]); if (!rows || rows.length === 0) return resolve([]);
try { resolve(JSON.parse(rows[0].value)); } try {
catch (e) { resolve([]); } 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' }); return res.status(409).json({ error: 'An emote with that name already exists' });
} }
const old = emotes[idx];
emotes[idx] = validated; emotes[idx] = validated;
await saveChannelEmotes(req.bot.channel_id, emotes); await saveChannelEmotes(req.bot.channel_id, emotes);

View file

@ -1,7 +1,30 @@
const express = require('express'); const express = require('express');
const csrf = require('../../csrf');
const router = express.Router(); 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/bots', require('./bots'));
router.use('/channels/:channel/emotes', require('./emotes')); router.use('/channels/:channel/emotes', require('./emotes'));
router.use('/channels/:channel/playlist', require('./playlist')); router.use('/channels/:channel/playlist', require('./playlist'));

View file

@ -63,6 +63,7 @@ async function getChannelRow(channelName) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.channels.lookup(channelName, (err, row) => { db.channels.lookup(channelName, (err, row) => {
if (err) reject(new Error(err)); if (err) reject(new Error(err));
else if (!row) reject(new Error('Channel not found'));
else resolve(row); else resolve(row);
}); });
}); });

View file

@ -138,6 +138,11 @@ async function authorizeChannel(req, res) {
return null; return null;
} }
if (bot.rank < 2) {
res.status(403).json({ error: 'Insufficient rank' });
return null;
}
return { return {
actorName: bot.name, actorName: bot.name,
rank: bot.rank, rank: bot.rank,

View file

@ -241,21 +241,23 @@ module.exports = {
}, },
setAuthCookie: function setAuthCookie(req, res, expiration, auth) { 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) { if (req.hostname.indexOf(Config.get("http.root-domain")) >= 0) {
// Prevent non-root cookie from screwing things up // Prevent non-root cookie from screwing things up
res.clearCookie("auth"); res.clearCookie("auth");
res.cookie("auth", auth, { res.cookie("auth", auth, Object.assign({}, baseCookieOptions, {
domain: Config.get("http.root-domain-dotted"), domain: Config.get("http.root-domain-dotted"),
expires: expiration, }));
httpOnly: true,
signed: true
});
} else { } else {
res.cookie("auth", auth, { res.cookie("auth", auth, baseCookieOptions);
expires: expiration,
httpOnly: true,
signed: true
});
} }
} }
}; };

View file

@ -15,10 +15,12 @@ mixin head()
var DEFAULT_THEME = '#{DEFAULT_THEME}'; var DEFAULT_THEME = '#{DEFAULT_THEME}';
var CHANNELPATH = '#{channelPath}'; var CHANNELPATH = '#{channelPath}';
var CHANNELNAME = '#{channelName}'; var CHANNELNAME = '#{channelName}';
var CSRF_TOKEN = '#{csrfToken}';
else else
script(type="text/javascript"). script(type="text/javascript").
var DEFAULT_THEME = '#{DEFAULT_THEME}'; var DEFAULT_THEME = '#{DEFAULT_THEME}';
var CHANNELPATH = '#{channelPath}'; var CHANNELPATH = '#{channelPath}';
var CSRF_TOKEN = '#{csrfToken}';
script(src="/js/theme.js") script(src="/js/theme.js")
//[if lt IE 9] //[if lt IE 9]

View file

@ -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 () { var CSTBots = (function () {
function apiBase() { function apiBase() {
return '/api/v1/channels/' + CHANNEL.name; return '/api/v1/channels/' + CHANNEL.name;