diff --git a/packages/backend/src/CoreModule.js b/packages/backend/src/CoreModule.js index b8f23112..be2b392a 100644 --- a/packages/backend/src/CoreModule.js +++ b/packages/backend/src/CoreModule.js @@ -207,6 +207,9 @@ const install = async ({ services, app }) => { const { UserProtectedEndpointsService } = require("./services/web/UserProtectedEndpointsService"); services.registerService('__user-protected-endpoints', UserProtectedEndpointsService); + + const { AntiCSRFService } = require('./services/auth/AntiCSRFService'); + services.registerService('anti-csrf', AntiCSRFService); } const install_legacy = async ({ services }) => { diff --git a/packages/backend/src/routers/logout.js b/packages/backend/src/routers/logout.js index 771c9ad7..caadfbe9 100644 --- a/packages/backend/src/routers/logout.js +++ b/packages/backend/src/routers/logout.js @@ -29,6 +29,11 @@ router.post('/logout', auth, express.json(), async (req, res, next)=>{ // check subdomain if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '') next(); + // check anti-csrf token + const svc_antiCSRF = req.services.get('anti-csrf'); + if ( ! svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf) ) { + return res.status(400).json({ message: 'incorrect anti-CSRF token' }); + } // delete cookie res.clearCookie(config.cookie_name); // delete session diff --git a/packages/backend/src/services/auth/AntiCSRFService.js b/packages/backend/src/services/auth/AntiCSRFService.js new file mode 100644 index 00000000..412f3353 --- /dev/null +++ b/packages/backend/src/services/auth/AntiCSRFService.js @@ -0,0 +1,118 @@ +const eggspress = require("../../api/eggspress"); +const config = require("../../config"); +const { subdomain } = require("../../helpers"); +const BaseService = require("../BaseService"); + +class CircularQueue { + constructor (size) { + this.size = size; + this.queue = []; + this.index = 0; + this.map = new Map(); + } + + push (item) { + if ( this.queue[this.index] ) { + this.map.delete(this.queue[this.index]); + } + this.queue[this.index] = item; + this.map.set(item, this.index); + this.index = (this.index + 1) % this.size; + } + + get (index) { + return this.queue[(this.index + index) % this.size]; + } + + has (item) { + return this.map.has(item); + } + + maybe_consume (item) { + if ( this.has(item) ) { + const index = this.map.get(item); + this.map.delete(item); + this.queue[index] = null; + return true; + } + return false; + } +} + +class AntiCSRFService extends BaseService { + _construct () { + this.map_session_to_tokens = {}; + } + + ['__on_install.routes'] () { + const { app } = this.services.get('web-server'); + + app.use(eggspress('/get-anticsrf-token', { + auth2: true, + allowedMethods: ['GET'], + }, async (req, res) => { + // We disallow `api.` because it has a more relaxed CORS policy + const subdomain_check = config.experimental_no_subdomain || + (subdomain(req) !== 'api'); + if ( ! subdomain_check ) { + return res.status(404).send('Hey, stop that!'); + } + + // TODO: session uuid instead of user + const token = this.create_token(req.user.uuid); + res.send({ token }); + })); + } + + create_token (session) { + let tokens = this.map_session_to_tokens[session]; + if ( ! tokens ) { + tokens = new CircularQueue(10); + this.map_session_to_tokens[session] = tokens; + } + const token = this.generate_token_(); + tokens.push(token); + return token; + } + + consume_token (session, token) { + const tokens = this.map_session_to_tokens[session]; + if ( ! tokens ) return false; + return tokens.maybe_consume(token); + } + + generate_token_ () { + return require('crypto').randomBytes(32).toString('hex'); + } + + _test ({ assert }) { + // Do this several times, like a user would + for ( let i=0 ; i < 30 ; i++ ) { + // Generate 30 tokens + const tokens = []; + for ( let j=0 ; j < 30 ; j++ ) { + tokens.push(this.create_token('session')); + } + // Only the last 10 should be valid + const results_for_stale_tokens = []; + for ( let j=0 ; j < 20 ; j++ ) { + const result = this.consume_token('session', tokens[j]); + results_for_stale_tokens.push(result); + } + assert(() => results_for_stale_tokens.every(v => v === false)); + // The last 10 should be valid + const results_for_valid_tokens = []; + for ( let j=20 ; j < 30 ; j++ ) { + const result = this.consume_token('session', tokens[j]); + results_for_valid_tokens.push(result); + } + assert(() => results_for_valid_tokens.every(v => v === true)); + // A completely arbitrary token should not be valid + assert(() => this.consume_token('session', 'arbitrary') === false); + } + } +} + +module.exports = { + AntiCSRFService, +}; diff --git a/src/initgui.js b/src/initgui.js index c45a427f..1cfcd517 100644 --- a/src/initgui.js +++ b/src/initgui.js @@ -1981,6 +1981,8 @@ window.initgui = async function(){ // logout try{ + const resp = await fetch(`${window.gui_origin}/get-anticsrf-token`); + const { token } = await resp.json(); await $.ajax({ url: window.gui_origin + "/logout", type: 'POST', @@ -1989,6 +1991,7 @@ window.initgui = async function(){ headers: { "Authorization": "Bearer " + window.auth_token }, + data: JSON.stringify({ anti_csrf: token }), statusCode: { 401: function () { },