fix(proxy): merge middleware.ts into proxy.ts for Next.js 16 compat
Next.js 16 staat alleen proxy.ts toe als de twee co-existeren; build faalt met "Both middleware file and proxy file are detected". De CSP- en CSRF-logica uit middleware.ts is samengevoegd in proxy.ts en de auth-redirect blijft. CSRF-validatie geldt nu alleen voor POST /api/*, auth-redirect alleen buiten /api — matcher uitgebreid om beide te dekken. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c147870456
commit
9a7191f4c1
2 changed files with 53 additions and 64 deletions
|
|
@ -1,55 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
const CSP = [
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self' 'unsafe-inline'",
|
|
||||||
"style-src 'self' 'unsafe-inline'",
|
|
||||||
"font-src 'self'",
|
|
||||||
"img-src 'self' data:",
|
|
||||||
"connect-src 'self'",
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"form-action 'self'",
|
|
||||||
].join('; ')
|
|
||||||
|
|
||||||
const CSRF_COOKIE = 'csrf_token'
|
|
||||||
const CSRF_HEADER = 'x-csrf-token'
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
const { method, nextUrl } = request
|
|
||||||
|
|
||||||
// Validate CSRF token on all POST requests to API routes
|
|
||||||
if (method === 'POST' && nextUrl.pathname.startsWith('/api/')) {
|
|
||||||
const cookieToken = request.cookies.get(CSRF_COOKIE)?.value
|
|
||||||
const headerToken = request.headers.get(CSRF_HEADER)
|
|
||||||
if (!cookieToken || cookieToken !== headerToken) {
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'CSRF validation failed' }),
|
|
||||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.next()
|
|
||||||
|
|
||||||
response.headers.set('Content-Security-Policy', CSP)
|
|
||||||
response.headers.set('X-Frame-Options', 'DENY')
|
|
||||||
response.headers.set('X-Content-Type-Options', 'nosniff')
|
|
||||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
||||||
|
|
||||||
// Issue a CSRF token cookie on GET requests when not yet present
|
|
||||||
if (method === 'GET' && !request.cookies.get(CSRF_COOKIE)) {
|
|
||||||
response.cookies.set(CSRF_COOKIE, crypto.randomUUID(), {
|
|
||||||
httpOnly: false, // must be readable by client JS for the double-submit pattern
|
|
||||||
sameSite: 'strict',
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
||||||
}
|
|
||||||
62
proxy.ts
62
proxy.ts
|
|
@ -2,22 +2,66 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const PUBLIC_PATHS = ['/login']
|
const PUBLIC_PATHS = ['/login']
|
||||||
|
|
||||||
|
const CSP = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"font-src 'self'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; ')
|
||||||
|
|
||||||
|
const CSRF_COOKIE = 'csrf_token'
|
||||||
|
const CSRF_HEADER = 'x-csrf-token'
|
||||||
|
|
||||||
export default function proxy(request: NextRequest) {
|
export default function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
const { method, nextUrl } = request
|
||||||
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
const { pathname } = nextUrl
|
||||||
const hasSession = request.cookies.has('ops_session')
|
|
||||||
|
|
||||||
if (!isPublic && !hasSession) {
|
if (method === 'POST' && pathname.startsWith('/api/')) {
|
||||||
return NextResponse.redirect(new URL('/login', request.url))
|
const cookieToken = request.cookies.get(CSRF_COOKIE)?.value
|
||||||
|
const headerToken = request.headers.get(CSRF_HEADER)
|
||||||
|
if (!cookieToken || cookieToken !== headerToken) {
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify({ error: 'CSRF validation failed' }),
|
||||||
|
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPublic && hasSession) {
|
if (!pathname.startsWith('/api/')) {
|
||||||
return NextResponse.redirect(new URL('/', request.url))
|
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
||||||
|
const hasSession = request.cookies.has('ops_session')
|
||||||
|
if (!isPublic && !hasSession) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url))
|
||||||
|
}
|
||||||
|
if (isPublic && hasSession) {
|
||||||
|
return NextResponse.redirect(new URL('/', request.url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next()
|
const response = NextResponse.next()
|
||||||
|
|
||||||
|
response.headers.set('Content-Security-Policy', CSP)
|
||||||
|
response.headers.set('X-Frame-Options', 'DENY')
|
||||||
|
response.headers.set('X-Content-Type-Options', 'nosniff')
|
||||||
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
|
||||||
|
if (method === 'GET' && !request.cookies.get(CSRF_COOKIE)) {
|
||||||
|
response.cookies.set(CSRF_COOKIE, crypto.randomUUID(), {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!api|_next/static|_next/image|.*\\.(?:png|ico|svg)$).*)'],
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|ico|svg)$).*)'],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue