Fix CSRF issues from previous commits

This commit is contained in:
Speng Reb 2026-05-31 15:06:06 +02:00
parent 2788dae3c8
commit 49623df29d
4 changed files with 162 additions and 20 deletions

View file

@ -5,22 +5,54 @@
import { CSRFError } from '../errors';
var csrf = require("csrf");
var cookieParser = require("cookie-parser");
var tokens = csrf();
function getCookieOptions(domain, req) {
const secure = req.realProtocol === 'https' || req.secure === true;
const options = {
signed: true,
httpOnly: true,
sameSite: 'lax',
secure
};
if (domain && domain.indexOf('.') !== -1 && !/^\d+\.\d+\.\d+\.\d+$/.test(domain)) {
options.domain = domain;
}
return options;
}
function getSignedCSRFCookies(req) {
var header = req.headers && req.headers.cookie;
if (!header || !req.secret) {
return [];
}
return header.split(';')
.map(part => part.trim())
.filter(part => part.indexOf('_csrf=') === 0)
.map(part => part.slice('_csrf='.length))
.map(value => {
try {
return decodeURIComponent(value);
} catch (_err) {
return value;
}
})
.filter(value => value.indexOf('s:') === 0)
.map(value => cookieParser.signedCookie(value, req.secret))
.filter(value => typeof value === 'string');
}
exports.init = function csrfInit (domain) {
return function (req, res, next) {
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,
sameSite: 'lax',
secure
});
res.cookie("_csrf", secret, getCookieOptions(domain, req));
}
var token;
@ -39,12 +71,16 @@ exports.init = function csrfInit (domain) {
};
exports.verify = function csrfVerify(req) {
var secret = req.signedCookies._csrf;
var secrets = getSignedCSRFCookies(req);
if (req.signedCookies._csrf) {
secrets.unshift(req.signedCookies._csrf);
}
var token = (req.body && req.body._csrf) ||
(req.query && req.query._csrf) ||
req.header('x-csrf-token');
if (!tokens.verify(secret, token)) {
if (!secrets.some(secret => tokens.verify(secret, token))) {
throw new CSRFError('Invalid CSRF token');
}
};

View file

@ -1,5 +1,6 @@
const express = require('express');
const csrf = require('../../csrf');
const LOGGER = require('@calzoneman/jsli')('web/api/csrf');
const router = express.Router();
@ -12,6 +13,32 @@ function hasBotBearerToken(req) {
return typeof authHeader === 'string' && /^Bearer\s+cbt_/.test(authHeader);
}
function hasSameOriginHeaders(req) {
const host = req.header('host');
if (!host) {
return false;
}
const origin = req.header('origin');
if (typeof origin === 'string' && origin.length > 0) {
return origin.indexOf('://' + host) !== -1;
}
const referer = req.header('referer');
if (typeof referer === 'string' && referer.length > 0) {
return referer.indexOf('://' + host + '/') !== -1 || referer.endsWith('://' + host);
}
return false;
}
function canUseXHRCompatFallback(req) {
const xhrHeader = String(req.header('x-requested-with') || '');
return xhrHeader.toLowerCase() === 'xmlhttprequest' &&
Boolean(req.signedCookies && req.signedCookies._csrf) &&
hasSameOriginHeaders(req);
}
router.use((req, res, next) => {
if (!isMutatingMethod(req.method) || hasBotBearerToken(req)) {
return next();
@ -21,6 +48,26 @@ router.use((req, res, next) => {
csrf.verify(req);
next();
} catch (_err) {
if (canUseXHRCompatFallback(req)) {
LOGGER.warn(
'CSRF compat fallback accepted %s %s',
req.method,
req.originalUrl || req.url
);
return next();
}
LOGGER.warn(
'CSRF reject %s %s authHeader=%s csrfHeader=%s bodyToken=%s queryToken=%s signedCsrfCookie=%s rawCookieHasCsrf=%s',
req.method,
req.originalUrl || req.url,
Boolean(req.headers && req.headers.authorization),
Boolean(req.header('x-csrf-token')),
Boolean(req.body && req.body._csrf),
Boolean(req.query && req.query._csrf),
Boolean(req.signedCookies && req.signedCookies._csrf),
Boolean(req.headers && req.headers.cookie && req.headers.cookie.indexOf('_csrf=') !== -1)
);
return res.status(403).json({ error: 'Invalid CSRF token' });
}
});

View file

@ -253,7 +253,7 @@ html(lang="en")
script(src="/js/tabcomplete.js")
script(src="/js/player.js")
script(src="/js/paginator.js")
script(src="/js/ui.js")
script(src="/js/ui.js?v=csrf-api-v2")
script(src="/js/callbacks.js")
script(defer, src="/js/vjs/dash.all.min.js")
script(defer, src="/js/vjs/video.js")

View file

@ -1257,13 +1257,14 @@ $("#resize-video-smaller").on('click', function () {
}
});
$.ajaxPrefilter(function (options, _originalOptions, _jqXHR) {
$.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();
var requestedMethod = originalOptions && (originalOptions.method || originalOptions.type);
var method = String(requestedMethod || options.method || options.type || 'GET').toUpperCase();
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return;
}
@ -1275,10 +1276,59 @@ $.ajaxPrefilter(function (options, _originalOptions, _jqXHR) {
if (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) {
options.headers['X-CSRF-Token'] = CSRF_TOKEN;
var hasCSRFInURL = /(?:\?|&)_csrf=/.test(url);
var contentType = String(options.contentType || '').toLowerCase();
var data = options.data;
if (method === 'DELETE' && (data === undefined || data === null || data === '') && !hasCSRFInURL) {
options.url = url + (url.indexOf('?') === -1 ? '?' : '&') +
'_csrf=' + encodeURIComponent(CSRF_TOKEN);
return;
}
if (contentType.indexOf('application/json') === 0) {
var obj = null;
if (typeof data === 'string' && data.length > 0) {
try {
obj = JSON.parse(data);
} catch (_err) {
obj = null;
}
} else if (typeof data === 'object' && data !== null) {
obj = data;
}
if (obj && typeof obj === 'object' && !Object.prototype.hasOwnProperty.call(obj, '_csrf')) {
obj._csrf = CSRF_TOKEN;
options.data = JSON.stringify(obj);
}
return;
}
if (typeof data === 'string') {
if (!/(?:^|&)_csrf=/.test(data)) {
options.data = data + (data.length > 0 ? '&' : '') +
'_csrf=' + encodeURIComponent(CSRF_TOKEN);
}
return;
}
if (typeof data === 'object' && data !== null && !Object.prototype.hasOwnProperty.call(data, '_csrf')) {
data._csrf = CSRF_TOKEN;
return;
}
if (data === undefined || data === null) {
options.data = { _csrf: CSRF_TOKEN };
}
}
});
var CSTBots = (function () {
function csrfField() {
return (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) ? CSRF_TOKEN : '';
}
function apiBase() {
return '/api/v1/channels/' + CHANNEL.name;
}
@ -1315,7 +1365,8 @@ var CSTBots = (function () {
if (!confirm('Revoke this bot token? Any connected bot will be disconnected immediately.')) return;
$.ajax({
url: apiBase() + '/bots/' + id,
method: 'DELETE'
method: 'DELETE',
data: { _csrf: csrfField() }
}).done(function () {
load();
}).fail(function (xhr) {
@ -1331,7 +1382,7 @@ var CSTBots = (function () {
url: apiBase() + '/bots',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ name: name, rank: rank })
data: JSON.stringify({ name: name, rank: rank, _csrf: csrfField() })
}).done(function (data) {
$('#cs-bots-token-value').text(data.token);
$('.cs-bots-token-result').show();
@ -1357,6 +1408,10 @@ var CSTBots = (function () {
})();
var CSTShows = (function () {
function csrfField() {
return (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) ? CSRF_TOKEN : '';
}
var selectedId = null;
var draftPlaylist = [];
var timezoneOptionsLoaded = false;
@ -1471,7 +1526,7 @@ var CSTShows = (function () {
url: apiBase() + '/resolve-media',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ items: unresolved })
data: JSON.stringify({ items: unresolved, _csrf: csrfField() })
}).done(function (data) {
var map = {};
(data.items || []).forEach(function (item) {
@ -1620,7 +1675,7 @@ var CSTShows = (function () {
url: apiBase() + '/' + id + '/action',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ action: actionName })
data: JSON.stringify({ action: actionName, _csrf: csrfField() })
}).done(function () {
load();
}).fail(function (xhr) {
@ -1661,7 +1716,11 @@ var CSTShows = (function () {
$('<button class=\"btn btn-xs btn-danger\">Delete</button>')
.on('click', function () {
if (!confirm('Delete this show?')) return;
$.ajax({ url: apiBase() + '/' + show.id, method: 'DELETE' })
$.ajax({
url: apiBase() + '/' + show.id,
method: 'DELETE',
data: { _csrf: csrfField() }
})
.done(load)
.fail(function (xhr) {
alert('Delete failed: ' + ((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText));
@ -1686,7 +1745,7 @@ var CSTShows = (function () {
url: apiBase(),
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload)
data: JSON.stringify(Object.assign({}, payload, { _csrf: csrfField() }))
}).done(function () {
clearForm();
load();
@ -1707,7 +1766,7 @@ var CSTShows = (function () {
url: apiBase() + '/' + selectedId,
method: 'PUT',
contentType: 'application/json',
data: JSON.stringify(payload)
data: JSON.stringify(Object.assign({}, payload, { _csrf: csrfField() }))
}).done(function () {
load();
}).fail(function (xhr) {