Content Security Policy di Next.js: Nonce & strict-dynamic

Foto oleh Severus Jones on Pexels
Nonce adalah string acak unik dan tak terduga yang dibuat server untuk satu respons HTTP saja. Nilai itu ditaruh di direktif script-src sebagai nonce-VALUE dan ditempelkan ke setiap tag skrip tepercaya. Browser hanya menjalankan skrip dengan nonce yang cocok, sehingga skrip suntikan diblokir karena penyerang tidak bisa menebak nilainya.
Buat di middleware Next.js (berkas proxy) sebelum render, memakai crypto.randomUUID yang di-encode ke base64. Susun string Content-Security-Policy dengan nonce-VALUE plus strict-dynamic, tetapkan sebagai header respons, lalu teruskan nonce mentah ke renderer melalui header request khusus x-nonce. Next.js kemudian menerapkannya otomatis saat server-side rendering.
Nonce harus unik per request, tetapi halaman statis dibangun sekali saat build time ketika belum ada request. Jadi halaman apa pun yang membawa nonce harus dirender dinamis. Ini menonaktifkan optimasi statis dan Incremental Static Regeneration, dan Partial Prerendering tidak kompatibel karena kerangka statisnya tidak punya nonce.
Ya, secara default. Karena halaman nonce dirender dinamis pada setiap request, halaman tidak bisa di-cache di edge CDN, sehingga muat awal lebih lambat dan biaya server lebih tinggi. Jika caching lebih penting, Next.js menyediakan alternatif eksperimental berbasis hash memakai Subresource Integrity yang mempertahankan keluaran statis.
Mulai dengan header Content-Security-Policy-Report-Only, yang menjalankan kebijakan sama tetapi tidak memblokir apa pun dan hanya melaporkan pelanggaran. Pasangkan dengan header Reporting-Endpoints dan direktif report-to untuk mengumpulkan laporan. Perbaiki tiap pelanggaran, dan hanya ketika laporan sunyi barulah ganti nama header menjadi Content-Security-Policy untuk menegakkannya.

