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" ;
923import { 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+
67181export const emailRateLimiter = new EmailRateLimiter ( ) ;
0 commit comments