Skip to content

Commit 8632bd0

Browse files
committed
fix(rate-limit): back email rate limiter with Valkey for multi-replica
Per-process Map enforced the resend/forgot-password cap per replica, so horizontal scale multiplied the real limit by the replica count. Use a shared Valkey counter when CACHE_PROVIDER=valkey, falling back to in-memory on any cache failure (degrade to per-process, never to no enforcement). check() is now async; await it at all 5 call sites. Audit: F004
1 parent 451fcce commit 8632bd0

4 files changed

Lines changed: 158 additions & 44 deletions

File tree

apps/api/src/api/accounts/accounts.routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const accountsRoutes = requireAuth()
9393
throw ApiErrors.forbidden("Only an account owner or admin can invite");
9494
}
9595

96-
if (!emailRateLimiter.check(body.email)) {
96+
if (!(await emailRateLimiter.check(body.email))) {
9797
throw ApiErrors.validation(
9898
"Too many invitation emails for this address. Please wait a few minutes.",
9999
"email"
@@ -145,7 +145,7 @@ const accountsRoutes = requireAuth()
145145
params.invitationId
146146
);
147147

148-
if (!emailRateLimiter.check(pending.email)) {
148+
if (!(await emailRateLimiter.check(pending.email))) {
149149
throw ApiErrors.validation(
150150
"Too many invitation emails for this address. Please wait a few minutes.",
151151
"email"

apps/api/src/api/auth/auth.routes.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const credentialingRoutes = new Elysia()
5959
.post(
6060
"/register",
6161
async ({ body }) => {
62-
if (!emailRateLimiter.check(body.email)) {
62+
if (!(await emailRateLimiter.check(body.email))) {
6363
throw ApiErrors.validation(
6464
"Too many registration attempts for this email. Please wait a few minutes.",
6565
"email"
@@ -157,7 +157,7 @@ const credentialingRoutes = new Elysia()
157157
.post(
158158
"/resend-verification",
159159
async ({ body }) => {
160-
if (!emailRateLimiter.check(body.email)) {
160+
if (!(await emailRateLimiter.check(body.email))) {
161161
throw ApiErrors.validation(
162162
"Too many verification emails requested. Please wait a few minutes.",
163163
"email"
@@ -289,7 +289,7 @@ const credentialingRoutes = new Elysia()
289289
.post(
290290
"/forgot-password",
291291
async ({ body }) => {
292-
if (!emailRateLimiter.check(body.email)) {
292+
if (!(await emailRateLimiter.check(body.email))) {
293293
throw ApiErrors.validation(
294294
"Too many password reset requests. Please wait a few minutes.",
295295
"email"
Lines changed: 133 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,47 @@
11
/**
2-
* Lightweight per-email rate limiter. In-memory only — sufficient for
3-
* single-process deployments (the default target of this template).
4-
* For horizontal scale, swap in a Valkey-backed implementation.
2+
* Per-email rate limiter for endpoints that trigger external email delivery
3+
* (resend-verification, forgot-password) — caps inbox-spam attacks from
4+
* distributed IPs.
55
*
6-
* Used on endpoints that trigger external email delivery (resend-verification,
7-
* forgot-password) to prevent inbox-spam attacks from distributed IPs.
6+
* Two backends, selected by config (mirroring `security.ts`'s rate-limit
7+
* context choice):
8+
*
9+
* - **Valkey** when `CACHE_ENABLED && CACHE_PROVIDER === "valkey"`: a shared
10+
* counter so the quota holds across replicas. A per-process limiter is
11+
* bypassable under horizontal scale — an attacker just rotates which
12+
* replica they hit, multiplying the real cap by the replica count.
13+
* - **In-memory** otherwise (the single-process default of this template),
14+
* and as the fallback when a Valkey call fails — so a cache blip degrades
15+
* to per-process enforcement rather than no enforcement at all.
816
*/
17+
import { Redis } from "ioredis";
18+
19+
import { getValkeyAppClientOptions } from "../../clients/valkey";
20+
import { env } from "../../config/env";
21+
import { logger } from "../../config/logger";
22+
import { getErrorMessage } from "../errors";
923
import { nowMs } from "../time/now";
1024

11-
class EmailRateLimiter {
12-
private static readonly windowMs = 300_000; // 5 minutes
13-
private static readonly maxAttempts = 3;
14-
private static readonly sweepIntervalMs = 600_000; // 10 minutes
25+
const WINDOW_MS = 300_000; // 5 minutes
26+
const MAX_ATTEMPTS = 3;
27+
const SWEEP_INTERVAL_MS = 600_000; // 10 minutes
28+
const KEY_PREFIX = "erl:";
1529

30+
class InMemoryEmailRateLimiter {
1631
private readonly attempts = new Map<string, number[]>();
1732

1833
constructor() {
1934
setInterval(() => {
2035
this.sweep();
21-
}, EmailRateLimiter.sweepIntervalMs).unref();
36+
}, SWEEP_INTERVAL_MS).unref();
2237
}
2338

24-
check(email: string): boolean {
39+
check(key: string): boolean {
2540
const now = nowMs();
26-
const key = email.toLowerCase().trim();
2741
const timestamps = this.attempts.get(key) ?? [];
42+
const valid = timestamps.filter((timestamp) => now - timestamp < WINDOW_MS);
2843

29-
// Prune stale entries outside the window
30-
const valid = timestamps.filter(
31-
(timestamp) => now - timestamp < EmailRateLimiter.windowMs
32-
);
33-
34-
if (valid.length >= EmailRateLimiter.maxAttempts) {
44+
if (valid.length >= MAX_ATTEMPTS) {
3545
this.attempts.set(key, valid);
3646

3747
return false;
@@ -52,7 +62,7 @@ class EmailRateLimiter {
5262

5363
for (const [key, timestamps] of this.attempts) {
5464
const valid = timestamps.filter(
55-
(timestamp) => now - timestamp < EmailRateLimiter.windowMs
65+
(timestamp) => now - timestamp < WINDOW_MS
5666
);
5767

5868
if (valid.length === 0) {
@@ -64,4 +74,108 @@ class EmailRateLimiter {
6474
}
6575
}
6676

77+
class ValkeyEmailRateLimiter {
78+
private client: Redis | null = null;
79+
private readonly fallback: InMemoryEmailRateLimiter;
80+
81+
constructor(fallback: InMemoryEmailRateLimiter) {
82+
this.fallback = fallback;
83+
}
84+
85+
private getClient(): Redis {
86+
if (this.client !== null) {
87+
return this.client;
88+
}
89+
90+
const client = new Redis(getValkeyAppClientOptions());
91+
92+
client.on("error", (err: Error) => {
93+
logger.warn("Email rate-limit Valkey client error", {
94+
event: "cache_valkey_error",
95+
error: err.message,
96+
});
97+
});
98+
99+
this.client = client;
100+
101+
return client;
102+
}
103+
104+
/**
105+
* Fixed-window counter: INCR the key, set its TTL only on the first write
106+
* (PEXPIRE NX), and allow while the count is within the cap. Any Valkey
107+
* failure falls back to the in-memory limiter so enforcement never silently
108+
* drops to nothing.
109+
*/
110+
async check(key: string): Promise<boolean> {
111+
const fullKey = `${KEY_PREFIX}${key}`;
112+
113+
try {
114+
const result = await this.getClient()
115+
.multi()
116+
.incr(fullKey)
117+
.pexpire(fullKey, WINDOW_MS, "NX")
118+
.exec();
119+
120+
if (result === null) {
121+
return this.fallback.check(key);
122+
}
123+
124+
const countCmd = result[0];
125+
126+
if (!countCmd) {
127+
return this.fallback.check(key);
128+
}
129+
130+
const [countErr, countRaw] = countCmd;
131+
132+
if (countErr !== null) {
133+
return this.fallback.check(key);
134+
}
135+
136+
const count = typeof countRaw === "number" ? countRaw : Number(countRaw);
137+
138+
if (Number.isNaN(count)) {
139+
return this.fallback.check(key);
140+
}
141+
142+
return count <= MAX_ATTEMPTS;
143+
} catch (error: unknown) {
144+
logger.warn(
145+
"Email rate-limit Valkey check failed; falling back to in-memory",
146+
{
147+
event: "cache_valkey_error",
148+
error: getErrorMessage(error),
149+
}
150+
);
151+
152+
return this.fallback.check(key);
153+
}
154+
}
155+
}
156+
157+
class EmailRateLimiter {
158+
private readonly inMemory = new InMemoryEmailRateLimiter();
159+
private readonly valkey = new ValkeyEmailRateLimiter(this.inMemory);
160+
161+
/**
162+
* Returns `true` when the email is allowed another attempt, `false` when it
163+
* has exhausted its window. Email is normalized (trim + lowercase) so casing
164+
* and whitespace share one bucket.
165+
*/
166+
check(email: string): Promise<boolean> {
167+
const key = email.toLowerCase().trim();
168+
169+
if (env.CACHE_ENABLED && env.CACHE_PROVIDER === "valkey") {
170+
return this.valkey.check(key);
171+
}
172+
173+
return Promise.resolve(this.inMemory.check(key));
174+
}
175+
176+
sweep(): void {
177+
this.inMemory.sweep();
178+
}
179+
}
180+
67181
export const emailRateLimiter = new EmailRateLimiter();

apps/api/tests/lib/rate-limit/email-rate-limit.test.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,44 +6,44 @@ const unique = (prefix: string): string =>
66
`${prefix}-${String(Date.now())}-${String(Math.random()).slice(2)}@example.com`;
77

88
describe("emailRateLimiter.check", () => {
9-
test("allows the first three attempts within the window", () => {
9+
test("allows the first three attempts within the window", async () => {
1010
const email = unique("first-three");
1111

12-
expect(emailRateLimiter.check(email)).toBe(true);
13-
expect(emailRateLimiter.check(email)).toBe(true);
14-
expect(emailRateLimiter.check(email)).toBe(true);
12+
expect(await emailRateLimiter.check(email)).toBe(true);
13+
expect(await emailRateLimiter.check(email)).toBe(true);
14+
expect(await emailRateLimiter.check(email)).toBe(true);
1515
});
1616

17-
test("blocks the fourth attempt", () => {
17+
test("blocks the fourth attempt", async () => {
1818
const email = unique("fourth-blocked");
1919

20-
emailRateLimiter.check(email);
21-
emailRateLimiter.check(email);
22-
emailRateLimiter.check(email);
20+
await emailRateLimiter.check(email);
21+
await emailRateLimiter.check(email);
22+
await emailRateLimiter.check(email);
2323

24-
expect(emailRateLimiter.check(email)).toBe(false);
24+
expect(await emailRateLimiter.check(email)).toBe(false);
2525
});
2626

27-
test("treats trimming + casing as the same bucket", () => {
27+
test("treats trimming + casing as the same bucket", async () => {
2828
const base = unique("normalize");
2929

30-
emailRateLimiter.check(base);
31-
emailRateLimiter.check(` ${base.toUpperCase()} `);
32-
emailRateLimiter.check(base);
30+
await emailRateLimiter.check(base);
31+
await emailRateLimiter.check(` ${base.toUpperCase()} `);
32+
await emailRateLimiter.check(base);
3333

34-
expect(emailRateLimiter.check(base)).toBe(false);
34+
expect(await emailRateLimiter.check(base)).toBe(false);
3535
});
3636

37-
test("isolates buckets across distinct emails", () => {
37+
test("isolates buckets across distinct emails", async () => {
3838
const firstEmail = unique("isolate-a");
3939
const secondEmail = unique("isolate-b");
4040

41-
emailRateLimiter.check(firstEmail);
42-
emailRateLimiter.check(firstEmail);
43-
emailRateLimiter.check(firstEmail);
41+
await emailRateLimiter.check(firstEmail);
42+
await emailRateLimiter.check(firstEmail);
43+
await emailRateLimiter.check(firstEmail);
4444

45-
expect(emailRateLimiter.check(firstEmail)).toBe(false);
46-
expect(emailRateLimiter.check(secondEmail)).toBe(true);
45+
expect(await emailRateLimiter.check(firstEmail)).toBe(false);
46+
expect(await emailRateLimiter.check(secondEmail)).toBe(true);
4747
});
4848
});
4949

0 commit comments

Comments
 (0)