Foto oleh Severus Jones on Pexels
Ringkasan Utama
Content Security Policy dengan nonce per-request adalah cara terkuat menahan cross-site scripting di Next.js: middleware membuat nonce acak baru, menyisipkannya ke direktif script-src bersama strict-dynamic, dan Next.js menerapkannya otomatis. Konsekuensinya, nonce memaksa dynamic rendering, sehingga halaman tidak lagi bisa di-cache di CDN secara default.
Cross-site scripting masih menjadi salah satu cara paling umum penyerang menjalankan JavaScript di dalam browser pengguna Anda. Content Security Policy, atau CSP, adalah daftar izin yang ditegakkan browser dan menentukan skrip, gaya, gambar, serta font mana yang benar-benar boleh dimuat. Jika benar, skrip yang disuntikkan penyerang sekadar menolak untuk berjalan.
Di tulisan ini saya membahas pendekatan yang direkomendasikan tim Next.js: CSP ketat berbasis nonce dengan strict-dynamic, dibuat per-request di middleware. Saya juga membahas bagian yang sering dilewatkan tutorial lain, yaitu berapa biayanya terhadap caching, dan cara meluncurkannya tanpa merusak produksi di hari pertama.
CSP dikirim sebagai header respons HTTP Content-Security-Policy. Isinya daftar direktif yang dipisahkan titik koma, di mana setiap direktif menamai satu jenis sumber daya dan asal yang diizinkan untuknya. Contohnya, default-src 'self' berarti memuat semuanya hanya dari origin Anda sendiri, dan object-src 'none' langsung menonaktifkan plugin lama seperti Flash.
Direktif yang paling penting untuk XSS adalah script-src. Jika script-src tidak mengizinkan kode inline, maka penyerang yang berhasil menyuntikkan tag skrip ke HTML Anda tidak memperoleh apa pun, karena browser menolak menjalankannya. Jaminan tunggal itulah inti dari CSP yang ketat.
CSP lama mengandalkan allowlist host seperti script-src www.example.com. Peneliti keamanan menunjukkan pendekatan ini mudah ditembus, karena domain mana pun di allowlist yang memuat pustaka rentan atau open redirect menjadi celah kabur. Kata kunci 'unsafe-inline' bahkan lebih buruk: ia mengaktifkan kembali setiap skrip inline dan meniadakan sebagian besar perlindungan yang seharusnya diberikan CSP.
Nonce memperbaiki ini. Nonce adalah string acak unik dan tak terduga yang dibuat untuk satu respons saja. Server menaruh nonce-VALUE ke dalam direktif script-src dan menempelkan nilai yang sama ke setiap skrip yang dipercayanya. Browser hanya menjalankan skrip yang membawa nonce yang cocok. Penyerang tidak bisa menebak nilai acak baru, sehingga skrip suntikan diblokir. Menambahkan strict-dynamic lalu membuat skrip tepercaya bernonce dapat memuat dependensinya sendiri tanpa Anda harus mendaftarkan tiap satunya.
Karena nonce harus berubah pada setiap request, Anda membuatnya di edge, di middleware Next.js (berkas proxy), sebelum halaman dirender. Middleware menyusun string CSP, menetapkannya sebagai header respons, sekaligus meneruskan nonce mentah ke renderer melalui header request khusus x-nonce.
// proxy.ts (Next.js middleware/proxy) — a fresh nonce per request
import { NextRequest, NextResponse } from 'next/server'
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
// Collapse whitespace into a single-line header value
const csp = cspHeader.replace(/\s{2,}/g, ' ').trim()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('Content-Security-Policy', csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('Content-Security-Policy', csp)
return response
}Next.js mengurus sisanya secara otomatis. Saat server-side rendering, ia mengurai header Content-Security-Policy yang masuk, mengekstrak nilai dari pola nonce-VALUE, dan menempelkan nonce itu ke skrip framework, bundel halaman, serta komponen Script apa pun yang Anda render. Anda tidak menstempel tiap tag secara manual. Jika butuh nonce sendiri, misalnya untuk tag analitik pihak ketiga, baca kembali dari header x-nonce lewat fungsi headers di sebuah Server Component.
Tips: di development Anda juga butuh 'unsafe-eval' pada script-src, karena React memakai eval untuk merekonstruksi jejak error sisi server demi debugging yang lebih baik. Ini tidak dibutuhkan di produksi, jadi kunci di balik pemeriksaan NODE_ENV dan jangan pernah mengirimkannya ke produksi.
Inilah jebakan yang jarang disebut sampai benar-benar menggigit: nonce harus unik per request, sedangkan halaman yang dibuat statis dibangun sekali saat build time ketika belum ada request. Jadi begitu Anda memakai CSP berbasis nonce, setiap halaman yang membawa nonce harus dirender secara dinamis. Optimasi statis dan Incremental Static Regeneration dinonaktifkan untuk rute tersebut, dan Partial Prerendering menjadi tidak kompatibel karena kerangka statisnya tidak punya nonce.
Biaya praktisnya adalah caching. Halaman dinamis dibuat ulang pada setiap request dan tidak bisa di-cache di edge CDN secara default, yang berarti muat awal lebih lambat, kerja server lebih banyak, dan tagihan hosting lebih tinggi. Jika mempertahankan keluaran statis dan caching CDN lebih penting, Next.js menyediakan alternatif eksperimental berbasis hash memakai Subresource Integrity, yang menghitung hash skrip saat build time alih-alih nonce saat request.
Peringatan: jika sebuah rute yang seharusnya dinamis masih dioptimasi secara statis, nonce akan hilang dan skrip Anda sendiri akan diblokir di produksi. Paksa dynamic rendering pada halaman tersebut, misalnya dengan menunggu fungsi connection dari next/server, agar render selalu menunggu request nyata.
Jangan pernah langsung menegakkan CSP ketat di situs live. Mulailah dengan header Content-Security-Policy-Report-Only. Ia menjalankan kebijakan yang persis sama tetapi tidak memblokir apa pun; browser hanya melaporkan apa yang akan diblokirnya. Pasangkan dengan header Reporting-Endpoints dan direktif report-to agar pelanggaran dikirim via POST ke pengumpul Anda dalam bentuk JSON.
// Roll out safely: report violations first, block nothing yet
const reportOnlyCsp =
"default-src 'self'; " +
"script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; " +
"object-src 'none'; base-uri 'none'; " +
"report-to csp-endpoint"
response.headers.set(
'Reporting-Endpoints',
'csp-endpoint="https://example.com/csp-reports"'
)
// Note: Report-Only observes, it does NOT enforce
response.headers.set('Content-Security-Policy-Report-Only', reportOnlyCsp)Biarkan Report-Only berjalan di produksi cukup lama untuk memunculkan setiap skrip inline sah, widget pihak ketiga, dan gaya nyasar yang Anda lupakan. Perbaiki tiap pelanggaran yang dilaporkan, dan hanya ketika laporan sudah sunyi barulah Anda mengganti nama header menjadi Content-Security-Policy untuk menegakkannya. Direktif report-to adalah mekanisme modern; report-uri yang lebih lama sudah deprecated tetapi tetap layak dideklarasikan bersamanya untuk browser yang belum mendukung report-to.
| Pendekatan | Rendering | Caching CDN | Perlindungan XSS |
|---|---|---|---|
| Nonce + strict-dynamic | Dinamis, per request | Tidak bisa di-cache secara default | Kuat |
| Hash / SRI | Statis, saat build | Ramah CDN | Kuat untuk skrip saat build |
| unsafe-inline | Statis | Ramah CDN | Lemah, meniadakan CSP |
Pedoman saya: pakai nonce ketika Anda benar-benar punya kebutuhan ketat atau menangani data sensitif, dan terima biaya dynamic rendering sebagai harga dari perlindungan XSS yang nyata. Untuk situs pemasaran yang sebagian besar statis di mana caching adalah raja, jalur eksperimental berbasis hash menjaga situs tetap cepat sekaligus tetap ketat. Apa pun pilihan Anda, luncurkan dulu dengan Report-Only.