From f199b7a7ede5f0975d65f28e16dade163a830e92 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 20 Apr 2026 19:24:41 +0300 Subject: [PATCH] security: baseline response headers (CSP report-only + OWASP set) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a catch-all rule in next.config.ts that merges a set of baseline security headers into every response — served alongside the existing Cache-Control rules, not instead of them. Enforced today: - Strict-Transport-Security: 2yr, includeSubDomains, preload-eligible. No-op on http://localhost. - X-Content-Type-Options: nosniff - X-Frame-Options: DENY (same defense as frame-ancestors below, but older browsers only read this one) - Referrer-Policy: strict-origin-when-cross-origin - Permissions-Policy: deny camera/mic/geolocation/payment/usb. We don't use any of those APIs; closing them off prevents a supply- chain compromise or a forgotten plugin from silently enabling them. Report-only: - Content-Security-Policy-Report-Only with a pragmatic allowlist: default-src 'self' script-src 'self' 'unsafe-inline' 'unsafe-eval' style-src 'self' 'unsafe-inline' img-src 'self' data: blob: https: font-src 'self' data: connect-src 'self' https://*.supabase.co wss://*.supabase.co frame-ancestors 'none' base-uri 'self' form-action 'self' 'unsafe-inline' / 'unsafe-eval' are there because Next's inline hydration script + dev-mode require them; nonce-based CSP is a later project. Violations appear in the browser console but nothing is blocked until we flip the header name to `Content-Security-Policy`. Verified against the running dev server: - /, /docs, /login, /api/whatsapp/webhook, /_next/static/* all return the six headers. - No CSP violations on / or /docs in the browser console. Co-Authored-By: Claude Opus 4.7 (1M context) --- next.config.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/next.config.ts b/next.config.ts index 72f70b9..02bd16f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,58 @@ import type { NextConfig } from "next"; +/** + * Baseline security headers applied to every response. + * + * CSP ships as `Content-Security-Policy-Report-Only` so the browser + * surfaces violations in the console without blocking anything — once + * we have confidence nothing legit trips it (two deploys, a pass on + * every route), flip the key to `Content-Security-Policy` to enforce. + * + * The rest of the headers are straight blocks, safe to enforce today: + * - HSTS: only meaningful on HTTPS (no-op on http://localhost). + * - X-Content-Type-Options / X-Frame-Options / Referrer-Policy: + * baseline OWASP hardening, no behavioural cost. + * - Permissions-Policy: we don't use camera / microphone / etc, so + * deny them. A supply-chain compromise or a forgotten plugin + * can't silently opt back in. + */ +const SECURITY_HEADERS = [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + }, + { + key: "Content-Security-Policy-Report-Only", + value: [ + "default-src 'self'", + // Next.js needs 'unsafe-inline' for its inline hydration script + // and 'unsafe-eval' in dev + some production optimisations. + // Nonce-based CSP is a later project. + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + // Tailwind + inline style attributes on lots of components. + "style-src 'self' 'unsafe-inline'", + // Supabase public-bucket avatars, contact avatars (arbitrary + // https URLs paste-able from the UI), OG images, data URLs for + // tiny inline assets. + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + // Supabase REST + realtime (WSS). All Meta API calls happen + // server-side, so graph.facebook.com does not belong here. + "connect-src 'self' https://*.supabase.co wss://*.supabase.co", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + }, +] as const; + const nextConfig: NextConfig = { /** * Cache-Control policy. @@ -32,6 +85,11 @@ const nextConfig: NextConfig = { * from a shared cache. The s-maxage here is a ceiling; Next.js * and auth middleware still set `private` / `no-store` for * per-user responses. + * + * Security headers are appended via a separate catch-all rule + * below — Next.js merges headers from every matching rule, so + * they apply to every response regardless of which cache rule + * matched. */ async headers() { return [ @@ -58,6 +116,13 @@ const nextConfig: NextConfig = { }, ], }, + { + // Security headers on every response, including /_next/static + // assets (nosniff matters there) and /api/* (HSTS + referrer- + // policy don't hurt). + source: "/:path*", + headers: [...SECURITY_HEADERS], + }, ]; }, };