-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.ts
More file actions
312 lines (255 loc) · 13.1 KB
/
Copy pathproxy.ts
File metadata and controls
312 lines (255 loc) · 13.1 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
/**
* Proxy (formerly middleware) — must stay under 1 MB.
*
* We use the lightweight edge-compatible NextAuth initialisation that only
* imports `authConfig` (no Prisma, no bcrypt, no heavy Node.js modules).
* The full auth config (with adapter, providers, etc.) lives in lib/auth.ts
* and is only used in the Node.js runtime (server actions, API routes).
*
* Also generates a per-request CSP nonce and sets a strict Content-Security-Policy
* header on every HTML response to defend against XSS attacks.
*/
import NextAuth from "next-auth"
import { authConfig } from "@/auth.config"
import { NextResponse } from "next/server"
const { auth } = NextAuth(authConfig)
const isDev = process.env.NODE_ENV === "development"
// ---------------------------------------------------------------------------
// In-edge rate limiter (per IP, per route prefix)
// ---------------------------------------------------------------------------
interface RateLimitEntry {
count: number
resetAt: number
}
const _rateLimitStore = new Map<string, RateLimitEntry>()
const RATE_LIMIT_RULES: Array<{ prefix: string; limit: number; windowMs: number }> = [
{ prefix: "/api/payment/initialize", limit: 10, windowMs: 60_000 },
{ prefix: "/api/payment/initialize-pay-request", limit: 10, windowMs: 60_000 },
{ prefix: "/api/payment/initialize-request", limit: 10, windowMs: 60_000 },
{ prefix: "/api/auth", limit: 20, windowMs: 60_000 },
{ prefix: "/api/upload", limit: 30, windowMs: 60_000 },
]
function checkRateLimit(ip: string, pathname: string): boolean {
const rule = RATE_LIMIT_RULES.find((r) => pathname.startsWith(r.prefix))
if (!rule) return true
const key = `${ip}:${rule.prefix}`
const now = Date.now()
const entry = _rateLimitStore.get(key)
if (!entry || now > entry.resetAt) {
_rateLimitStore.set(key, { count: 1, resetAt: now + rule.windowMs })
return true
}
if (entry.count >= rule.limit) return false
entry.count++
return true
}
function getClientIp(req: { headers: { get: (key: string) => string | null } }): string {
return (
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
req.headers.get("x-real-ip") ??
"unknown"
)
}
// ---------------------------------------------------------------------------
// CSP helper
// ---------------------------------------------------------------------------
function buildCsp(nonce: string): string {
const policy = [
// Only allow resources from the same origin by default
"default-src 'self'",
// Scripts: nonce for Next.js inline runtime chunks.
// 'strict-dynamic' lets nonce-trusted scripts load further scripts
// (required for Next.js chunk loading). 'self' is redundant with
// strict-dynamic but kept for older browser fallback.
// 'unsafe-eval' is required by Framer Motion's animation engine in
// both dev and production — it uses eval() internally.
`script-src 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval'`,
// Styles — split into two granular directives (CSP Level 3):
//
// style-src-elem governs <style> tags and <link rel="stylesheet">.
// In production: nonce-only (Next.js attaches the nonce automatically).
// In dev: unsafe-inline only — when a nonce is present, browsers ignore
// unsafe-inline entirely (CSP Level 2 spec), so the two can't coexist.
// The Next.js/Turbopack dev overlay and Sentry devtools inject <style>
// tags without a nonce, so dev must use unsafe-inline without a nonce.
//
// style-src-attr governs style="…" attributes — nonces can't apply here,
// so unsafe-inline is required in all environments.
isDev
? "style-src-elem 'self' 'unsafe-inline'"
: `style-src-elem 'self' 'nonce-${nonce}'`,
`style-src-attr 'unsafe-inline'`,
// Images: same origin, data URIs, blob: (canvas exports),
// Cloudinary (product images), Google user profile pictures (OAuth),
// and the legacy papersmiths host.
"img-src 'self' data: blob: https://res.cloudinary.com https://*.papersmiths.co.uk https://lh3.googleusercontent.com https://*.googleusercontent.com",
// Fonts: self only — next/font/google self-hosts all fonts at build time.
"font-src 'self'",
// Fetch/XHR: same origin + Sentry ingest for error reporting.
// Paystack API calls happen server-side (Node.js), never from the browser.
"connect-src 'self' https://*.ingest.us.sentry.io https://*.ingest.sentry.io",
// No plugins (Flash, etc.)
"object-src 'none'",
// No iframes from other origins
"frame-src 'none'",
// Prevent this site being embedded in foreign frames (clickjacking)
"frame-ancestors 'none'",
// Forms can only submit to same origin, plus Paystack's hosted checkout
// (the browser redirects to checkout.paystack.com after server init)
"form-action 'self' https://checkout.paystack.com",
// Prevent <base> tag hijacking
"base-uri 'self'",
// Auto-upgrade any accidental http:// resource loads to https://
"upgrade-insecure-requests",
// NOTE: Trusted Types (require-trusted-types-for / trusted-types) is
// intentionally omitted. Turbopack's chunk loader assigns script.src as a
// plain string without going through any Trusted Types policy — enabling
// the directive crashes the app in both dev and prod.
// XSS protection is provided by the nonce-based script-src + strict-dynamic
// above, which already prevents any injected script from executing.
]
return policy.join("; ")
}
// ---------------------------------------------------------------------------
// Routes that require authentication (customer account area)
// ---------------------------------------------------------------------------
const protectedPrefixes = ["/account"]
// /checkout itself requires login, but /checkout/verify must stay PUBLIC —
// unauthenticated payers (Pay-For-Me) land here after Paystack redirects them back
const protectedCheckoutPrefixes = ["/checkout"]
// Checkout sub-paths that must remain public even though /checkout is protected
const publicCheckoutPaths = ["/checkout/verify", "/checkout/confirmation"]
// Routes that require admin role
const adminRoutes = ["/admin"]
// Routes only customers should access (not admins)
const customerOnlyRoutes = ["/shop", "/account", "/cart", "/collections"]
// Auth pages — redirect logged-in users away
const authRoutes = ["/auth/login", "/auth/register"]
// ---------------------------------------------------------------------------
// Proxy
// ---------------------------------------------------------------------------
export default auth((req: any) => {
const { nextUrl, auth: session } = req
const pathname = nextUrl.pathname
const isLoggedIn = !!session?.user
const isAdmin = session?.user?.role === "ADMIN"
const isStaff = session?.user?.role === "STAFF"
const isAdminOrStaff = isAdmin || isStaff
// Generate a fresh nonce for every request
const nonce = Buffer.from(crypto.randomUUID()).toString("base64")
const csp = buildCsp(nonce)
// Helper: build a response that always carries the CSP + nonce headers
function withCsp(response: NextResponse): NextResponse {
response.headers.set("Content-Security-Policy", csp)
// Pass nonce to Server Components via a readable request header
response.headers.set("x-nonce", nonce)
// Pass pathname to Server Components (used by ConditionalNavbar)
response.headers.set("x-pathname", pathname)
// ── Transport security ───────────────────────────────────────────────
// 2-year max-age, covers all subdomains, eligible for browser preload lists.
// Only sent in production — dev runs over http so HSTS would break it.
if (!isDev) {
response.headers.set(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload"
)
}
// ── Origin isolation (COOP + COEP + CORP) ───────────────────────────
// COOP: isolates this window from cross-origin windows opened via
// window.open() or target="_blank". Prevents cross-origin JS from
// accessing window references (Spectre, XS-Leaks).
response.headers.set("Cross-Origin-Opener-Policy", "same-origin")
// COEP: requires every sub-resource to explicitly opt in to being
// loaded cross-origin. Required to enable SharedArrayBuffer / high-res
// timers. "credentialless" is less strict than "require-corp" and works
// with third-party images (Cloudinary) that don't send CORP headers.
response.headers.set("Cross-Origin-Embedder-Policy", "credentialless")
// CORP: prevents other origins from reading this document's resources
// in no-cors requests (Spectre side-channel mitigation).
response.headers.set("Cross-Origin-Resource-Policy", "same-origin")
// ── Clickjacking (belt-and-suspenders with frame-ancestors in CSP) ───
// X-Frame-Options covers older browsers that don't support CSP.
response.headers.set("X-Frame-Options", "DENY")
// ── MIME sniffing ────────────────────────────────────────────────────
response.headers.set("X-Content-Type-Options", "nosniff")
// ── Referrer ─────────────────────────────────────────────────────────
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
// ── Feature/Permissions policy ───────────────────────────────────────
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=()"
)
return response
}
// ── Rate limiting on sensitive API routes ─────────────────────────────
if (pathname.startsWith("/api/")) {
const ip = getClientIp(req)
if (!checkRateLimit(ip, pathname)) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 }
)
}
}
// ── Always-public paths — never intercept ─────────────────────────────
if (
pathname.startsWith("/pay/") ||
publicCheckoutPaths.some((p) => pathname.startsWith(p))
) {
return withCsp(NextResponse.next())
}
// ── Admin routes ───────────────────────────────────────────────────────
if (adminRoutes.some((r) => pathname.startsWith(r))) {
if (!isLoggedIn) {
const loginUrl = new URL("/auth/login", nextUrl)
loginUrl.searchParams.set("callbackUrl", pathname)
return withCsp(NextResponse.redirect(loginUrl))
}
if (!isAdminOrStaff) {
return withCsp(NextResponse.redirect(new URL("/", nextUrl)))
}
return withCsp(NextResponse.next())
}
// ── Customer-only routes — redirect admins/staff to their dashboard ────
if (customerOnlyRoutes.some((r) => pathname.startsWith(r)) && isLoggedIn && isAdminOrStaff) {
return withCsp(NextResponse.redirect(new URL("/admin", nextUrl)))
}
// ── Protected checkout (requires login) ───────────────────────────────
if (protectedCheckoutPrefixes.some((r) => pathname.startsWith(r)) && !isLoggedIn) {
const loginUrl = new URL("/auth/login", nextUrl)
loginUrl.searchParams.set("callbackUrl", pathname)
return withCsp(NextResponse.redirect(loginUrl))
}
// ── Protected account area ─────────────────────────────────────────────
if (protectedPrefixes.some((r) => pathname.startsWith(r)) && !isLoggedIn) {
const loginUrl = new URL("/auth/login", nextUrl)
loginUrl.searchParams.set("callbackUrl", pathname)
return withCsp(NextResponse.redirect(loginUrl))
}
// ── Auth pages — redirect logged-in users to their destination ─────────
if (authRoutes.some((r) => pathname.startsWith(r)) && isLoggedIn) {
const callbackUrl = nextUrl.searchParams.get("callbackUrl")
const destination = isAdminOrStaff
? "/admin"
: callbackUrl && callbackUrl.startsWith("/")
? callbackUrl
: "/account"
return withCsp(NextResponse.redirect(new URL(destination, nextUrl)))
}
return withCsp(NextResponse.next())
})
export const config = {
matcher: [
{
// Match all page routes; skip static assets and image optimisation —
// they don't need a nonce and skipping them keeps the proxy lean.
source:
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
missing: [
// Don't re-run on RSC prefetch requests — they inherit the parent nonce
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
}