diff --git a/package.json b/package.json index fdb2acb63..cc6e7ed75 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "connect-mongo": "4.6.0", "cookie": "0.4.2", "cookie-parser": "1.4.6", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", "csv": "6.0.5", "debug": "4.3.3", "dive": "0.5.0", @@ -50,6 +52,7 @@ "fs-extra": "10.0.0", "handlebars": "4.7.7", "html-to-text": "8.1.0", + "http-errors": "~1.7.3", "imap": "0.8.19", "immutable": "4.0.0", "imports-loader": "3.1.1", diff --git a/src/client/containers/Settings/Server/index.jsx b/src/client/containers/Settings/Server/index.jsx index 7f75c98eb..69b3e0590 100644 --- a/src/client/containers/Settings/Server/index.jsx +++ b/src/client/containers/Settings/Server/index.jsx @@ -59,11 +59,20 @@ class ServerSettingsController extends React.Component { restartServer () { this.setState({ restarting: true }) + const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content') axios - .get('/api/v1/admin/restart') + .post( + '/api/v1/admin/restart', + {}, + { + headers: { + 'CSRF-TOKEN': token + } + } + ) .catch(error => { helpers.hideLoader() - Log.error(error.response) + Log.error(error.responseText) Log.error('Unable to restart server. Server must run under PM2 and Account must have admin rights.') helpers.UI.showSnackbar('Unable to restart server. Are you an Administrator?', true) }) diff --git a/src/controllers/api/v1/routes.js b/src/controllers/api/v1/routes.js index 0cb0267aa..5499c6360 100644 --- a/src/controllers/api/v1/routes.js +++ b/src/controllers/api/v1/routes.js @@ -136,8 +136,8 @@ module.exports = function (middleware, router, controllers) { router.delete('/api/v1/users/:username', apiv1, canUser('accounts:delete'), apiCtrl.users.deleteUser) router.post('/api/v1/users/:id/generateapikey', apiv1, apiCtrl.users.generateApiKey) router.post('/api/v1/users/:id/removeapikey', apiv1, apiCtrl.users.removeApiKey) - router.post('/api/v1/users/:id/generatel2auth', apiv1, apiCtrl.users.generateL2Auth) - router.post('/api/v1/users/:id/removel2auth', apiv1, apiCtrl.users.removeL2Auth) + router.post('/api/v1/users/:id/generatel2auth', apiv1, middleware.csrfCheck, apiCtrl.users.generateL2Auth) + router.post('/api/v1/users/:id/removel2auth', apiv1, middleware.csrfCheck, apiCtrl.users.removeL2Auth) // Messages router.get('/api/v1/messages', apiv1, apiCtrl.messages.get) diff --git a/src/dependencies/csrf-td/index.js b/src/dependencies/csrf-td/index.js new file mode 100644 index 000000000..35990c8a4 --- /dev/null +++ b/src/dependencies/csrf-td/index.js @@ -0,0 +1,314 @@ +/*! + * csurf + * Copyright(c) 2011 Sencha Inc. + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + * + * Modified for Trudesk by Chris Brame (2022) + */ + +'use strict' + +/** + * Module dependencies. + * @private + */ + +const Cookie = require('cookie') +const createError = require('http-errors') +const sign = require('cookie-signature').sign +const Tokens = require('csrf') + +/** + * Module exports. + * @public + */ + +const csurf = {} + +/** + * CSRF protection middleware. + * + * This middleware adds a `req.csrfToken()` function to make a token + * which should be added to requests which mutate + * state, within a hidden form field, query-string etc. This + * token is validated against the visitor's session. + * + * @param {Object} options + * @return {Function} middleware + * @public + */ + +let cookie, sessionKey, value, tokens, ignoreMethods, opts + +csurf.init = function (options) { + opts = options || {} + + // get cookie options + cookie = getCookieOptions(opts.cookie) + + // get session options + sessionKey = opts.sessionKey || 'session' + + // get value getter + value = opts.value || defaultValue + + // token repo + tokens = new Tokens(opts) + + // ignored methods + ignoreMethods = opts.ignoreMethods === undefined ? ['GET', 'HEAD', 'OPTIONS'] : opts.ignoreMethods + + if (!Array.isArray(ignoreMethods)) { + throw new TypeError('option ignoreMethods must be an array') + } +} + +function generateSecret (req, res) { + // get the secret from the request + let secret = getSecret(req, sessionKey, cookie) + + // generate & set secret + if (!secret) { + secret = tokens.secretSync() + setSecret(req, res, sessionKey, secret, cookie) + } + + return secret +} + +csurf.generateToken = function (req, res, next) { + // validate the configuration against request + if (!verifyConfiguration(req, sessionKey, cookie)) { + throw new Error('misconfigured csrf') + } + + // eslint-disable-next-line prefer-const + let token + let secret = generateSecret(req, res) + + let sec = !cookie ? getSecret(req, sessionKey, cookie) : secret + + // use cached token if secret has not changed + if (token && sec === secret) { + return token + } + + // generate & set new secret + if (sec === undefined) { + sec = tokens.secretSync() + setSecret(req, res, sessionKey, sec, cookie) + } + + // update changed secret + secret = sec + + // create new token + token = tokens.create(secret) + req.csrfToken = token + + next() +} + +csurf.middleware = function csrf (req, res, next) { + // generate lookup + const ignoreMethod = getIgnoredMethods(ignoreMethods) + + // validate the configuration against request + if (!verifyConfiguration(req, sessionKey, cookie)) { + return next(new Error('misconfigured csrf')) + } + + const secret = generateSecret(req, res) + + // verify the incoming token + if (!ignoreMethod[req.method] && !tokens.verify(secret, value(req))) { + return next( + createError(403, 'invalid csrf token', { + code: 'EBADCSRFTOKEN' + }) + ) + } + + next() +} + +/** + * Default value function, checking the `req.body` + * and `req.query` for the CSRF token. + * + * @param {IncomingMessage} req + * @return {String} + * @api private + */ + +function defaultValue (req) { + return ( + (req.body && req.body._csrf) || + (req.query && req.query._csrf) || + req.headers['csrf-token'] || + req.headers['xsrf-token'] || + req.headers['x-csrf-token'] || + req.headers['x-xsrf-token'] + ) +} + +/** + * Get options for cookie. + * + * @param {boolean|object} [options] + * @returns {object} + * @api private + */ + +function getCookieOptions (options) { + if (options !== true && typeof options !== 'object') { + return undefined + } + + const opts = Object.create(null) + + // defaults + opts.key = '_csrf' + opts.path = '/' + + if (options && typeof options === 'object') { + for (const prop in options) { + const val = options[prop] + + if (val !== undefined) { + opts[prop] = val + } + } + } + + return opts +} + +/** + * Get a lookup of ignored methods. + * + * @param {array} methods + * @returns {object} + * @api private + */ + +function getIgnoredMethods (methods) { + const obj = Object.create(null) + + for (let i = 0; i < methods.length; i++) { + const method = methods[i].toUpperCase() + obj[method] = true + } + + return obj +} + +/** + * Get the token secret from the request. + * + * @param {IncomingMessage} req + * @param {String} sessionKey + * @param {Object} [cookie] + * @api private + */ + +function getSecret (req, sessionKey, cookie) { + // get the bag & key + const bag = getSecretBag(req, sessionKey, cookie) + const key = cookie ? cookie.key : 'csrfSecret' + + if (!bag) { + throw new Error('misconfigured csrf') + } + + // return secret from bag + return bag[key] +} + +/** + * Get the token secret bag from the request. + * + * @param {IncomingMessage} req + * @param {String} sessionKey + * @param {Object} [cookie] + * @api private + */ + +function getSecretBag (req, sessionKey, cookie) { + if (cookie) { + // get secret from cookie + const cookieKey = cookie.signed ? 'signedCookies' : 'cookies' + + return req[cookieKey] + } else { + // get secret from session + return req[sessionKey] + } +} + +/** + * Set a cookie on the HTTP response. + * + * @param {OutgoingMessage} res + * @param {string} name + * @param {string} val + * @param {Object} [options] + * @api private + */ + +function setCookie (res, name, val, options) { + const data = Cookie.serialize(name, val, options) + + const prev = res.getHeader('set-cookie') || [] + const header = Array.isArray(prev) ? prev.concat(data) : [prev, data] + + res.setHeader('set-cookie', header) +} + +/** + * Set the token secret on the request. + * + * @param {IncomingMessage} req + * @param {OutgoingMessage} res + * @param {string} sessionKey + * @param {string} val + * @param {Object} [cookie] + * @api private + */ + +function setSecret (req, res, sessionKey, val, cookie) { + if (cookie) { + // set secret on cookie + let value = val + + if (cookie.signed) { + value = 's:' + sign(val, req.secret) + } + + setCookie(res, cookie.key, value, cookie) + } else { + // set secret on session + req[sessionKey].csrfSecret = val + } +} + +/** + * Verify the configuration against the request. + * @private + */ + +function verifyConfiguration (req, sessionKey, cookie) { + if (!getSecretBag(req, sessionKey, cookie)) { + return false + } + + if (cookie && cookie.signed && !req.secret) { + return false + } + + return true +} + +module.exports = csurf diff --git a/src/middleware/index.js b/src/middleware/index.js index 17f9744af..1cb07dff3 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -99,6 +99,9 @@ module.exports = function (app, db, callback) { // CORS app.use(allowCrossDomain) + const csrf = require('../dependencies/csrf-td') + csrf.init() + app.use(csrf.generateToken) // Maintenance Mode app.use(function (req, res, next) { diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index 9fd5273b6..3d966f459 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -17,7 +17,9 @@ var _ = require('lodash') var db = require('../database') var mongoose = require('mongoose') -var winston = require('winston') +var winston = require('../logger') +const csrf = require('../dependencies/csrf-td') +const viewdata = require('../helpers/viewdata') var middleware = {} @@ -111,8 +113,8 @@ middleware.ensurel2Auth = function (req, res, next) { // Common middleware.loadCommonData = function (req, res, next) { - var viewdata = require('../helpers/viewdata') viewdata.getData(req, function (data) { + data.csrfToken = req.csrfToken req.viewdata = data return next() @@ -250,6 +252,11 @@ middleware.isAgent = function (req, res, next) { middleware.isSupport = middleware.isAgent +middleware.csrfCheck = function (req, res, next) { + csrf.init() + return csrf.middleware(req, res, next) +} + module.exports = function () { return middleware } diff --git a/src/public/js/angularjs/controllers/profile.js b/src/public/js/angularjs/controllers/profile.js index 65eb52db2..2396cd364 100644 --- a/src/public/js/angularjs/controllers/profile.js +++ b/src/public/js/angularjs/controllers/profile.js @@ -24,10 +24,12 @@ define([ ], function (angular, _, $, helpers, UIKit) { return angular .module('trudesk.controllers.profile', ['trudesk.services.session']) - .controller('profileCtrl', function (SessionService, $scope, $window, $http, $log, $timeout) { + .controller('profileCtrl', function (SessionService, $scope, $window, $document, $http, $log, $timeout) { + var otpEnabled = false $scope.init = function () { // Fix Inputs if input is preloaded with a value fixInputLabels() + otpEnabled = $scope.otpEnabled } function fixInputLabels () { @@ -164,7 +166,7 @@ define([ var $qrCode = $totpSettings.find('#totp-qrcode') event.preventDefault() - if ($scope.otpEnabled) { + if (otpEnabled) { UIKit.modal.confirm( 'WARNING: Disabling Two Factor Authentication will remove your shared secret. A new key will generate when re-enabled.
' + 'Are you sure you want to disable two factor authentication?', @@ -180,7 +182,7 @@ define([ $qrCode.find('canvas').remove() $tOTPKey.val() $timeout(function () { - $scope.otpEnabled = false + otpEnabled = false }, 0) }) }) @@ -198,7 +200,7 @@ define([ } $timeout(function () { - $scope.otpEnabled = true + otpEnabled = true angular.element(event.target).prop('checked', true) }, 0) @@ -234,29 +236,39 @@ define([ return helpers.UI.showSnackbar('Unable to get user ID.', true) } - $http.post('/api/v1/users/' + id + '/generatel2auth').then( - function success (response) { - if (!response.data.success) { - helpers.UI.showSnackbar('Error: Unknown error has occurred.', true) - if (_.isFunction(completed)) { - return completed('Error: Unknown error has occurred.') + $http + .post( + '/api/v1/users/' + id + '/generatel2auth', + {}, + { + headers: { + 'CSRF-TOKEN': $document[0].querySelector('meta[name="csrf-token"]').getAttribute('content') } - } else { - // Success + } + ) + .then( + function success (response) { + if (!response.data.success) { + helpers.UI.showSnackbar('Error: Unknown error has occurred.', true) + if (_.isFunction(completed)) { + return completed('Error: Unknown error has occurred.') + } + } else { + // Success + if (_.isFunction(completed)) { + completed(null, response.data.generatedKey) + } + } + }, + function error (err) { + $log.error('[trudesk:profile:generateL2Auth]') + $log.error(err) + helpers.UI.showSnackbar('Error: Could not generate new secret! Check Console', true) if (_.isFunction(completed)) { - completed(null, response.data.generatedKey) + completed(err) } } - }, - function error (err) { - $log.error('[trudesk:profile:generateL2Auth]') - $log.error(err) - helpers.UI.showSnackbar('Error: Could not generate new secret! Check Console', true) - if (_.isFunction(completed)) { - completed(err) - } - } - ) + ) } function removeL2Auth (completed) { @@ -266,7 +278,15 @@ define([ } $http - .post('/api/v1/users/' + id + '/removel2auth') + .post( + '/api/v1/users/' + id + '/removel2auth', + {}, + { + headers: { + 'CSRF-TOKEN': $document[0].querySelector('meta[name="csrf-token"]').getAttribute('content') + } + } + ) .success(function () { if (_.isFunction(completed)) { completed() diff --git a/src/routes/index.js b/src/routes/index.js index 842de7d61..74d1b0f23 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -13,7 +13,7 @@ const express = require('express') const router = express.Router() const controllers = require('../controllers') const path = require('path') -const winston = require('winston') +const winston = require('../logger') const packagejson = require('../../package.json') function mainRoutes (router, middleware, controllers) { @@ -226,7 +226,13 @@ function mainRoutes (router, middleware, controllers) { ) // Accounts - router.get('/profile', middleware.redirectToLogin, middleware.loadCommonData, controllers.accounts.profile) + router.get( + '/profile', + middleware.redirectToLogin, + middleware.csrfCheck, + middleware.loadCommonData, + controllers.accounts.profile + ) router.get('/accounts', middleware.redirectToLogin, middleware.loadCommonData, controllers.accounts.getCustomers) router.get( '/accounts/customers', @@ -328,6 +334,7 @@ function mainRoutes (router, middleware, controllers) { middleware.redirectToLogin, middleware.isAdmin, middleware.loadCommonData, + middleware.csrfCheck, controllers.settings.serverSettings ) router.get('/settings/legal', middleware.redirectToLogin, middleware.loadCommonData, controllers.settings.legal) @@ -365,7 +372,7 @@ function mainRoutes (router, middleware, controllers) { controllers.api.v1.plugins.removePlugin ) - router.get('/api/v1/admin/restart', middleware.api, middleware.isAdmin, function (req, res) { + router.post('/api/v1/admin/restart', middleware.csrfCheck, middleware.api, middleware.isAdmin, function (req, res) { if (process.env.DISABLE_RESTART) return res.json({ success: true }) const pm2 = require('pm2') diff --git a/src/views/layout/main.hbs b/src/views/layout/main.hbs index 09b6a545f..908ae1110 100644 --- a/src/views/layout/main.hbs +++ b/src/views/layout/main.hbs @@ -4,6 +4,7 @@ {{data.common.siteTitle}} · {{{title}}} + diff --git a/yarn.lock b/yarn.lock index c205806f3..7d6b68cba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4642,6 +4642,15 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +csrf@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/csrf/-/csrf-3.1.0.tgz#ec75e9656d004d674b8ef5ba47b41fbfd6cb9c30" + integrity sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w== + dependencies: + rndm "1.2.0" + tsscmp "1.0.6" + uid-safe "2.1.5" + css-loader@6.6.0: version "6.6.0" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.6.0.tgz#c792ad5510bd1712618b49381bd0310574fafbd3" @@ -7726,7 +7735,7 @@ http-cache-semantics@^4.1.0: resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== -http-errors@1.7.3: +http-errors@1.7.3, http-errors@~1.7.3: version "1.7.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== @@ -13275,6 +13284,11 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2, rimraf@~3.0.2: dependencies: glob "^7.1.3" +rndm@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c" + integrity sha1-8z/pz7Urv9UgqhgyO8ZdsRCht2w= + run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -14953,6 +14967,11 @@ tslib@^2.2.0, tslib@^2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.17.1: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -15115,7 +15134,7 @@ uglify-js@^3.1.4: commander "~2.17.1" source-map "~0.6.1" -uid-safe@~2.1.5: +uid-safe@2.1.5, uid-safe@~2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==