-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathproxy.ts
More file actions
383 lines (335 loc) · 14 KB
/
Copy pathproxy.ts
File metadata and controls
383 lines (335 loc) · 14 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
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
/**
* Edge proxy (Next.js 16 successor to middleware) — access-code gate + CSRF + security headers.
*
* Responsibilities:
* 1. CSRF protection: for state-changing requests (POST/PUT/DELETE/PATCH),
* validates the Origin header against the expected host. Browser requests
* with a mismatched Origin are rejected with 403. Non-browser requests
* (no Origin header, e.g. curl, API key clients) are allowed through.
* 2. Access-code gate: when `ACCESS_CODE` env var is set, gates all non-public
* routes behind the `nova_access` cookie. The cookie's HMAC signature is
* verified using the Web Crypto API (Edge-compatible).
* 3. Security headers: sets standard security headers (CSP, X-Frame-Options,
* X-Content-Type-Options, Referrer-Policy, Permissions-Policy) on all
* responses.
* 4. Public routes (home, auth pages, health, access-code endpoints, public
* classroom playback, static assets) are always accessible.
*
* Fine-grained permission checks (RBAC) happen server-side via
* `requirePermission()` in the route handlers — the proxy is a first-pass
* gate only.
*/
import { NextRequest, NextResponse } from 'next/server';
import { buildCorsHeaders } from '@/lib/server/cors';
const ACCESS_TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
// ── Security headers ─────────────────────────────────────────────────────────
/**
* Standard security headers applied to every response.
*
* CSP is intentionally permissive for a Next.js app that uses inline styles,
* dynamic imports, and external CDN resources. tighten in production by
* setting CSP environment variables.
*/
const SECURITY_HEADERS: Record<string, string> = {
'x-frame-options': 'SAMEORIGIN',
'x-content-type-options': 'nosniff',
'referrer-policy': 'strict-origin-when-cross-origin',
// 与 next.config.ts 保持一致:课堂使用麦克风/摄像头
'permissions-policy': 'camera=(self), microphone=(self), geolocation=(), browsing-topics=()',
'x-dns-prefetch-control': 'off',
'strict-transport-security': 'max-age=63072000; includeSubDomains; preload',
};
/**
* Apply security headers to a NextResponse.
* Called for every response that passes through the proxy.
*/
function applySecurityHeaders(res: NextResponse): NextResponse {
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
// Don't override headers already set by the route handler
if (!res.headers.has(key)) {
res.headers.set(key, value);
}
}
return res;
}
/**
* Apply CORS headers to a NextResponse.
* Uses the centralized CORS policy from lib/server/cors.ts.
*/
function applyCorsHeaders(res: NextResponse, request: NextRequest): NextResponse {
const origin = request.headers.get('origin');
const host = request.headers.get('host') || request.nextUrl.host;
const corsHeaders = buildCorsHeaders(origin, host);
for (const [key, value] of Object.entries(corsHeaders)) {
if (!res.headers.has(key)) {
res.headers.set(key, value);
}
}
return res;
}
/**
* Apply all response headers (security + CORS) to a NextResponse.
*/
function applyAllHeaders(res: NextResponse, request: NextRequest): NextResponse {
return applyCorsHeaders(applySecurityHeaders(res), request);
}
/** Convert string to Uint8Array */
function encode(str: string): Uint8Array {
return new TextEncoder().encode(str);
}
/** Convert ArrayBuffer to hex string */
function bufToHex(buf: ArrayBuffer): string {
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Verify an HMAC-signed token using Web Crypto API (Edge-compatible) */
async function verifyToken(token: string, accessCode: string): Promise<boolean> {
const dotIndex = token.indexOf('.');
if (dotIndex === -1) return false;
const timestamp = token.substring(0, dotIndex);
const signature = token.substring(dotIndex + 1);
if (!/^\d+$/.test(timestamp)) return false;
const issuedAt = Number(timestamp);
const now = Date.now();
if (
!Number.isSafeInteger(issuedAt) ||
issuedAt > now ||
now - issuedAt > ACCESS_TOKEN_MAX_AGE_MS
) {
return false;
}
const keyData = encode(accessCode);
const key = await crypto.subtle.importKey(
'raw',
keyData.buffer as ArrayBuffer,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const data = encode(timestamp);
const expected = bufToHex(await crypto.subtle.sign('HMAC', key, data.buffer as ArrayBuffer));
// Constant-length comparison (not truly constant-time in JS, but sufficient here)
if (signature.length !== expected.length) return false;
let mismatch = 0;
for (let i = 0; i < signature.length; i++) {
mismatch |= signature.charCodeAt(i) ^ expected.charCodeAt(i);
}
return mismatch === 0;
}
// ── Public route patterns (no access code required) ─────────────────────────
const PUBLIC_PATTERNS = [
/^\/$/, // home
/^\/auth\//, // sign-in, sign-up pages
/^\/api\/auth\//, // NextAuth endpoints
/^\/api\/health/, // liveness/readiness probes
/^\/api\/access-code\//, // access code verify/status
/^\/api\/usage/, // public usage info
/^\/_next\//, // static assets
/^\/favicon/, // favicon
/^\/icons\//, // icon files
/^\/classroom\//, // public classroom playback (URL: /classroom/[id])
/^\/fonts\//, // font files
/^\/manifest/, // PWA manifest
];
function isPublicRoute(pathname: string): boolean {
return PUBLIC_PATTERNS.some((p) => p.test(pathname));
}
// ── Edge-compatible global rate limiter ─────────────────────────────────────
/**
* Lightweight in-memory rate limiter for the Edge proxy.
*
* Acts as a global DDoS protection layer — per-route limiters in the
* route handlers enforce more specific limits (e.g. 'generation' preset).
*
* Limits:
* - API routes: 120 req/min per IP
* - Page routes: 60 req/min per IP
* - Health/static: unlimited
*
* Uses a sliding window with periodic cleanup. Not shared across instances
* (Edge functions are stateless per cold start), but provides a baseline
* protection against abuse from a single client.
*/
const GLOBAL_API_LIMIT = 120; // requests per window
const GLOBAL_PAGE_LIMIT = 60;
const GLOBAL_WINDOW_MS = 60_000; // 1 minute
const globalBuckets = new Map<string, { count: number; resetAt: number }>();
let lastGlobalSweep = Date.now();
function checkGlobalRateLimit(ip: string, isApi: boolean): { allowed: boolean; retryAfter: number } {
const now = Date.now();
// Sweep stale entries every 60s
if (now - lastGlobalSweep > GLOBAL_WINDOW_MS) {
for (const [key, bucket] of globalBuckets) {
if (bucket.resetAt <= now) globalBuckets.delete(key);
}
lastGlobalSweep = now;
}
const key = `${ip}:${isApi ? 'api' : 'page'}`;
const limit = isApi ? GLOBAL_API_LIMIT : GLOBAL_PAGE_LIMIT;
const bucket = globalBuckets.get(key);
if (!bucket || bucket.resetAt <= now) {
globalBuckets.set(key, { count: 1, resetAt: now + GLOBAL_WINDOW_MS });
return { allowed: true, retryAfter: 0 };
}
bucket.count++;
if (bucket.count > limit) {
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
}
return { allowed: true, retryAfter: 0 };
}
/** Extract client IP from request, accounting for common proxy headers. */
function getClientIp(request: NextRequest): string {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
);
}
// ── CSRF protection ──────────────────────────────────────────────────────────
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/**
* Validate the Origin header for state-changing requests to prevent CSRF.
*
* Strategy:
* - Only applies to POST/PUT/PATCH/DELETE (GET/HEAD/OPTIONS are safe).
* - NextAuth endpoints (/api/auth/*) are skipped — NextAuth has its own CSRF
* token mechanism.
* - If no Origin header is present, the request is allowed (non-browser clients
* like curl, API SDKs, server-to-server calls don't send Origin).
* - If Origin is present, it must match the request's host. This prevents
* cross-site form submissions and fetch() calls from other origins.
*/
function checkCsrf(request: NextRequest): NextResponse | null {
if (!STATE_CHANGING_METHODS.has(request.method)) return null;
const { pathname } = request.nextUrl;
// NextAuth has built-in CSRF tokens
if (pathname.startsWith('/api/auth/')) return null;
const origin = request.headers.get('origin');
if (!origin) return null; // Non-browser client — allow
const expectedHost = request.headers.get('host') || request.nextUrl.host;
if (!expectedHost) return null; // Can't verify — allow (proxy/load balancer scenario)
// Extract the host portion of the Origin URL and compare
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
// Malformed Origin header — reject
return NextResponse.json(
{ success: false, errorCode: 'FORBIDDEN', error: 'Invalid Origin header' },
{ status: 403 },
);
}
if (originHost !== expectedHost) {
return NextResponse.json(
{ success: false, errorCode: 'FORBIDDEN', error: 'Cross-site requests are not allowed' },
{ status: 403 },
);
}
return null;
}
export async function proxy(request: NextRequest) {
// ── Handle CORS preflight (OPTIONS) ─────────────────────────────────────
if (request.method === 'OPTIONS') {
const res = new NextResponse(null, { status: 204 });
return applyAllHeaders(res, request);
}
// ── Request ID injection (for log correlation) ──────────────────────────
// If the client didn't send one, generate a short ID so all log lines for
// a single request can be correlated downstream.
const requestId =
request.headers.get('x-request-id') ??
crypto.randomUUID().slice(0, 8);
// Clone headers so we can add the request ID without mutating the original
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-request-id', requestId);
const { pathname: rawPathname } = request.nextUrl;
// ── API version routing (/api/v1/* → /api/*) ─────────────────────────────
// Supports versioned API calls by stripping the /v1 prefix and rewriting
// to the actual route. Backward compatible: /api/* still works as-is.
let pathname = rawPathname;
if (rawPathname.startsWith('/api/v1/')) {
pathname = '/api/' + rawPathname.slice('/api/v1/'.length);
const url = request.nextUrl.clone();
url.pathname = pathname;
const rewritten = NextResponse.rewrite(url, { request: { headers: requestHeaders } });
rewritten.headers.set('x-api-version', '1');
rewritten.headers.set('x-request-id', requestId);
return applyAllHeaders(rewritten, request);
}
const isApi = pathname.startsWith('/api/');
// ── Global rate limiting (DDoS protection) ──────────────────────────────
// Skip for health checks and static assets (they're served from CDN/cache).
if (!pathname.startsWith('/api/health') && !pathname.startsWith('/_next/')) {
const clientIp = getClientIp(request);
const rateLimitResult = checkGlobalRateLimit(clientIp, isApi);
if (!rateLimitResult.allowed) {
const res = NextResponse.json(
{
success: false,
errorCode: 'RATE_LIMITED',
error: 'Too many requests',
},
{
status: 429,
headers: {
'retry-after': String(rateLimitResult.retryAfter),
'x-request-id': requestId,
},
},
);
return applyAllHeaders(res, request);
}
}
// ── CSRF protection (always on, regardless of ACCESS_CODE) ──────────────
const csrfError = checkCsrf(request);
if (csrfError) {
csrfError.headers.set('x-request-id', requestId);
return applyAllHeaders(csrfError, request);
}
const accessCode = process.env.ACCESS_CODE;
if (!accessCode) {
const res = NextResponse.next({ request: { headers: requestHeaders } });
res.headers.set('x-request-id', requestId);
return applyAllHeaders(res, request);
}
// Always allow public routes
if (isPublicRoute(pathname)) {
const res = NextResponse.next({ request: { headers: requestHeaders } });
res.headers.set('x-request-id', requestId);
return applyAllHeaders(res, request);
}
// Check cookie — validate HMAC signature, not just existence
const cookie = request.cookies.get('nova_access');
if (cookie?.value && (await verifyToken(cookie.value, accessCode))) {
const res = NextResponse.next({ request: { headers: requestHeaders } });
res.headers.set('x-request-id', requestId);
return applyAllHeaders(res, request);
}
// API requests without valid cookie → 401
if (pathname.startsWith('/api/')) {
const res = NextResponse.json(
{ success: false, errorCode: 'UNAUTHORIZED', error: 'Access code required' },
{ status: 401 },
);
res.headers.set('x-request-id', requestId);
return applyAllHeaders(res, request);
}
// Page requests → let through, frontend shows modal
const res = NextResponse.next({ request: { headers: requestHeaders } });
res.headers.set('x-request-id', requestId);
return applyAllHeaders(res, request);
}
export const config = {
matcher: [
/*
* Match all paths EXCEPT:
* - Static assets (_next/static, _next/image, favicon, icons, logos, fonts)
* - NextAuth API endpoints (/api/auth/*)
* - Health/access-code/usage API endpoints (public)
*
* Everything else goes through the proxy for access-code gating.
*/
'/((?!api/access-code|api/health|api/usage|api/auth|_next/static|_next/image|favicon.ico|icons|logos|fonts|manifest).*)',
],
};