Introduction
In the Everclaw Key API repository, we discovered a high-severity rate limiting vulnerability in everclaw-key-api/server.js at line 105. The /api/keys/request endpoint, which handles API key generation and renewal, implemented IP-based rate limiting through the checkIpRateLimit() function—but with a critical architectural flaw. The function stored rate limit counters in an in-memory JavaScript Map(), meaning every server restart wiped the slate clean, and multiple server instances each maintained their own separate counters.
This matters because rate limiting is often the first line of defense against automated abuse. When that defense has holes you can drive a truck through, attackers can exhaust resources, generate unlimited API keys, or bypass intended access controls simply by timing their attacks around deployments or exploiting load balancer distribution.
The Vulnerability Explained
Let's examine the original vulnerable code in server.js:
const ipRequestCounts = new Map();
function checkIpRateLimit(ip) {
const now = Date.now();
const entry = ipRequestCounts.get(ip);
if (!entry || now - entry.windowStart > KEY_REQUEST_WINDOW_MS) {
ipRequestCounts.set(ip, { windowStart: now, count: 1 });
return true;
}
entry.count++;
ipRequestCounts.set(ip, entry);
return entry.count <= KEY_REQUEST_MAX_PER_WINDOW;
}
The function enforces 10 requests per 60-second window (KEY_REQUEST_MAX_PER_WINDOW = 10, KEY_REQUEST_WINDOW_MS = 60 * 1000). The problem? The ipRequestCounts Map lives entirely in Node.js process memory.
Specific Attack Scenarios:
-
Server Restart Bypass: An attacker hits the endpoint 10 times, exhausting their limit. They wait 30 seconds (not the full 60), then trigger a server restart (via a separate DoS attack, deployment, or crash). When the server comes back up,
ipRequestCountsis empty—they can immediately make 10 more requests. Repeat indefinitely. -
Multi-Instance Bypass: In a production environment with 3 load-balanced instances behind NGINX, each instance maintains its own
ipRequestCountsMap. An attacker can make 10 requests to instance A, 10 to instance B, and 10 to instance C—effectively 30 requests in the same 60-second window, tripling the intended limit. -
Sustained Distributed Attack: An attacker with a botnet of 100 IPs can generate 1,000 API keys per minute (10 per IP) instead of the intended 10 total, especially if they cycle through instances or time attacks around deployments.
The real-world impact for the Everclaw Key API is severe: unlimited API key generation could enable downstream abuse of whatever services those keys unlock, resource exhaustion on key validation endpoints, or database bloat from storing excessive keys.
The Fix
The fix migrates rate limiting to Redis, a persistent, shared data store. Here's the specific change to checkIpRateLimit():
Before (lines 50-60):
function checkIpRateLimit(ip) {
const now = Date.now();
const entry = ipRequestCounts.get(ip);
if (!entry || now - entry.windowStart > KEY_REQUEST_WINDOW_MS) {
ipRequestCounts.set(ip, { windowStart: now, count: 1 });
return true;
}
entry.count++;
ipRequestCounts.set(ip, entry);
return entry.count <= KEY_REQUEST_MAX_PER_WINDOW;
}
After (lines 50-68):
async function checkIpRateLimit(ip) {
// Prefer Redis so the limit survives restarts and is shared across instances.
if (redis) {
const key = `ratelimit:keys:${ip}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, Math.ceil(KEY_REQUEST_WINDOW_MS / 1000));
}
return count <= KEY_REQUEST_MAX_PER_WINDOW;
}
const now = Date.now();
const entry = ipRequestCounts.get(ip);
if (!entry || now - entry.windowStart > KEY_REQUEST_WINDOW_MS) {
ipRequestCounts.set(ip, { windowStart: now, count: 1 });
return true;
}
entry.count++;
ipRequestCounts.set(ip, entry);
return entry.count <= KEY_REQUEST_MAX_PER_WINDOW;
}
Key Improvements:
-
Redis INCR Operation:
await redis.incr(key)atomically increments the counter forratelimit:keys:{ip}. This operation is thread-safe and works correctly even with concurrent requests across multiple instances. -
Automatic Expiration:
await redis.expire(key, Math.ceil(KEY_REQUEST_WINDOW_MS / 1000))sets a 60-second TTL on the first request. Redis automatically deletes the key after 60 seconds, implementing the sliding window without manual cleanup. -
Graceful Fallback: The original in-memory logic remains as a fallback if Redis is unavailable, ensuring the API doesn't break during Redis outages (though rate limiting would be degraded).
-
Async/Await Migration: The function signature changed from
function checkIpRateLimit(ip)toasync function checkIpRateLimit(ip), and the call site at line 116 updated toif (!(await checkIpRateLimit(clientIp))).
This specific change solves the restart problem (Redis persists data to disk), the multi-instance problem (all instances share the same Redis), and provides atomic operations that prevent race conditions.
Prevention & Best Practices
To avoid rate limiting vulnerabilities in your Node.js APIs:
-
Always Use Shared Storage for Rate Limits: Redis, Memcached, or a database. Never use in-memory Maps, objects, or module-level variables for production rate limiting.
-
Implement Atomic Operations: Use Redis INCR or database UPDATE...SET count = count + 1 with proper locking. Avoid read-modify-write patterns that create race conditions.
-
Test Multi-Instance Scenarios: Run your application with multiple instances behind a load balancer in staging. Verify rate limits work correctly across all instances.
-
Monitor Rate Limit Effectiveness: Log when rate limits trigger. If you never see 429 responses, your limits might be too high or bypassable.
-
Layer Your Defenses: Combine IP-based limiting with:
- Account/API key-based limits
- CAPTCHA for suspicious patterns
- Exponential backoff for repeated violations
- Web Application Firewall (WAF) rules -
Use Battle-Tested Libraries: Consider
express-rate-limitwith Redis store,rate-limiter-flexible, or cloud provider solutions (AWS API Gateway throttling, Cloudflare Rate Limiting). -
Configure Proxy Trust Correctly: If behind a reverse proxy, set
app.set('trust proxy', true)in Express soreq.ipreflects the real client IP, not the proxy's IP.
Relevant Security Standards:
- OWASP API Security Top 10: API4:2023 Unrestricted Resource Consumption
- CWE-770: Allocation of Resources Without Limits or Throttling
- CWE-307: Improper Restriction of Excessive Authentication Attempts
Key Takeaways
-
The
ipRequestCountsMap incheckIpRateLimit()reset on every server restart, allowing attackers to bypass the 10 requests per 60 seconds limit by timing attacks around deployments. -
Load-balanced deployments multiplied the effective limit by the number of instances, since each maintained separate in-memory counters—3 instances meant 30 requests per minute per IP instead of 10.
-
Redis-backed rate limiting with
INCRandEXPIREoperations provides atomic, persistent, distributed counters that survive restarts and work correctly across all server instances. -
The fix maintains backward compatibility by keeping the in-memory fallback when Redis is unavailable, preventing complete API failure during infrastructure issues.
-
Rate limiting is only as strong as its storage layer—in-memory state is never sufficient for production security controls that must survive restarts and scale horizontally.
How Orbis AppSec Detected This
- Source: The rate limiting mechanism's reliance on in-memory storage (
ipRequestCountsMap) as the single source of truth for security-critical state. - Sink: The
checkIpRateLimit(ip)function ateverclaw-key-api/server.js:50and its invocation at line 116 in the/api/keys/requestPOST endpoint. - Missing control: Persistent, shared storage backend for rate limit counters; no synchronization mechanism across server instances or restart boundaries.
- CWE: CWE-770 (Allocation of Resources Without Limits or Throttling)
- Fix: Migrated rate limiting to Redis using atomic INCR operations with automatic TTL expiration, ensuring counters persist across restarts and synchronize across all server instances.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The Everclaw Key API rate limiting vulnerability demonstrates a common but dangerous pattern: using in-memory storage for security controls that must be reliable and consistent. While the original implementation correctly tracked request counts and enforced limits, its architectural choice of a JavaScript Map made it trivially bypassable through server restarts or multi-instance deployments.
The Redis-based fix transforms a weak, stateless protection into a robust, distributed security control. This isn't just about preventing API key abuse—it's a blueprint for implementing any rate limiting, throttling, or quota system in modern, horizontally-scaled applications.
Remember: security controls that live only in memory are security controls that can disappear at the worst possible moment. Choose persistence. Choose shared state. Choose Redis.