Summary
cache.ts silently falls back to an in-process MemoryBackend when neither KV_REST_API_URL/KV_REST_API_TOKEN (Upstash) nor REDIS_URL (ioredis) are set. In a Vercel serverless deployment each function invocation runs in an isolated process, so every invocation starts with a fresh in-memory counter. Rate limits stored in MemoryBackend are never shared across invocations. The result: an attacker making many simultaneous requests hits a different Lambda each time and sees a perpetual count of 1, regardless of the configured limit.
Affected Files
src/lib/cache.ts (silent fallback in pickDefaultBackend)
src/lib/rate-limit.ts (caller has no way to know the backend is non-functional)
Root Cause
// cache.ts
function pickDefaultBackend(): CacheBackend {
const upstashUrl = process.env.KV_REST_API_URL;
const upstashToken = process.env.KV_REST_API_TOKEN;
if (upstashUrl && upstashToken) {
return new UpstashBackend(new UpstashRedis({ url: upstashUrl, token: upstashToken }));
}
const redisUrl = process.env.REDIS_URL;
if (redisUrl) {
// ... returns IoRedisBackend
}
return new MemoryBackend(); // silent fallback - no warning, no error
}
When MemoryBackend is active, cacheGet and cacheSet in rateLimit() read and write to a Map that lives only for the lifetime of the current process. Vercel cold-starts each function invocation in a new process, so the window counter never accumulates across requests.
Impact
Every server action that calls rateLimit() is affected:
| Action |
Nominal Limit |
Actual Limit (no Redis) |
recs:get |
60 per 60s |
Unlimited |
recs:claim |
20 per 60s |
Unlimited |
recs:link-pr |
10 per 60s |
Unlimited |
recs:skip |
30 per 60s |
Unlimited |
maint:queue |
60 per 60s |
Unlimited |
An attacker can script unlimited claim attempts, skip operations, and PR link requests without any throttle, undermining the 3-claim limit (which is also protected only by a non-atomic count check per issue #205).
There is no log message, startup warning, or health check that reveals rate limiting is not functioning. The system behaves identically in both states from the caller's perspective.
Steps to Reproduce
- Deploy the app to Vercel without setting
KV_REST_API_URL or REDIS_URL.
- Fire more than
limit requests per windowSec to any rate-limited server action from different client IPs.
- Observe that all requests return
ok: true.
Expected Behavior
- If no distributed cache backend is configured, the app should either refuse to start (strict mode) or log a startup warning that clearly states rate limiting is operating in a degraded, non-distributed mode.
- The
MemoryBackend fallback should be explicitly documented as unsafe for multi-instance production deployments.
Proposed Fix Direction
Add a production safety check to pickDefaultBackend:
if (process.env.NODE_ENV === 'production' && !upstashUrl && !redisUrl) {
console.error(
'[cache] WARNING: No Redis backend configured. Rate limiting is PER-INSTANCE ' +
'and will NOT enforce limits across concurrent serverless invocations. ' +
'Set KV_REST_API_URL/KV_REST_API_TOKEN or REDIS_URL to enable distributed rate limiting.',
);
}
return new MemoryBackend();
Additionally, export a isCacheDistributed(): boolean helper so health-check endpoints and startup logs can surface this state without parsing env vars manually.
Related
Summary
cache.tssilently falls back to an in-processMemoryBackendwhen neitherKV_REST_API_URL/KV_REST_API_TOKEN(Upstash) norREDIS_URL(ioredis) are set. In a Vercel serverless deployment each function invocation runs in an isolated process, so every invocation starts with a fresh in-memory counter. Rate limits stored inMemoryBackendare never shared across invocations. The result: an attacker making many simultaneous requests hits a different Lambda each time and sees a perpetual count of 1, regardless of the configuredlimit.Affected Files
src/lib/cache.ts(silent fallback inpickDefaultBackend)src/lib/rate-limit.ts(caller has no way to know the backend is non-functional)Root Cause
When
MemoryBackendis active,cacheGetandcacheSetinrateLimit()read and write to aMapthat lives only for the lifetime of the current process. Vercel cold-starts each function invocation in a new process, so the window counter never accumulates across requests.Impact
Every server action that calls
rateLimit()is affected:recs:getrecs:claimrecs:link-prrecs:skipmaint:queueAn attacker can script unlimited claim attempts, skip operations, and PR link requests without any throttle, undermining the 3-claim limit (which is also protected only by a non-atomic count check per issue #205).
There is no log message, startup warning, or health check that reveals rate limiting is not functioning. The system behaves identically in both states from the caller's perspective.
Steps to Reproduce
KV_REST_API_URLorREDIS_URL.limitrequests perwindowSecto any rate-limited server action from different client IPs.ok: true.Expected Behavior
MemoryBackendfallback should be explicitly documented as unsafe for multi-instance production deployments.Proposed Fix Direction
Add a production safety check to
pickDefaultBackend:Additionally, export a
isCacheDistributed(): booleanhelper so health-check endpoints and startup logs can surface this state without parsing env vars manually.Related
src/lib/rate-limit.ts— therateLimit()caller; returnsok: trueregardless of backend