mirror of
https://github.com/Spengreb/sync.git
synced 2026-06-09 23:02:05 +00:00
Fix CSRF issues from previous commits
This commit is contained in:
parent
2788dae3c8
commit
49623df29d
4 changed files with 162 additions and 20 deletions
|
|
@ -5,22 +5,54 @@
|
||||||
import { CSRFError } from '../errors';
|
import { CSRFError } from '../errors';
|
||||||
|
|
||||||
var csrf = require("csrf");
|
var csrf = require("csrf");
|
||||||
|
var cookieParser = require("cookie-parser");
|
||||||
|
|
||||||
var tokens = csrf();
|
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) {
|
exports.init = function csrfInit (domain) {
|
||||||
return function (req, res, next) {
|
return function (req, res, next) {
|
||||||
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, getCookieOptions(domain, req));
|
||||||
res.cookie("_csrf", secret, {
|
|
||||||
domain: domain,
|
|
||||||
signed: true,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var token;
|
var token;
|
||||||
|
|
@ -39,12 +71,16 @@ exports.init = function csrfInit (domain) {
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.verify = function csrfVerify(req) {
|
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) ||
|
var token = (req.body && req.body._csrf) ||
|
||||||
(req.query && req.query._csrf) ||
|
(req.query && req.query._csrf) ||
|
||||||
req.header('x-csrf-token');
|
req.header('x-csrf-token');
|
||||||
|
|
||||||
if (!tokens.verify(secret, token)) {
|
if (!secrets.some(secret => tokens.verify(secret, token))) {
|
||||||
throw new CSRFError('Invalid CSRF token');
|
throw new CSRFError('Invalid CSRF token');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const csrf = require('../../csrf');
|
const csrf = require('../../csrf');
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('web/api/csrf');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -12,6 +13,32 @@ function hasBotBearerToken(req) {
|
||||||
return typeof authHeader === 'string' && /^Bearer\s+cbt_/.test(authHeader);
|
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) => {
|
router.use((req, res, next) => {
|
||||||
if (!isMutatingMethod(req.method) || hasBotBearerToken(req)) {
|
if (!isMutatingMethod(req.method) || hasBotBearerToken(req)) {
|
||||||
return next();
|
return next();
|
||||||
|
|
@ -21,6 +48,26 @@ router.use((req, res, next) => {
|
||||||
csrf.verify(req);
|
csrf.verify(req);
|
||||||
next();
|
next();
|
||||||
} catch (_err) {
|
} 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' });
|
return res.status(403).json({ error: 'Invalid CSRF token' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ html(lang="en")
|
||||||
script(src="/js/tabcomplete.js")
|
script(src="/js/tabcomplete.js")
|
||||||
script(src="/js/player.js")
|
script(src="/js/player.js")
|
||||||
script(src="/js/paginator.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(src="/js/callbacks.js")
|
||||||
script(defer, src="/js/vjs/dash.all.min.js")
|
script(defer, src="/js/vjs/dash.all.min.js")
|
||||||
script(defer, src="/js/vjs/video.js")
|
script(defer, src="/js/vjs/video.js")
|
||||||
|
|
|
||||||
77
www/js/ui.js
77
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 || '');
|
var url = String(options.url || '');
|
||||||
if (!/\/api\/v1\//.test(url)) {
|
if (!/\/api\/v1\//.test(url)) {
|
||||||
return;
|
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') {
|
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1275,10 +1276,59 @@ $.ajaxPrefilter(function (options, _originalOptions, _jqXHR) {
|
||||||
|
|
||||||
if (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) {
|
if (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) {
|
||||||
options.headers['X-CSRF-Token'] = CSRF_TOKEN;
|
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 () {
|
var CSTBots = (function () {
|
||||||
|
function csrfField() {
|
||||||
|
return (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) ? CSRF_TOKEN : '';
|
||||||
|
}
|
||||||
|
|
||||||
function apiBase() {
|
function apiBase() {
|
||||||
return '/api/v1/channels/' + CHANNEL.name;
|
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;
|
if (!confirm('Revoke this bot token? Any connected bot will be disconnected immediately.')) return;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: apiBase() + '/bots/' + id,
|
url: apiBase() + '/bots/' + id,
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
data: { _csrf: csrfField() }
|
||||||
}).done(function () {
|
}).done(function () {
|
||||||
load();
|
load();
|
||||||
}).fail(function (xhr) {
|
}).fail(function (xhr) {
|
||||||
|
|
@ -1331,7 +1382,7 @@ var CSTBots = (function () {
|
||||||
url: apiBase() + '/bots',
|
url: apiBase() + '/bots',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
data: JSON.stringify({ name: name, rank: rank })
|
data: JSON.stringify({ name: name, rank: rank, _csrf: csrfField() })
|
||||||
}).done(function (data) {
|
}).done(function (data) {
|
||||||
$('#cs-bots-token-value').text(data.token);
|
$('#cs-bots-token-value').text(data.token);
|
||||||
$('.cs-bots-token-result').show();
|
$('.cs-bots-token-result').show();
|
||||||
|
|
@ -1357,6 +1408,10 @@ var CSTBots = (function () {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
var CSTShows = (function () {
|
var CSTShows = (function () {
|
||||||
|
function csrfField() {
|
||||||
|
return (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) ? CSRF_TOKEN : '';
|
||||||
|
}
|
||||||
|
|
||||||
var selectedId = null;
|
var selectedId = null;
|
||||||
var draftPlaylist = [];
|
var draftPlaylist = [];
|
||||||
var timezoneOptionsLoaded = false;
|
var timezoneOptionsLoaded = false;
|
||||||
|
|
@ -1471,7 +1526,7 @@ var CSTShows = (function () {
|
||||||
url: apiBase() + '/resolve-media',
|
url: apiBase() + '/resolve-media',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
data: JSON.stringify({ items: unresolved })
|
data: JSON.stringify({ items: unresolved, _csrf: csrfField() })
|
||||||
}).done(function (data) {
|
}).done(function (data) {
|
||||||
var map = {};
|
var map = {};
|
||||||
(data.items || []).forEach(function (item) {
|
(data.items || []).forEach(function (item) {
|
||||||
|
|
@ -1620,7 +1675,7 @@ var CSTShows = (function () {
|
||||||
url: apiBase() + '/' + id + '/action',
|
url: apiBase() + '/' + id + '/action',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
data: JSON.stringify({ action: actionName })
|
data: JSON.stringify({ action: actionName, _csrf: csrfField() })
|
||||||
}).done(function () {
|
}).done(function () {
|
||||||
load();
|
load();
|
||||||
}).fail(function (xhr) {
|
}).fail(function (xhr) {
|
||||||
|
|
@ -1661,7 +1716,11 @@ var CSTShows = (function () {
|
||||||
$('<button class=\"btn btn-xs btn-danger\">Delete</button>')
|
$('<button class=\"btn btn-xs btn-danger\">Delete</button>')
|
||||||
.on('click', function () {
|
.on('click', function () {
|
||||||
if (!confirm('Delete this show?')) return;
|
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)
|
.done(load)
|
||||||
.fail(function (xhr) {
|
.fail(function (xhr) {
|
||||||
alert('Delete failed: ' + ((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText));
|
alert('Delete failed: ' + ((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText));
|
||||||
|
|
@ -1686,7 +1745,7 @@ var CSTShows = (function () {
|
||||||
url: apiBase(),
|
url: apiBase(),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
data: JSON.stringify(payload)
|
data: JSON.stringify(Object.assign({}, payload, { _csrf: csrfField() }))
|
||||||
}).done(function () {
|
}).done(function () {
|
||||||
clearForm();
|
clearForm();
|
||||||
load();
|
load();
|
||||||
|
|
@ -1707,7 +1766,7 @@ var CSTShows = (function () {
|
||||||
url: apiBase() + '/' + selectedId,
|
url: apiBase() + '/' + selectedId,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
data: JSON.stringify(payload)
|
data: JSON.stringify(Object.assign({}, payload, { _csrf: csrfField() }))
|
||||||
}).done(function () {
|
}).done(function () {
|
||||||
load();
|
load();
|
||||||
}).fail(function (xhr) {
|
}).fail(function (xhr) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue