diff --git a/src/web/csrf.js b/src/web/csrf.js index b37ceafc..7f199a7b 100644 --- a/src/web/csrf.js +++ b/src/web/csrf.js @@ -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'); } }; diff --git a/src/web/routes/api/index.js b/src/web/routes/api/index.js index 044c1b62..0ca9a096 100644 --- a/src/web/routes/api/index.js +++ b/src/web/routes/api/index.js @@ -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' }); } }); diff --git a/templates/channel.pug b/templates/channel.pug index 9e9b2817..1f1aadb8 100644 --- a/templates/channel.pug +++ b/templates/channel.pug @@ -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") diff --git a/www/js/ui.js b/www/js/ui.js index ae14b22a..6078ef95 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -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 () { $('') .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) {