From c8b147410054785568f84fe8aa27400960dca337 Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Thu, 2 Jul 2026 17:15:55 +0530 Subject: [PATCH] fix: make daily export limit check atomic to prevent concurrent requests bypassing quota The export limit check used a non-atomic pattern: read the current count from Redis, check if it exceeds the daily maximum, then increment. Two concurrent requests arriving simultaneously could both read the same count (0 if it was a fresh day), both pass the limit check (0 < 1 or 5), and both increment, resulting in a count of 2 despite a quota of 1 or 5. Use a Lua script to atomically check and increment the quota counter in a single Redis round-trip. The script is executed atomically on the server, ensuring only one request sees a sub-limit count at a time. Concurrent requests now correctly queue and are rejected once the limit is reached, preventing quota bypass. Closes #255 Signed-off-by: Anshul Jain --- .../src/controllers/dbExport.controller.js | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/dashboard-api/src/controllers/dbExport.controller.js b/apps/dashboard-api/src/controllers/dbExport.controller.js index 9072a5c4..c718b128 100644 --- a/apps/dashboard-api/src/controllers/dbExport.controller.js +++ b/apps/dashboard-api/src/controllers/dbExport.controller.js @@ -46,14 +46,34 @@ module.exports.dbExportHandler = async (req, res, next) => { const today = new Date().toISOString().split('T')[0]; const key = `project:${projectId}:export_limit:${today}`; - const currentCount = await redis.get(key); - if (currentCount && Number(currentCount) >= maxExports) { - return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`)); + // Atomic check-and-increment: use Lua script to prevent TOCTOU race where + // two concurrent requests both read the current count, both pass the limit + // check, and both increment, exceeding the daily quota. The script ensures + // only one request at a time sees a sub-limit count. + const luaScript = ` + local current = redis.call('GET', KEYS[1]) + current = current and tonumber(current) or 0 + if current >= tonumber(ARGV[1]) then + return {0, current} + end + local newCount = redis.call('INCR', KEYS[1]) + if newCount == 1 then + redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2])) + end + return {1, newCount} + `; + + let result; + try { + result = await redis.eval(luaScript, 1, key, maxExports, 86400); + } catch (err) { + console.error("[Dashboard API] Redis Lua script error:", err); + return next(new AppError(500, "Failed to check export quota.")); } - const newCount = await redis.incr(key); - if (newCount === 1) { - await redis.expire(key, 86400); // Set expiry to 24 hours + const [allowed, newCount] = result; + if (!allowed) { + return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`)); } await exportQueue.add('export-database', { projectId, collectionName, userId, email });