Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 63 additions & 41 deletions src/middleware/rateLimit.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* Rate limiting middleware
*
*
* Uses in-memory storage by default.
* Can be configured to use Redis for distributed deployments.
*/

const config = require('../config');
const { RateLimitError } = require('../utils/errors');
const config = require("../config");
const { RateLimitError } = require("../utils/errors");

// In-memory storage for rate limiting
const storage = new Map();
Expand All @@ -15,9 +15,9 @@ const storage = new Map();
setInterval(() => {
const now = Date.now();
const cutoff = now - 3600000; // 1 hour

for (const [key, entries] of storage.entries()) {
const filtered = entries.filter(e => e.timestamp >= cutoff);
const filtered = entries.filter((e) => e.timestamp >= cutoff);
if (filtered.length === 0) {
storage.delete(key);
} else {
Expand All @@ -28,103 +28,125 @@ setInterval(() => {

/**
* Get rate limit key from request
*
* Parses the Authorization header directly instead of relying on req.token,
* which is only set by requireAuth middleware. This prevents a race condition
* when the rate limiter runs before auth middleware in the middleware chain.
*
* Fixes: #5, #8, #16, #28, #33, #34, #55
*/
function getKey(req, limitType) {
const identifier = req.token || req.ip || 'anonymous';
let identifier;

// Parse Authorization header directly — don't depend on req.token
// because this middleware may run before requireAuth sets it
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
identifier = authHeader.substring(7);
} else if (req.token) {
// Fallback to req.token if already set by prior middleware
identifier = req.token;
} else {
identifier = req.ip || "anonymous";
}

return `rl:${limitType}:${identifier}`;
}

/**
* Check and consume rate limit
*
*
* @param {string} key - Rate limit key
* @param {Object} limit - Limit config { max, window }
* @returns {Object} { allowed, remaining, resetAt, retryAfter }
*/
function checkLimit(key, limit) {
const now = Date.now();
const windowStart = now - (limit.window * 1000);
const windowStart = now - limit.window * 1000;

// Get or create entries
let entries = storage.get(key) || [];

// Filter to current window
entries = entries.filter(e => e.timestamp >= windowStart);
entries = entries.filter((e) => e.timestamp >= windowStart);

const count = entries.length;
const allowed = count < limit.max;
const remaining = Math.max(0, limit.max - count - (allowed ? 1 : 0));

// Calculate reset time
let resetAt;
let retryAfter = 0;

if (entries.length > 0) {
const oldest = Math.min(...entries.map(e => e.timestamp));
resetAt = new Date(oldest + (limit.window * 1000));
const oldest = Math.min(...entries.map((e) => e.timestamp));
resetAt = new Date(oldest + limit.window * 1000);
retryAfter = Math.ceil((resetAt.getTime() - now) / 1000);
} else {
resetAt = new Date(now + (limit.window * 1000));
resetAt = new Date(now + limit.window * 1000);
}

// Consume if allowed
if (allowed) {
entries.push({ timestamp: now });
storage.set(key, entries);
}

return {
allowed,
remaining,
limit: limit.max,
resetAt,
retryAfter: allowed ? 0 : retryAfter
retryAfter: allowed ? 0 : retryAfter,
};
}

/**
* Create rate limit middleware
*
*
* @param {string} limitType - Type of limit ('requests', 'posts', 'comments')
* @param {Object} options - Options
* @returns {Function} Express middleware
*/
function rateLimit(limitType = 'requests', options = {}) {
function rateLimit(limitType = "requests", options = {}) {
const limit = config.rateLimits[limitType];

if (!limit) {
throw new Error(`Unknown rate limit type: ${limitType}`);
}

const {
skip = () => false,
keyGenerator = (req) => getKey(req, limitType),
message = `Rate limit exceeded`
message = `Rate limit exceeded`,
} = options;

return async (req, res, next) => {
try {
// Check if should skip
if (await Promise.resolve(skip(req))) {
return next();
}

const key = await Promise.resolve(keyGenerator(req));
const result = checkLimit(key, limit);

// Set headers
res.setHeader('X-RateLimit-Limit', result.limit);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', Math.floor(result.resetAt.getTime() / 1000));

res.setHeader("X-RateLimit-Limit", result.limit);
res.setHeader("X-RateLimit-Remaining", result.remaining);
res.setHeader(
"X-RateLimit-Reset",
Math.floor(result.resetAt.getTime() / 1000),
);

if (!result.allowed) {
res.setHeader('Retry-After', result.retryAfter);
res.setHeader("Retry-After", result.retryAfter);
throw new RateLimitError(message, result.retryAfter);
}

// Attach rate limit info to request
req.rateLimit = result;

next();
} catch (error) {
next(error);
Expand All @@ -135,25 +157,25 @@ function rateLimit(limitType = 'requests', options = {}) {
/**
* General request rate limiter (100/min)
*/
const requestLimiter = rateLimit('requests');
const requestLimiter = rateLimit("requests");

/**
* Post creation rate limiter (1/30min)
*/
const postLimiter = rateLimit('posts', {
message: 'You can only post once every 30 minutes'
const postLimiter = rateLimit("posts", {
message: "You can only post once every 30 minutes",
});

/**
* Comment rate limiter (50/hr)
*/
const commentLimiter = rateLimit('comments', {
message: 'Too many comments, slow down'
const commentLimiter = rateLimit("comments", {
message: "Too many comments, slow down",
});

module.exports = {
rateLimit,
requestLimiter,
postLimiter,
commentLimiter
commentLimiter,
};
Loading