-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathproxy.ts
More file actions
176 lines (153 loc) · 5.42 KB
/
proxy.ts
File metadata and controls
176 lines (153 loc) · 5.42 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
import { type NextRequest, NextResponse } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
const REDIRECTS: Record<string, string> = {
// Pages now exist at /api-docs, /cli-docs, /openapi, /employers
};
// ── Polling throttle ─────────────────────────────────────────────
// Endpoints that get polled heavily by clients with the page open.
// Throttle: only let one request through per IP+path every 30s.
// Others get a lightweight cached response (no DB hit).
const THROTTLED_PATHS = [
"/api/wallet/balance",
"/api/wallet/transactions",
"/api/notifications",
"/api/funding/total",
];
const THROTTLE_WINDOW_MS = 30_000;
const throttleMap = new Map<string, { ts: number; body: string }>();
// ── Polling abuse detection ─────────────────────────────────────
// If an IP hits throttled endpoints for >8 hours continuously,
// block them from those endpoints entirely until they stop for 30 min.
const ABUSE_WINDOW_MS = 8 * 60 * 60_000; // 8 hours
const ABUSE_COOLDOWN_MS = 30 * 60_000; // 30 min cooldown
const abuseTracker = new Map<string, { firstSeen: number; lastSeen: number; blocked: boolean }>();
function checkPollingAbuse(ip: string): boolean {
const now = Date.now();
const entry = abuseTracker.get(ip);
if (!entry) {
abuseTracker.set(ip, { firstSeen: now, lastSeen: now, blocked: false });
return false;
}
// If blocked, check if cooldown passed
if (entry.blocked) {
if (now - entry.lastSeen > ABUSE_COOLDOWN_MS) {
// Cooldown passed, reset
abuseTracker.delete(ip);
return false;
}
entry.lastSeen = now;
return true; // still blocked
}
// If gap > 30 min since last request, reset tracking
if (now - entry.lastSeen > ABUSE_COOLDOWN_MS) {
abuseTracker.set(ip, { firstSeen: now, lastSeen: now, blocked: false });
return false;
}
entry.lastSeen = now;
// If polling for >8 hours continuously, block
if (now - entry.firstSeen > ABUSE_WINDOW_MS) {
entry.blocked = true;
console.log(`[abuse] Blocked polling from ${ip} after 8h continuous`);
return true;
}
return false;
}
// Cleanup stale entries every 60s
let lastThrottleCleanup = Date.now();
function cleanupThrottle() {
const now = Date.now();
if (now - lastThrottleCleanup < 60_000) return;
lastThrottleCleanup = now;
for (const [key, entry] of throttleMap) {
if (now - entry.ts > THROTTLE_WINDOW_MS * 2) throttleMap.delete(key);
}
// Also clean abuse tracker
for (const [ip, entry] of abuseTracker) {
if (now - entry.lastSeen > ABUSE_COOLDOWN_MS * 2) abuseTracker.delete(ip);
}
}
function getClientIp(request: NextRequest): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
(request as unknown as { ip?: string }).ip ||
"unknown"
);
}
export async function proxy(request: NextRequest) {
const ip = getClientIp(request);
const method = request.method;
const path = request.nextUrl.pathname;
// Block TRACE method — return 405 Method Not Allowed (#66)
if (method === "TRACE") {
return new NextResponse(null, {
status: 405,
headers: { Allow: "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS" },
});
}
// Redirect legacy/broken paths
const redirect = REDIRECTS[path];
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url), 301);
}
// Throttle heavy polling endpoints — 1 request per IP+path per 30s
if (method === "GET" && THROTTLED_PATHS.includes(path)) {
cleanupThrottle();
// Block IPs that poll continuously for >8 hours
if (checkPollingAbuse(ip)) {
return new NextResponse(
JSON.stringify({ error: "Too many requests. Please refresh the page." }),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": "1800",
"X-Blocked": "polling-abuse",
},
},
);
}
const key = `${ip}:${path}`;
const cached = throttleMap.get(key);
const now = Date.now();
if (cached && now - cached.ts < THROTTLE_WINDOW_MS) {
// Return cached response without hitting the app
return new NextResponse(cached.body, {
status: 200,
headers: {
"Content-Type": "application/json",
"X-Throttled": "true",
"Cache-Control": "private, max-age=30",
},
});
}
}
// Log with real client IP (not proxy IP)
if (path.startsWith("/api/")) {
console.log(`[${method}] ${path} — ${ip}`);
}
// After the response, cache it for throttled endpoints
const response = await updateSession(request);
if (method === "GET" && THROTTLED_PATHS.includes(path) && response.status === 200) {
try {
const cloned = response.clone();
const body = await cloned.text();
throttleMap.set(`${ip}:${path}`, { ts: Date.now(), body });
} catch {
// Don't break if we can't cache
}
}
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};