Files
CapaKraken/packages/api/src/__tests__/rate-limit.test.ts
T
Hartmut 3c5d1d37f7 security: rate-limit IP-keyed, fail-closed on empty key (#37)
Rate-limiter now accepts string | string[] so callers can key on
multiple buckets simultaneously. If any bucket is exhausted the
request is denied, which lets login/TOTP/reset-password throttle on
BOTH user identifier and source IP without either becoming a bypass.

Fail-closed: empty/whitespace-only keys now deny by default instead
of silently allowing unbounded attempts (was CWE-307 gap).

Degraded-fallback divisor reduced from /10 to /2 — the old aggressive
clamp forced-logged-out legitimate users during brief Redis outages;
/2 still meaningfully slows distributed brute-force.

Callers updated:
- auth.ts (login): both email: and ip: buckets
- auth router requestPasswordReset: email + IP
- auth router resetPassword: IP before lookup, email-reset after
- invite router getInvite/acceptInvite: IP
- user-self-service verifyTotp: userId + IP

TRPCContext now carries clientIp; web tRPC route extracts it from
X-Forwarded-For / X-Real-IP.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 08:19:33 +02:00

159 lines
5.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("rate limiter", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-30T10:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
vi.resetModules();
vi.unmock("ioredis");
vi.unstubAllEnvs();
});
it("enforces limits and reset in memory mode", async () => {
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "memory-test" });
const first = await limiter("user-1");
const second = await limiter("user-1");
const third = await limiter("user-1");
expect(first.allowed).toBe(true);
expect(first.remaining).toBe(1);
expect(second.allowed).toBe(true);
expect(second.remaining).toBe(0);
expect(third.allowed).toBe(false);
expect(third.remaining).toBe(0);
await limiter.reset();
const afterReset = await limiter("user-1");
expect(afterReset.allowed).toBe(true);
expect(afterReset.remaining).toBe(1);
});
it("uses a shared Redis counter when Redis mode is enabled", async () => {
const store = new Map<string, { count: number; resetAt: number }>();
const delMock = vi.fn(async (...keys: string[]) => {
for (const key of keys) {
store.delete(key);
}
return keys.length;
});
vi.doMock("ioredis", () => ({
Redis: vi.fn().mockImplementation(() => ({
on: vi.fn(),
eval: vi.fn(
async (_script: string, _numKeys: number, key: string, windowMsValue: string) => {
const now = Date.now();
const windowMs = Number(windowMsValue);
const existing = store.get(key);
if (!existing || existing.resetAt <= now) {
store.set(key, { count: 1, resetAt: now + windowMs });
} else {
existing.count += 1;
}
const current = store.get(key)!;
return [current.count, current.resetAt - now];
},
),
scan: vi.fn(async () => ["0", [...store.keys()]]),
del: delMock,
})),
}));
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 2, {
backend: "redis",
redisUrl: "redis://test",
name: "redis-test",
});
const first = await limiter("user-1");
const second = await limiter("user-1");
const third = await limiter("user-1");
expect(first.allowed).toBe(true);
expect(second.allowed).toBe(true);
expect(third.allowed).toBe(false);
expect(third.remaining).toBe(0);
await limiter.reset();
expect(delMock).toHaveBeenCalled();
const afterReset = await limiter("user-1");
expect(afterReset.allowed).toBe(true);
expect(afterReset.remaining).toBe(1);
});
it("falls back to degraded in-memory counters when Redis is unavailable", async () => {
vi.doMock("ioredis", () => ({
Redis: vi.fn().mockImplementation(() => ({
on: vi.fn(),
eval: vi.fn(async () => {
throw new Error("redis down");
}),
scan: vi.fn(async () => ["0", []]),
del: vi.fn(async () => 0),
})),
}));
const { createRateLimiter } = await import("../middleware/rate-limit.js");
// Degraded fallback uses max(1, floor(maxRequests/2)), so with
// maxRequests=4 the degraded limit is 2 attempts within the window.
const limiter = createRateLimiter(60_000, 4, {
backend: "redis",
redisUrl: "redis://test",
name: "redis-fallback-test",
});
const first = await limiter("user-1");
const second = await limiter("user-1");
const third = await limiter("user-1");
expect(first.allowed).toBe(true);
expect(second.allowed).toBe(true);
expect(third.allowed).toBe(false);
expect(third.remaining).toBe(0);
});
it("denies by default when called with an empty key (fail-closed)", async () => {
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 5, { backend: "memory", name: "empty-key-test" });
const empty = await limiter("");
const whitespace = await limiter(" ");
const emptyArray = await limiter([]);
const allEmpty = await limiter(["", " "]);
expect(empty.allowed).toBe(false);
expect(whitespace.allowed).toBe(false);
expect(emptyArray.allowed).toBe(false);
expect(allEmpty.allowed).toBe(false);
});
it("denies if any key in a multi-key call is over its limit", async () => {
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "multi-key-test" });
// Exhaust the "email:a" bucket alone
await limiter("email:a");
await limiter("email:a");
const emailExhausted = await limiter("email:a");
expect(emailExhausted.allowed).toBe(false);
// A call keyed on both email:a AND ip:x must deny because email:a is
// exhausted, even though ip:x is fresh.
const combined = await limiter(["email:a", "ip:x"]);
expect(combined.allowed).toBe(false);
// A fresh bucket pair still succeeds.
const freshPair = await limiter(["email:b", "ip:y"]);
expect(freshPair.allowed).toBe(true);
});
});