Introduction
Rate limiting is one of the most fundamental security controls in modern web applications. It protects your APIs from abuse, prevents brute force attacks, and ensures fair resource allocation among users. However, as with many security mechanisms, implementation details matter—a lot.
The vulnerability we're discussing today affects an Express.js authentication controller that implemented rate limiting but left the door open for determined attackers to bypass these protections entirely. While the vulnerability was classified as medium severity, the potential impact on authentication endpoints could be severe, enabling credential stuffing attacks, account enumeration, and denial of service.
The Vulnerability Explained
What Went Wrong?
The application used the popular express-rate-limit middleware to throttle requests, but it suffered from two critical weaknesses:
-
IP Rotation Vulnerability: The rate limiter tracked requests by IP address, but attackers could easily rotate through different IPs using proxy networks, VPNs, or botnets, effectively resetting their rate limit counter with each new IP.
-
Header Manipulation: The application trusted
X-Forwarded-Forheaders without proper validation. This header is commonly used by reverse proxies to pass the original client IP, but it can be trivially spoofed by attackers.
Technical Deep Dive
When you deploy an Express application behind a reverse proxy (like NGINX or a load balancer), the actual client IP is often lost. The proxy adds headers like X-Forwarded-For to preserve this information:
X-Forwarded-For: 203.0.113.45, 198.51.100.67
A naive rate limiter configuration might look like this:
// VULNERABLE CODE
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
// Trusts X-Forwarded-For without validation
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/auth/login', limiter, authController.login);
How Could It Be Exploited?
Scenario 1: Header Spoofing Attack
An attacker can send requests with forged X-Forwarded-For headers:
# Request 1
curl -H "X-Forwarded-For: 192.168.1.1" https://api.example.com/auth/login
# Request 2 (bypasses rate limit)
curl -H "X-Forwarded-For: 192.168.1.2" https://api.example.com/auth/login
# Request 3 (bypasses rate limit)
curl -H "X-Forwarded-For: 192.168.1.3" https://api.example.com/auth/login
Each request appears to come from a different IP, bypassing the rate limiter entirely.
Scenario 2: Distributed Attack via Proxy Networks
Attackers can route requests through:
- Residential proxy networks: Services that route traffic through thousands of legitimate residential IPs
- VPN networks: Rotating through different VPN exit nodes
- Botnets: Using compromised devices distributed across the internet
- Cloud infrastructure: Spinning up multiple cloud instances with different IPs
Real-World Impact
This vulnerability enables several dangerous attack vectors:
- Credential Stuffing: Attackers can test millions of username/password combinations from data breaches without being throttled
- Account Enumeration: Bypassing rate limits allows attackers to discover valid usernames by testing different accounts
- Brute Force Attacks: Password guessing attacks become feasible when rate limits can be circumvented
- Resource Exhaustion: Unlimited requests can overwhelm backend services and databases
- API Abuse: Scraping, data harvesting, or other automated abuse becomes trivial
Attack Scenario Example
Imagine an e-commerce platform with this vulnerability on its login endpoint:
1. Attacker obtains 10 million email/password pairs from a data breach
2. They use a residential proxy network with 100,000 unique IPs
3. Rate limiter allows 5 login attempts per 15 minutes per IP
4. Attacker can attempt: 100,000 IPs × 5 attempts = 500,000 login attempts every 15 minutes
5. In 24 hours: 48 million login attempts possible
6. Result: Thousands of compromised accounts, potential data breach
The Fix
Implementing Robust Rate Limiting
The proper fix involves multiple layers of defense:
1. Configure Trust Proxy Correctly
// SECURE CODE
const express = require('express');
const app = express();
// Only trust proxy headers from known proxy servers
app.set('trust proxy', ['loopback', 'linklocal', '172.31.0.0/16']);
// Or specify exact proxy IP
// app.set('trust proxy', '172.31.0.1');
2. Use Multiple Rate Limiting Keys
const rateLimit = require('express-rate-limit');
// Combine IP-based and user-based rate limiting
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
// Custom key generator for better tracking
keyGenerator: (req) => {
// Use the rightmost trusted IP from X-Forwarded-For
return req.ip;
},
// Custom handler for rate limit exceeded
handler: (req, res) => {
res.status(429).json({
error: 'Too many login attempts. Please try again later.',
retryAfter: req.rateLimit.resetTime
});
}
});
// Additional user-specific rate limiting
const userLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
keyGenerator: (req) => {
// Rate limit by username/email
return req.body.email || req.body.username || req.ip;
},
skipSuccessfulRequests: true // Only count failed attempts
});
app.use('/api/auth/login', loginLimiter, userLimiter, authController.login);
3. Implement Additional Security Layers
const { RateLimiterMemory, RateLimiterRedis } = require('rate-limiter-flexible');
// More sophisticated rate limiting with Redis
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
points: 5, // Number of points
duration: 900, // Per 15 minutes
blockDuration: 900, // Block for 15 minutes if exceeded
// Track by multiple factors
keyPrefix: 'login_attempt',
});
const rateLimitMiddleware = async (req, res, next) => {
try {
// Combine IP and user identifier
const key = `${req.ip}_${req.body.email || 'anonymous'}`;
await rateLimiter.consume(key);
next();
} catch (rejRes) {
res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(rejRes.msBeforeNext / 1000)
});
}
};
4. Add CAPTCHA for Suspicious Activity
const suspiciousActivityDetector = async (req, res, next) => {
const failedAttempts = await getFailedLoginAttempts(req.ip);
if (failedAttempts > 3 && !req.body.captchaToken) {
return res.status(400).json({
error: 'CAPTCHA verification required',
requiresCaptcha: true
});
}
if (req.body.captchaToken) {
const isValid = await verifyCaptcha(req.body.captchaToken);
if (!isValid) {
return res.status(400).json({ error: 'Invalid CAPTCHA' });
}
}
next();
};
How This Solves the Problem
The comprehensive fix addresses the vulnerability through:
- Proper Proxy Configuration: Only trusts proxy headers from verified sources
- Multi-Factor Rate Limiting: Tracks both IP addresses and user identifiers
- Distributed Storage: Uses Redis for rate limiting across multiple application instances
- Progressive Challenges: Introduces CAPTCHA after detecting suspicious patterns
- Granular Control: Different limits for different scenarios (per-IP, per-user, per-endpoint)
Conclusion
Rate limiting is a critical security control, but its effectiveness depends entirely on proper implementation. As this vulnerability demonstrates, a rate limiter that can be easily bypassed provides a false sense of security—perhaps worse than having no rate limiting at all.
The key takeaways:
- Never trust client-provided headers without proper validation and proxy configuration
- Implement multiple layers of protection rather than relying on a single mechanism
- Use distributed rate limiting for applications running across multiple instances
- Monitor and test your rate limiting implementation regularly
- Consider the full attack surface, including IP rotation, header manipulation, and distributed attacks
Security is not a checkbox—it's an ongoing process of identifying weaknesses, implementing defenses, and continuously improving. By understanding vulnerabilities like this one and implementing robust fixes, we build more resilient applications that protect both our users and our infrastructure.
Remember: in security, the details matter. A small configuration oversight can undermine an entire security mechanism. Stay vigilant, test thoroughly, and always assume attackers will try to bypass your defenses.
Stay secure, and happy coding!
Have you encountered rate limiting bypass attempts in your applications? Share your experiences and solutions in the comments below.