Skip to content

Commit c8b1474

Browse files
committed
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 <anshul23102@iiitd.ac.in>
1 parent ec6e78e commit c8b1474

1 file changed

Lines changed: 26 additions & 6 deletions

File tree

apps/dashboard-api/src/controllers/dbExport.controller.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,34 @@ module.exports.dbExportHandler = async (req, res, next) => {
4646
const today = new Date().toISOString().split('T')[0];
4747
const key = `project:${projectId}:export_limit:${today}`;
4848

49-
const currentCount = await redis.get(key);
50-
if (currentCount && Number(currentCount) >= maxExports) {
51-
return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`));
49+
// Atomic check-and-increment: use Lua script to prevent TOCTOU race where
50+
// two concurrent requests both read the current count, both pass the limit
51+
// check, and both increment, exceeding the daily quota. The script ensures
52+
// only one request at a time sees a sub-limit count.
53+
const luaScript = `
54+
local current = redis.call('GET', KEYS[1])
55+
current = current and tonumber(current) or 0
56+
if current >= tonumber(ARGV[1]) then
57+
return {0, current}
58+
end
59+
local newCount = redis.call('INCR', KEYS[1])
60+
if newCount == 1 then
61+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
62+
end
63+
return {1, newCount}
64+
`;
65+
66+
let result;
67+
try {
68+
result = await redis.eval(luaScript, 1, key, maxExports, 86400);
69+
} catch (err) {
70+
console.error("[Dashboard API] Redis Lua script error:", err);
71+
return next(new AppError(500, "Failed to check export quota."));
5272
}
5373

54-
const newCount = await redis.incr(key);
55-
if (newCount === 1) {
56-
await redis.expire(key, 86400); // Set expiry to 24 hours
74+
const [allowed, newCount] = result;
75+
if (!allowed) {
76+
return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`));
5777
}
5878

5979
await exportQueue.add('export-database', { projectId, collectionName, userId, email });

0 commit comments

Comments
 (0)