fix: anticsrf to store tokens in redis (#2834)

* fix

* fix: anticsrf to store tokens in redis
This commit is contained in:
Daniel Salazar
2026-04-23 18:46:43 -07:00
committed by GitHub
parent f22206fe5a
commit 1a6009c00c
7 changed files with 49 additions and 39 deletions
@@ -4,7 +4,7 @@ import MockRedis from 'ioredis-mock';
const redisStartupRetryMaxDelayMs = 2000;
const redisSlotsRefreshTimeoutMs = 5000;
const redisConnectTimeoutMs = 10000;
const redisMaxRetriesPerRequest = 1;
const redisMaxRetriesPerRequest = 2;
const redisBootRetryRegex = /Cluster(All)?FailedError|None of startup nodes is available/i;
const formatRedisError = (error: unknown): string => {
@@ -49,11 +49,11 @@ if ( process.env.REDIS_CONFIG ) {
redisOpt = new Redis.Cluster(redisConfig, {
dnsLookup: (address, callback) => callback(null, address),
clusterRetryStrategy: (attempts) => Math.min(100 + (attempts * 100), redisStartupRetryMaxDelayMs),
retryDelayOnFailover: 500,
retryDelayOnClusterDown: 1000,
retryDelayOnTryAgain: 300,
retryDelayOnFailover: 50,
retryDelayOnClusterDown: 50,
retryDelayOnTryAgain: 50,
slotsRefreshTimeout: redisSlotsRefreshTimeoutMs,
enableOfflineQueue: true,
enableOfflineQueue: false,
redisOptions: {
tls: {},
connectTimeout: redisConnectTimeoutMs,
+1 -1
View File
@@ -40,7 +40,7 @@ const anticsrf = options => async (req, res, next) => {
err.write(res);
return;
}
const has = svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf);
const has = await svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf);
if ( ! has ) {
const err = APIError.create('anti-csrf-incorrect');
err.write(res);
@@ -37,7 +37,7 @@ module.exports = eggspress('/auth/revoke-session', {
}
const svc_antiCSRF = req.services.get('anti-csrf');
if ( ! svc_antiCSRF.consume_token(actor.type.user.uuid, req.body.anti_csrf) ) {
if ( ! await svc_antiCSRF.consume_token(actor.type.user.uuid, req.body.anti_csrf) ) {
return res.status(400).json({ message: 'incorrect anti-CSRF token' });
}
+1 -1
View File
@@ -49,7 +49,7 @@ router.post('/down', express.json(), express.urlencoded({ extended: true }), con
// check anti-csrf token
const svc_antiCSRF = req.services.get('anti-csrf');
if ( ! svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf) ) {
if ( ! await svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf) ) {
return res.status(400).json({ message: 'incorrect anti-CSRF token' });
}
+1 -1
View File
@@ -33,7 +33,7 @@ router.post('/logout', auth, express.json(), async (req, res, 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) ) {
if ( ! await svc_antiCSRF.consume_token(req.user.uuid, req.body.anti_csrf) ) {
return res.status(400).json({ message: 'incorrect anti-CSRF token' });
}
// delete cookie
@@ -20,21 +20,24 @@ const eggspress = require('../../api/eggspress');
const config = require('../../config');
const { subdomain } = require('../../helpers');
const BaseService = require('../BaseService');
const { CircularQueue } = require('../../util/CircularQueue');
const { redisClient } = require('../../clients/redis/redisSingleton');
const REDIS_KEY_PREFIX = 'anticsrf:';
const MAX_TOKENS_PER_SESSION = 10;
const TOKEN_TTL_SECONDS = 60 * 60;
// Sub-millisecond tie-breaker so rapid-fire create_token calls get strictly
// increasing ZSET scores instead of falling back to lexical token ordering.
const SCORE_TIEBREAKER_MOD = 1000;
/**
* Class AntiCSRFService extends BaseService to manage and protect against Cross-Site Request Forgery (CSRF) attacks.
* It provides methods for generating, consuming, and verifying anti-CSRF tokens based on user sessions.
* Tokens are stored in Redis as a per-session sorted set (score = creation timestamp)
* so state is shared across backend instances. Only the most recent
* MAX_TOKENS_PER_SESSION tokens are retained; keys expire after TOKEN_TTL_SECONDS.
*/
class AntiCSRFService extends BaseService {
/**
* Initializes the AntiCSRFService instance and sets up the mapping
* between session IDs and their associated tokens.
*
* @returns {void}
*/
_construct () {
this.map_session_to_tokens = {};
this.score_tiebreaker_ = 0;
}
/**
@@ -63,40 +66,47 @@ class AntiCSRFService extends BaseService {
}
// TODO: session uuid instead of user
const token = this.create_token(req.user.uuid);
const token = await this.create_token(req.user.uuid);
res.send({ token });
}));
}
/**
* Creates a new anti-CSRF token for the specified session.
* If no token queue exists for the session, a new one is created.
* Creates a new anti-CSRF token for the specified session and stores it in Redis.
* Only the most recent MAX_TOKENS_PER_SESSION tokens are retained per session.
*
* @param {string} session - The session identifier
* @returns {string} The newly created token
* @returns {Promise<string>} The newly created 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;
}
async create_token (session) {
const token = this.generate_token_();
tokens.push(token);
const key = this.redis_key_(session);
this.score_tiebreaker_ = (this.score_tiebreaker_ + 1) % SCORE_TIEBREAKER_MOD;
const score = Date.now() * SCORE_TIEBREAKER_MOD + this.score_tiebreaker_;
const pipeline = redisClient.pipeline();
pipeline.zadd(key, score, token);
pipeline.zremrangebyrank(key, 0, -(MAX_TOKENS_PER_SESSION + 1));
pipeline.expire(key, TOKEN_TTL_SECONDS);
await pipeline.exec();
return token;
}
/**
* Attempts to consume (validate and remove) a token for the specified session.
* Uses an atomic ZREM so concurrent consumers can't double-spend a token.
*
* @param {string} session - The session identifier
* @param {string} token - The token to consume
* @returns {boolean} True if the token was valid and consumed, false otherwise
* @returns {Promise<boolean>} True if the token was valid and consumed, false otherwise
*/
consume_token (session, token) {
const tokens = this.map_session_to_tokens[session];
if ( ! tokens ) return false;
return tokens.maybe_consume(token);
async consume_token (session, token) {
if ( ! token ) return false;
const removed = await redisClient.zrem(this.redis_key_(session), token);
return removed > 0;
}
redis_key_ (session) {
return `${REDIS_KEY_PREFIX}${session}`;
}
/**
@@ -14,28 +14,28 @@ describe('AntiCSRFService', () => {
// Do this several times, like a user would
for ( let i = 0 ; i < 30 ; i++ ) {
const session = `session-${i}`;
// Generate 30 tokens
const tokens = [];
for ( let j = 0 ; j < 30 ; j++ ) {
tokens.push(antiCSRFService.create_token('session'));
tokens.push(await antiCSRFService.create_token(session));
}
// Only the last 10 should be valid
const results_for_stale_tokens = [];
for ( let j = 0 ; j < 20 ; j++ ) {
const result = antiCSRFService.consume_token('session', tokens[j]);
const result = await antiCSRFService.consume_token(session, tokens[j]);
results_for_stale_tokens.push(result);
}
expect(results_for_stale_tokens.every(v => v === false)).toBe(true);
// The last 10 should be valid
const results_for_valid_tokens = [];
for ( let j = 20 ; j < 30 ; j++ ) {
const result = antiCSRFService.consume_token('session', tokens[j]);
const result = await antiCSRFService.consume_token(session, tokens[j]);
results_for_valid_tokens.push(result);
}
expect(results_for_valid_tokens.every(v => v === true)).toBe(true);
// A completely arbitrary token should not be valid
expect(antiCSRFService.consume_token('session', 'arbitrary')).toBe(false);
expect(await antiCSRFService.consume_token(session, 'arbitrary')).toBe(false);
}
});
});