Repository navigation
Expand file tree
/
Copy pathproxy.ts
More file actions
143 lines (124 loc) · 5.85 KB
/
Copy pathproxy.ts
File metadata and controls
143 lines (124 loc) · 5.85 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
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { clientIp, createRateLimiter, isCrossSiteMutation } from '@/lib/security';
import { REGISTRATIONS_OPEN } from '@/config/launch';
// Largest legitimate body: a 2MB upload plus multipart overhead. Bigger requests are refused before buffering.
const MAX_API_BODY_BYTES = 3 * 1024 * 1024;
// Per-IP flood limits. In memory, so they count PER SERVER INSTANCE: Vercel runs several, and a client spread across
// them gets this allowance on each. Cross-instance limits need the Vercel WAF (counted per region) or a shared store.
// Deliberately generous: many students share one campus NAT address. Per-user limits live in the routes.
const mutationLimiter = createRateLimiter({ limit: 300, windowMs: 60 * 1000 });
const authLimiter = createRateLimiter({ limit: 300, windowMs: 60 * 1000 });
/**
* Venue routes left out of the per-IP write limit: the check-in scanners (/api/admin/entry/*) and participants opening
* their pass (/api/pass). At the gate they all share one campus Wi-Fi or carrier-NAT address, and a busy minute of
* participants could otherwise exhaust that address's allowance and lock the scanners out. Both require a signed-in
* user and are rate limited per user in the route instead. CSRF and body-size checks still apply.
*/
function isPerUserLimitedVenueRoute(pathname: string): boolean {
return pathname === '/api/pass' || pathname.startsWith('/api/pass/') || pathname.startsWith('/api/admin/entry/');
}
const tooMany = () =>
NextResponse.json(
{ error: 'Too Many Requests', message: 'Too many requests. Please slow down and try again shortly.' },
{ status: 429, headers: { 'Retry-After': '60' } }
);
/**
* Request proxy (formerly Middleware; renamed in Next.js 16).
*
* 1. CSRF: rejects cross-site POST/PATCH/PUT/DELETE requests to any /api route. Auth cookies are SameSite=Lax,
* and this same-origin check closes the remaining gaps (sibling subdomains, `text/plain` JSON bodies).
* 2. Abuse limits on /api: request body size cap and per-IP rate limits for writes and auth endpoints.
* 3. Optimistic auth gate for /register, /registration, /admin, /api/register and /api/admin: requests that carry
* no session at all are bounced early. This is NOT the authorization boundary; every route handler validates
* the token with Supabase and checks roles itself.
*/
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/api/')) {
if (isCrossSiteMutation(request)) {
return NextResponse.json(
{ error: 'Forbidden', message: 'Cross-site requests are not allowed.' },
{ status: 403 }
);
}
if (Number(request.headers.get('content-length') || 0) > MAX_API_BODY_BYTES) {
return NextResponse.json({ error: 'Payload Too Large' }, { status: 413 });
}
// Only Vercel's own (overwritten) IP headers are trusted; elsewhere there is no trustworthy IP and these limits are
// skipped rather than keyed on a header the client picks (see clientIp).
const ip = clientIp(request.headers, { trustPlatformHeaders: process.env.VERCEL === '1' });
if (ip) {
if (pathname.startsWith('/api/auth/') && !authLimiter.hit(ip)) return tooMany();
if (
!['GET', 'HEAD', 'OPTIONS'].includes(request.method) &&
!isPerUserLimitedVenueRoute(pathname) &&
!mutationLimiter.hit(ip)
) {
return tooMany();
}
}
}
// While registrations are closed, /register is a public "opening soon" page (src/config/launch.ts).
const isRegisterPage =
REGISTRATIONS_OPEN &&
(pathname === '/register' ||
pathname.startsWith('/register/') ||
pathname === '/registration' ||
pathname.startsWith('/registration/'));
const isRegisterApi =
pathname === '/api/register' || pathname.startsWith('/api/register/');
const isAdminPage = pathname === '/admin' || pathname.startsWith('/admin/');
const isAdminApi = pathname === '/api/admin' || pathname.startsWith('/api/admin/');
if (isRegisterPage || isRegisterApi || isAdminPage || isAdminApi) {
// 1. Check cookies for access_token or refresh_token
const hasInnAccessToken = !!request.cookies.get('inn_access_token')?.value;
const hasInnRefreshToken = !!request.cookies.get('inn_refresh_token')?.value;
const allCookies = request.cookies.getAll();
const hasSupabaseCookie = allCookies.some(
(c) =>
c.name.includes('-auth-token') ||
c.name.includes('supabase-auth') ||
c.name === 'sb-access-token' ||
c.name === 'inv_session'
);
// 2. Check Authorization header
const authHeader = request.headers.get('authorization');
const hasAuthHeader = !!authHeader && authHeader.startsWith('Bearer ');
const isAuthenticated =
hasInnAccessToken || hasInnRefreshToken || hasSupabaseCookie || hasAuthHeader;
if (!isAuthenticated) {
if (isRegisterApi || isAdminApi) {
return NextResponse.json(
{
error: 'Authentication Required',
message: 'You must log in or sign up before accessing this endpoint.',
},
{ status: 401 }
);
}
// For page routes, redirect to home with auth prompt query params
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = '/';
redirectUrl.searchParams.set('auth', 'login');
// 'scan': the home page sends the volunteer back to the check-in scanner once signed in.
redirectUrl.searchParams.set(
'required',
pathname === '/admin/scan' ? 'scan' : isAdminPage ? 'admin' : 'register'
);
return NextResponse.redirect(redirectUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: [
'/register',
'/register/:path*',
'/registration',
'/registration/:path*',
'/admin',
'/admin/:path*',
'/api/:path*',
],
};