-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrate-limit.ts
More file actions
170 lines (145 loc) · 5.31 KB
/
rate-limit.ts
File metadata and controls
170 lines (145 loc) · 5.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
/**
* Distributed rate limiter using Upstash Redis.
* Falls back to in-memory rate limiting when Redis is not configured (development).
*/
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// ── Upstash Redis rate limiter (production) ──────────────────────────────────
let redis: Redis | null = null;
function getRedis(): Redis | null {
if (redis) return redis;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (url && token) {
redis = new Redis({ url, token });
}
return redis;
}
// Cache Ratelimit instances by config key to avoid re-creating them
const ratelimiters = new Map<string, Ratelimit>();
function getUpstashRatelimiter(config: RateLimitConfig): Ratelimit {
const key = `${config.limit}:${config.windowSeconds}`;
let rl = ratelimiters.get(key);
if (!rl) {
rl = new Ratelimit({
redis: getRedis()!,
limiter: Ratelimit.slidingWindow(config.limit, `${config.windowSeconds} s`),
prefix: 'civaccount',
});
ratelimiters.set(key, rl);
}
return rl;
}
// ── In-memory fallback (development) ─────────────────────────────────────────
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitMap = new Map<string, RateLimitEntry>();
const CLEANUP_INTERVAL = 5 * 60 * 1000;
let lastCleanup = Date.now();
function cleanup() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
for (const [key, entry] of rateLimitMap.entries()) {
if (entry.resetTime < now) {
rateLimitMap.delete(key);
}
}
}
function checkRateLimitMemory(
identifier: string,
config: RateLimitConfig
): RateLimitResult {
cleanup();
const now = Date.now();
const windowMs = config.windowSeconds * 1000;
const entry = rateLimitMap.get(identifier);
if (!entry || entry.resetTime < now) {
rateLimitMap.set(identifier, { count: 1, resetTime: now + windowMs });
return { success: true, remaining: config.limit - 1, resetIn: config.windowSeconds };
}
if (entry.count >= config.limit) {
return { success: false, remaining: 0, resetIn: Math.ceil((entry.resetTime - now) / 1000) };
}
entry.count++;
return { success: true, remaining: config.limit - entry.count, resetIn: Math.ceil((entry.resetTime - now) / 1000) };
}
// ── Public API (same interface, consumers don't change) ──────────────────────
export interface RateLimitConfig {
/** Maximum number of requests allowed in the window */
limit: number;
/** Time window in seconds */
windowSeconds: number;
}
export interface RateLimitResult {
success: boolean;
remaining: number;
resetIn: number;
}
/**
* Check if a request should be rate limited.
* Uses Upstash Redis in production, in-memory in development.
*/
export async function checkRateLimit(
identifier: string,
config: RateLimitConfig
): Promise<RateLimitResult> {
const redisClient = getRedis();
if (redisClient) {
try {
const rl = getUpstashRatelimiter(config);
const result = await rl.limit(identifier);
return {
success: result.success,
remaining: result.remaining,
resetIn: Math.ceil((result.reset - Date.now()) / 1000),
};
} catch {
// Redis failed — fall back to in-memory
return checkRateLimitMemory(identifier, config);
}
}
return checkRateLimitMemory(identifier, config);
}
/**
* Get client IP from request headers.
*
* Header preference order matters for rate-limiting correctness:
* 1. `x-vercel-forwarded-for` — set by Vercel's edge, cannot be spoofed
* by the client because Vercel overwrites whatever the request
* carried. First choice on any Vercel deployment.
* 2. `x-real-ip` — set by many reverse proxies, also overwritten by
* Vercel. Still reliable.
* 3. `x-forwarded-for` — spoofable end-to-end. Only trust the
* RIGHTMOST entry (our own proxy appends the real client IP last in
* many configurations, though Vercel appends leftmost — the
* headers above are preferred precisely because this one is
* ambiguous).
* 4. `"unknown"` fallback — all requests without a recognisable IP
* share a bucket, so the IP-less attacker can't rotate buckets by
* scrubbing headers to evade rate limits.
*
* IPv6 addresses are normalised to lowercase so the same address bucketed
* twice (once via `x-forwarded-for`, once via `x-real-ip`) hits the same
* rate-limit key.
*/
export function getClientIP(request: Request): string {
const vercelFwd = request.headers.get('x-vercel-forwarded-for');
if (vercelFwd) {
return vercelFwd.split(',')[0].trim().toLowerCase();
}
const realIP = request.headers.get('x-real-ip');
if (realIP) {
return realIP.trim().toLowerCase();
}
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
// Leftmost-ish — first entry is traditionally the client. On Vercel
// this branch is unreachable because `x-vercel-forwarded-for` always
// wins; on local dev / other hosts it's the best signal we have.
return forwarded.split(',')[0].trim().toLowerCase();
}
return 'unknown';
}