diff --git a/src/backend/src/services/auth/AntiCSRFService.js b/src/backend/src/services/auth/AntiCSRFService.js index 2e720c8e..3343e70c 100644 --- a/src/backend/src/services/auth/AntiCSRFService.js +++ b/src/backend/src/services/auth/AntiCSRFService.js @@ -20,76 +20,7 @@ const eggspress = require('../../api/eggspress'); const config = require('../../config'); const { subdomain } = require('../../helpers'); const BaseService = require('../BaseService'); - -/** - * A utility class used by AntiCSRFService to manage a circular queue of - * CSRF tokens (or, as we like to call them, "anti-CSRF" tokens). - * - * A token expires when it is evicted from the queue. - */ -class CircularQueue { - /** - * Creates a new CircularQueue instance with the specified size. - * - * @param {number} size - The maximum number of items the queue can hold - */ - constructor (size) { - this.size = size; - this.queue = []; - this.index = 0; - this.map = new Map(); - } - - /** - * Adds an item to the queue. If the queue is full, the oldest item is removed. - * - * @param {*} item - The item to add to the queue - */ - 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; - } - - /** - * Retrieves an item from the queue at the specified relative index. - * - * @param {number} index - The relative index from the current position - * @returns {*} The item at the specified index - */ - get (index) { - return this.queue[(this.index + index) % this.size]; - } - - /** - * Checks if the queue contains the specified item. - * - * @param {*} item - The item to check for - * @returns {boolean} True if the item exists in the queue, false otherwise - */ - has (item) { - return this.map.has(item); - } - - /** - * Attempts to consume (remove) an item from the queue if it exists. - * - * @param {*} item - The item to consume - * @returns {boolean} True if the item was found and consumed, false otherwise - */ - 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; - } -} +const { CircularQueue } = require('../../util/CircularQueue'); /** * Class AntiCSRFService extends BaseService to manage and protect against Cross-Site Request Forgery (CSRF) attacks. diff --git a/src/backend/src/util/CircularQueue.bench.js b/src/backend/src/util/CircularQueue.bench.js new file mode 100644 index 00000000..cd5029e9 --- /dev/null +++ b/src/backend/src/util/CircularQueue.bench.js @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { bench, describe } from 'vitest'; +const { CircularQueue } = require('./CircularQueue'); + +/** + * Naive array-based implementation for comparison (no Map optimization). + * This serves as a baseline to demonstrate the performance improvement + * of the Map-optimized CircularQueue. + */ +class NaiveCircularQueue { + constructor (size) { + this.size = size; + this.queue = []; + this.index = 0; + } + + push (item) { + this.queue[this.index] = item; + this.index = (this.index + 1) % this.size; + } + + get (index) { + return this.queue[(this.index + index) % this.size]; + } + + has (item) { + return this.queue.includes(item); + } + + maybe_consume (item) { + const index = this.queue.indexOf(item); + if ( index !== -1 ) { + this.queue[index] = null; + return true; + } + return false; + } +} + +// Generate test tokens +const generateToken = () => Math.random().toString(36).substring(2, 15); + +describe('CircularQueue - push() operations', () => { + bench('push() with size=50', () => { + const queue = new CircularQueue(50); + for ( let i = 0; i < 1000; i++ ) { + queue.push(generateToken()); + } + }); + + bench('push() with size=500', () => { + const queue = new CircularQueue(500); + for ( let i = 0; i < 1000; i++ ) { + queue.push(generateToken()); + } + }); + + bench('NaiveCircularQueue push() with size=50 (baseline)', () => { + const queue = new NaiveCircularQueue(50); + for ( let i = 0; i < 1000; i++ ) { + queue.push(generateToken()); + } + }); +}); + +describe('CircularQueue - has() operations', () => { + const setupQueue = (QueueClass, size) => { + const queue = new QueueClass(size); + const tokens = []; + for ( let i = 0; i < size; i++ ) { + const token = generateToken(); + tokens.push(token); + queue.push(token); + } + return { queue, tokens }; + }; + + bench('has() on existing items - CircularQueue', () => { + const { queue, tokens } = setupQueue(CircularQueue, 100); + for ( let i = 0; i < 1000; i++ ) { + queue.has(tokens[i % tokens.length]); + } + }); + + bench('has() on existing items - NaiveCircularQueue (baseline)', () => { + const { queue, tokens } = setupQueue(NaiveCircularQueue, 100); + for ( let i = 0; i < 1000; i++ ) { + queue.has(tokens[i % tokens.length]); + } + }); + + bench('has() on non-existing items - CircularQueue', () => { + const { queue } = setupQueue(CircularQueue, 100); + for ( let i = 0; i < 1000; i++ ) { + queue.has(`nonexistent-token-${ i}`); + } + }); + + bench('has() on non-existing items - NaiveCircularQueue (baseline)', () => { + const { queue } = setupQueue(NaiveCircularQueue, 100); + for ( let i = 0; i < 1000; i++ ) { + queue.has(`nonexistent-token-${ i}`); + } + }); +}); + +describe('CircularQueue - maybe_consume() operations', () => { + bench('maybe_consume() on existing items', () => { + const queue = new CircularQueue(100); + const tokens = []; + for ( let i = 0; i < 100; i++ ) { + const token = generateToken(); + tokens.push(token); + queue.push(token); + } + for ( const token of tokens ) { + queue.maybe_consume(token); + } + }); + + bench('maybe_consume() mixed existing/non-existing', () => { + const queue = new CircularQueue(100); + const tokens = []; + for ( let i = 0; i < 100; i++ ) { + const token = generateToken(); + tokens.push(token); + queue.push(token); + } + for ( let i = 0; i < 200; i++ ) { + if ( i % 2 === 0 && i / 2 < tokens.length ) { + queue.maybe_consume(tokens[i / 2]); + } else { + queue.maybe_consume(`fake-token-${ i}`); + } + } + }); +}); + +describe('CircularQueue - real-world usage pattern', () => { + bench('CSRF token lifecycle: generate, validate, consume', () => { + const queue = new CircularQueue(50); + const activeTokens = []; + + for ( let i = 0; i < 500; i++ ) { + // Generate new token + const token = generateToken(); + queue.push(token); + activeTokens.push(token); + + // Occasionally validate tokens + if ( i % 3 === 0 && activeTokens.length > 0 ) { + const checkToken = activeTokens[Math.floor(Math.random() * activeTokens.length)]; + queue.has(checkToken); + } + + // Occasionally consume tokens + if ( i % 5 === 0 && activeTokens.length > 0 ) { + const consumeToken = activeTokens.shift(); + queue.maybe_consume(consumeToken); + } + } + }); +}); diff --git a/src/backend/src/util/CircularQueue.js b/src/backend/src/util/CircularQueue.js new file mode 100644 index 00000000..0f642d01 --- /dev/null +++ b/src/backend/src/util/CircularQueue.js @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * A utility class to manage a circular queue with O(1) lookup. + * Uses a Map for fast membership checks and a circular array for storage. + * + * Items expire when they are evicted from the queue (when the queue is full + * and a new item is pushed). + */ +class CircularQueue { + /** + * Creates a new CircularQueue instance with the specified size. + * + * @param {number} size - The maximum number of items the queue can hold + */ + constructor (size) { + this.size = size; + this.queue = []; + this.index = 0; + this.map = new Map(); + } + + /** + * Adds an item to the queue. If the queue is full, the oldest item is removed. + * + * @param {*} item - The item to add to the queue + */ + 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; + } + + /** + * Retrieves an item from the queue at the specified relative index. + * + * @param {number} index - The relative index from the current position + * @returns {*} The item at the specified index + */ + get (index) { + return this.queue[(this.index + index) % this.size]; + } + + /** + * Checks if the queue contains the specified item. + * + * @param {*} item - The item to check for + * @returns {boolean} True if the item exists in the queue, false otherwise + */ + has (item) { + return this.map.has(item); + } + + /** + * Attempts to consume (remove) an item from the queue if it exists. + * + * @param {*} item - The item to consume + * @returns {boolean} True if the item was found and consumed, false otherwise + */ + 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; + } +} + +module.exports = { + CircularQueue, +};