Pārlūkot izejas kodu

feat: hardening CSP

- implementing nonce
- added csp report endpoint
yusuf 2 nedēļas atpakaļ
vecāks
revīzija
328696c1cc
2 mainītis faili ar 64 papildinājumiem un 8 dzēšanām
  1. 12 0
      src/app/csp-report-endpoint/route.ts
  2. 52 8
      src/middleware.ts

+ 12 - 0
src/app/csp-report-endpoint/route.ts

@@ -0,0 +1,12 @@
+import type { NextRequest } from 'next/server'
+
+export async function POST(request: NextRequest) {
+  try {
+    // Keep this lightweight: reports are best-effort telemetry.
+    await request.json()
+  } catch {
+    // Ignore malformed reports; we still return no-content.
+  }
+
+  return new Response(null, { status: 204 })
+}

+ 52 - 8
src/middleware.ts

@@ -38,10 +38,44 @@ function getClientIP(request: NextRequest): string {
   return request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown'
 }
 
+function buildCspHeader(nonce: string): string {
+  const apiOrigin = process.env.PAYLOAD_SERVER_URL?.trim() || 'https://api.hanoman.co.id'
+
+  return [
+    "default-src 'self'",
+    "base-uri 'self'",
+    "object-src 'none'",
+    "frame-ancestors 'none'",
+    `script-src 'self' 'nonce-${nonce}'`,
+    "style-src 'self' 'unsafe-inline'",
+    `img-src 'self' data: blob: ${apiOrigin} https:`,
+    "font-src 'self' data:",
+    `connect-src 'self' ${apiOrigin}`,
+    "frame-src 'self' https://maps.google.com https://www.google.com",
+    'report-uri /csp-report-endpoint',
+  ].join('; ')
+}
+
+function withSecurityHeaders(response: NextResponse, nonce: string): NextResponse {
+  response.headers.set('x-nonce', nonce)
+  response.headers.set('Content-Security-Policy', buildCspHeader(nonce))
+  return response
+}
+
 export function middleware(request: NextRequest) {
-  // Only apply rate limiting to API routes
-  if (!request.nextUrl.pathname.startsWith('/api')) {
-    return NextResponse.next()
+  const nonce = crypto.randomUUID().replace(/-/g, '')
+  const requestHeaders = new Headers(request.headers)
+  requestHeaders.set('x-nonce', nonce)
+
+  const isApiRoute = request.nextUrl.pathname.startsWith('/api')
+
+  if (!isApiRoute) {
+    const response = NextResponse.next({
+      request: {
+        headers: requestHeaders,
+      },
+    })
+    return withSecurityHeaders(response, nonce)
   }
 
   const clientIP = getClientIP(request)
@@ -57,7 +91,12 @@ export function middleware(request: NextRequest) {
       resetTime: now + RATE_LIMIT_WINDOW,
     }
     rateLimitStore.set(clientIP, rateLimit)
-    return NextResponse.next()
+    const response = NextResponse.next({
+      request: {
+        headers: requestHeaders,
+      },
+    })
+    return withSecurityHeaders(response, nonce)
   }
 
   // Increment request count
@@ -67,7 +106,7 @@ export function middleware(request: NextRequest) {
   if (rateLimit.count > RATE_LIMIT_MAX) {
     const retryAfter = Math.ceil((rateLimit.resetTime - now) / 1000)
 
-    return NextResponse.json(
+    const response = NextResponse.json(
       {
         error: 'Too Many Requests',
         message: `Rate limit exceeded. Maximum ${RATE_LIMIT_MAX} requests per ${RATE_LIMIT_WINDOW / 1000 / 60} minutes.`,
@@ -83,20 +122,25 @@ export function middleware(request: NextRequest) {
         },
       }
     )
+    return withSecurityHeaders(response, nonce)
   }
 
   // Update rate limit entry
   rateLimitStore.set(clientIP, rateLimit)
 
   // Add rate limit headers to response
-  const response = NextResponse.next()
+  const response = NextResponse.next({
+    request: {
+      headers: requestHeaders,
+    },
+  })
   response.headers.set('X-RateLimit-Limit', RATE_LIMIT_MAX.toString())
   response.headers.set('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - rateLimit.count).toString())
   response.headers.set('X-RateLimit-Reset', new Date(rateLimit.resetTime).toISOString())
 
-  return response
+  return withSecurityHeaders(response, nonce)
 }
 
 export const config = {
-  matcher: '/api/:path*',
+  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
 }