-
Notifications
You must be signed in to change notification settings - Fork 34
Expand file tree
/
Copy pathrate-limit.ts
More file actions
326 lines (280 loc) · 10.2 KB
/
Copy pathrate-limit.ts
File metadata and controls
326 lines (280 loc) · 10.2 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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { NextRequest, NextResponse } from "next/server";
type WindowUnit = "s" | "m" | "h";
type WindowString = `${number} ${WindowUnit}`;
type RateLimitPolicy = {
limit: number;
window: WindowString;
};
type RateLimitResult = {
success: boolean;
limit: number;
remaining: number;
reset: number;
};
const MAX_LOCAL_BUCKETS = 10_000;
const DEFAULT_POLICY: RateLimitPolicy = { limit: 20, window: "1 m" };
/**
* Hard ceiling applied to every `/api/*` request, keyed by client IP only.
*
* This is the safety net enforced in `proxy.ts` before a request ever reaches
* a route handler. Per-route policies below are stricter and are counted in
* their own independent buckets.
*/
export const GLOBAL_API_POLICY: RateLimitPolicy = { limit: 100, window: "1 m" };
const ROUTE_POLICIES: Record<string, RateLimitPolicy> = {
"/api/loans/apply": { limit: 5, window: "10 m" },
"/api/loans/fund": { limit: 10, window: "10 m" },
"/api/loans/repay": { limit: 10, window: "10 m" },
"/api/loans/repay/preflight": { limit: 30, window: "1 m" },
"/api/loans/repayments": { limit: 30, window: "1 m" },
"/api/pools": { limit: 60, window: "1 m" },
"/api/pools/deposit": { limit: 15, window: "10 m" },
"/api/pools/withdraw": { limit: 10, window: "10 m" },
"/api/sponsor": { limit: 10, window: "1 m" },
"/api/tasks/complete": { limit: 30, window: "10 m" },
"/api/notifications/clear": { limit: 20, window: "1 m" },
"/api/kyc/token": { limit: 10, window: "1 m" },
"/api/kyc/webhook": { limit: 20, window: "10 m" },
"/api/analytics": { limit: 60, window: "1 m" },
"/api/reputation": { limit: 60, window: "1 m" },
"/api/notifications": { limit: 60, window: "1 m" },
"/api/borrower/transactions": { limit: 60, window: "1 m" },
"/api/lender/transactions": { limit: 60, window: "1 m" },
"/api/lender/tax-report": { limit: 5, window: "10 m" },
"/api/treasury": { limit: 60, window: "1 m" },
"/api/metrics": { limit: 60, window: "1 m" },
"/api/admin/webhooks": { limit: 30, window: "1 m" },
"/api/admin/risk-parameters": { limit: 20, window: "1 m" },
};
/**
* Policies for dynamic route segments, matched in order when no exact
* `ROUTE_POLICIES` entry exists for the pathname.
*/
const ROUTE_PATTERN_POLICIES: Array<{ pattern: RegExp; policy: RateLimitPolicy }> = [
// PDF generation is expensive — keep it tight.
{ pattern: /^\/api\/loans\/[^/]+\/receipt$/, policy: { limit: 10, window: "10 m" } },
{ pattern: /^\/api\/admin\/webhooks\/[^/]+$/, policy: { limit: 30, window: "1 m" } },
// Pool borrow-cap management — sensitive admin action, keep conservative
{ pattern: /^\/api\/admin\/pools\/[^/]+\/borrow-cap$/, policy: { limit: 10, window: "1 m" } },
];
const localWindowStore = new Map<string, { count: number; reset: number }>();
const redis =
process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
: null;
const ratelimiters = new Map<string, Ratelimit>();
function getPolicy(pathname: string): RateLimitPolicy {
const exact = ROUTE_POLICIES[pathname];
if (exact) return exact;
const matched = ROUTE_PATTERN_POLICIES.find(({ pattern }) => pattern.test(pathname));
return matched?.policy ?? DEFAULT_POLICY;
}
function getWindowMs(window: WindowString): number {
const [value, unit] = window.split(" ") as [string, WindowUnit];
const amount = Number(value);
switch (unit) {
case "s":
return amount * 1_000;
case "m":
return amount * 60_000;
case "h":
return amount * 3_600_000;
}
}
function pruneLocalWindowStore(now: number) {
for (const [key, value] of localWindowStore) {
if (value.reset <= now) {
localWindowStore.delete(key);
}
}
if (localWindowStore.size <= MAX_LOCAL_BUCKETS) {
return;
}
const entriesByReset = [...localWindowStore.entries()].sort((a, b) => a[1].reset - b[1].reset);
const overflow = localWindowStore.size - MAX_LOCAL_BUCKETS;
for (const [key] of entriesByReset.slice(0, overflow)) {
localWindowStore.delete(key);
}
}
function getLocalRateLimit(identifier: string, policy: RateLimitPolicy): RateLimitResult {
const now = Date.now();
const windowMs = getWindowMs(policy.window);
pruneLocalWindowStore(now);
const current = localWindowStore.get(identifier);
if (!current || current.reset <= now) {
const reset = now + windowMs;
localWindowStore.set(identifier, { count: 1, reset });
pruneLocalWindowStore(now);
return {
success: true,
limit: policy.limit,
remaining: policy.limit - 1,
reset,
};
}
current.count += 1;
localWindowStore.set(identifier, current);
pruneLocalWindowStore(now);
return {
success: current.count <= policy.limit,
limit: policy.limit,
remaining: Math.max(policy.limit - current.count, 0),
reset: current.reset,
};
}
async function getUpstashRateLimit(
identifier: string,
bucket: string,
policy: RateLimitPolicy
): Promise<RateLimitResult | null> {
const cacheKey = `${bucket}:${policy.limit}:${policy.window}`;
let limiter = ratelimiters.get(cacheKey);
if (!limiter && redis) {
limiter = new Ratelimit({
redis,
limiter: Ratelimit.fixedWindow(policy.limit, policy.window),
prefix: `trustlend:ratelimit:${bucket}`,
analytics: false,
});
ratelimiters.set(cacheKey, limiter);
}
if (!limiter) {
return null;
}
try {
const result = await limiter.limit(identifier);
await result.pending;
return {
success: result.success,
limit: result.limit,
remaining: result.remaining,
reset: result.reset,
};
} catch (error) {
console.error("Upstash rate limit check failed:", error);
return null;
}
}
// ─── Admin / whitelist bypass ────────────────────────────────────────────────
/**
* Check if the request carries a valid admin bearer token.
* Requests with `Authorization: Bearer <ADMIN_SECRET_KEY>` bypass rate limits.
*/
function isAdminRequest(request: NextRequest): boolean {
const adminSecret = process.env.ADMIN_SECRET_KEY;
if (!adminSecret) return false;
const authHeader = request.headers.get("authorization") ?? "";
return authHeader === `Bearer ${adminSecret}`;
}
/**
* Check if the request IP is in the whitelist.
* Configure via `RATE_LIMIT_WHITELIST` env var (comma-separated IPs/CIDR).
*/
function isWhitelistedIp(request: NextRequest): boolean {
const whitelistCsv = process.env.RATE_LIMIT_WHITELIST;
if (!whitelistCsv) return false;
const ip = getRequestIdentifier(request);
const whitelisted = whitelistCsv.split(",").map((s) => s.trim()).filter(Boolean);
return whitelisted.includes(ip);
}
/**
* Headers that may carry the originating client IP, most trustworthy first.
*
* The platform-injected headers (Vercel, Cloudflare) are preferred because a
* client cannot forge them. `x-real-ip` / `x-forwarded-for` are the fallback
* for self-hosted deployments behind a reverse proxy — without them every
* caller collapses into a single `unknown` bucket and per-IP limiting stops
* working entirely.
*/
const CLIENT_IP_HEADERS = [
"x-vercel-ip-address",
"cf-connecting-ip",
"x-vercel-forwarded-for",
"x-real-ip",
"x-forwarded-for",
] as const;
export function getRequestIdentifier(request: NextRequest): string {
for (const header of CLIENT_IP_HEADERS) {
const value = request.headers.get(header);
if (!value) continue;
// Forwarded-for style headers carry a chain: "client, proxy1, proxy2".
const clientIp = value.split(",")[0].trim();
if (clientIp) return clientIp;
}
return "unknown";
}
/**
* Count a request against one bucket and build the 429 response if it exceeds
* the policy. Returns `null` when the request is allowed through.
*
* Fails open: if the shared (Upstash) store is unreachable the request is
* allowed rather than blocking legitimate traffic on infrastructure trouble.
*/
async function enforcePolicy(
request: NextRequest,
bucket: string,
policy: RateLimitPolicy,
message: string
): Promise<NextResponse | null> {
// ── Admin / whitelist bypass ───────────────────────────────────────────────
if (isAdminRequest(request) || isWhitelistedIp(request)) {
return null;
}
const ip = getRequestIdentifier(request);
const identifier = `${bucket}:${ip}`;
const result = redis
? await getUpstashRateLimit(identifier, bucket, policy)
: getLocalRateLimit(identifier, policy);
if (!result || result.success) {
return null;
}
const retryAfterSeconds = Math.max(1, Math.ceil((result.reset - Date.now()) / 1_000));
return NextResponse.json(
{ error: message },
{
status: 429,
headers: {
"Retry-After": String(retryAfterSeconds),
"X-RateLimit-Limit": String(result.limit),
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(result.reset),
},
}
);
}
/**
* Apply rate limiting to a Next.js API route.
*
* Returns a `NextResponse` with status 429 if the request is rate-limited,
* or `null` if the request should proceed.
*
* **Bypass:** Requests with `Authorization: Bearer <ADMIN_SECRET_KEY>` or
* from whitelisted IPs (configured via `RATE_LIMIT_WHITELIST` env var) skip
* rate limiting entirely.
*/
export async function enforceRouteRateLimit(request: NextRequest) {
const pathname = request.nextUrl.pathname;
return enforcePolicy(request, pathname, getPolicy(pathname), "Too many requests");
}
/**
* Apply the global per-IP ceiling to any `/api/*` request: at most
* `GLOBAL_API_POLICY.limit` requests per minute per client IP, across all
* endpoints combined.
*
* Enforced in `proxy.ts` so it runs before a request reaches a route handler.
* Counted in its own bucket, independent of the per-route policies applied by
* `enforceRouteRateLimit`.
*/
export async function enforceGlobalApiRateLimit(request: NextRequest) {
return enforcePolicy(
request,
"global:api",
GLOBAL_API_POLICY,
"Too many requests, please slow down."
);
}