Skip to content

[Bug] MemoryBackend silently selected when Redis is unconfigured, making all rate limiting per-Lambda-invocation and effectively disabled in production #215

@anshul23102

Description

@anshul23102

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

  1. Deploy the app to Vercel without setting KV_REST_API_URL or REDIS_URL.
  2. Fire more than limit requests per windowSec to any rate-limited server action from different client IPs.
  3. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions