Harga sebenarnya dari CSP nonce di Next.js App Router
Nonce per-request adalah cara paling jujur menegakkan CSP, tapi ia menukar static rendering dengan keamanan itu. Berikut perhitungan yang saya pakai sebelum memilih — termasuk dua jebakan di matcher yang baru terlihat setelah situsnya bertambah halaman.
- CSP
- Next.js
- Middleware
- Rendering
Content-Security-Policy yang ditulis di next.config.mjs terlihat paling sederhana: satu array header, selesai. Masalahnya, CSP statis untuk aplikasi React praktis selalu berakhir memuat 'unsafe-inline' pada script-src, karena framework menyuntikkan script inline untuk hydration. Dan script-src dengan unsafe-inline adalah CSP yang tidak menahan apa pun — tepat kelas serangan yang seharusnya ia hentikan.
Alternatifnya nonce: satu token acak dibuat per request, dipasang di header CSP sekaligus di setiap tag script yang sah. Script yang disuntikkan lewat HTML tidak tahu token hari ini, jadi browser menolak mengeksekusinya. Next 14 meneruskan nonce dari header request ke script-nya sendiri secara otomatis, jadi tidak ada satu pun komponen yang perlu diubah.
Yang hilang saat nonce dipasang
Nonce harus berbeda tiap request. Halaman yang responsnya berbeda tiap request tidak bisa disajikan sebagai HTML statis hasil build. Artinya setiap route yang dilewati middleware berhenti menjadi static dan berpindah ke dynamic rendering. Untuk situs portofolio yang isinya tidak pernah berubah, itu biaya nyata — bukan fatal, tapi tidak boleh ditanggung tanpa disadari.
Mitigasinya adalah membatasi matcher middleware. Aset yang tidak pernah memuat script sama sekali — OG image, icon, sitemap, robots, feed — tidak butuh CSP ber-nonce dan sebaiknya tetap statis. Ini matcher yang berjalan di situs ini saat tulisan ini terbit:
export const config = {
matcher: [
"/((?!api/|_next/|\\.well-known/|(?:.*/)?opengraph-image(?:-[^/]+)?$|(?:.*/)?icon(?:-[^/]+)?$|favicon\\.ico$|sitemap\\.xml$|robots\\.txt$|(?:.*/)?rss\\.xml$).*)",
],
};Bentuknya jelek, dan setiap bagian jeleknya dibayar oleh satu bug. Semua grup non-capturing karena path-to-regexp memperlakukan grup penangkap di matcher sebagai parameter route dan menolak dikompilasi. Suffix (?:-[^/]+)? ada karena Next menyajikan gambar generatif dengan nama ber-hash.
Jebakan pertama: rewrite berjalan setelah middleware
Situs ini menyajikan /.well-known/security.txt lewat rewrite ke sebuah route handler di /api/security-txt, karena folder berawalan titik tidak dapat diandalkan sebagai segmen route. Karena matcher sudah mengecualikan api/, saya menganggap dokumen itu ikut aman. Salah: urutan pipeline Next adalah headers, redirects, middleware, baru rewrites. Middleware melihat path asli, dan /.well-known/security.txt sama sekali tidak berawalan api/.
Hasilnya sebuah dokumen text/plain yang tidak pernah mengeksekusi satu baris script pun mendapat CSP ber-nonce, lalu berhenti bisa disajikan sebagai respons statis. Tidak ada yang rusak, tidak ada yang error — hanya biaya rendering yang dibayar tanpa imbalan apa pun. Pengecualian harus ditulis untuk path yang dilihat middleware, bukan path tujuan rewrite.
Jebakan kedua: pola substring mematikan CSP di halaman nyata
Versi awal matcher memakai .*icon dan .*opengraph-image. Keduanya pencocokan substring tanpa anchor, jadi keduanya juga mencocokkan halaman HTML biasa yang slug-nya kebetulan memuat kata itu — sebuah catatan berjudul /notes/icon-fonts-dan-cls akan dilewati middleware dan terbit tanpa CSP sama sekali.
Yang membuatnya berbahaya: pemicunya adalah menulis artikel baru, bukan menyentuh kode keamanan. Tidak ada typecheck yang gagal, tidak ada test yang merah, dan halamannya terlihat sempurna. Satu-satunya gejalanya adalah header yang tidak ada, di satu URL, dan hanya kalau seseorang memeriksanya. Karena itu setiap pola sekarang di-anchor dengan $ dan dibatasi batas segmen.
Jebakan ketiga, yang paling mahal
Jangan pernah memasang CSP di dua tempat sekaligus. Kalau satu header datang dari next.config.mjs dan satu lagi dari middleware, browser tidak memilih yang lebih longgar — ia menegakkan irisan keduanya. Kebijakan A mengizinkan nonce, kebijakan B mengizinkan unsafe-inline, dan hasil akhirnya adalah tidak ada script yang boleh jalan. Gejalanya berupa halaman kosong tanpa error jaringan, dan sumbernya baru terlihat setelah membaca dua file konfigurasi yang berbeda.
Cara memverifikasinya satu perintah, dan nilainya harus berubah setiap kali dijalankan. Kalau nonce-nya sama dua kali, berarti responsnya ter-cache di suatu tempat dan seluruh mekanismenya sudah tidak berarti:
curl -sI https://ruchel.dev | grep -i content-security-policy
curl -sI https://ruchel.dev | grep -i content-security-policy # nonce harus beda
# dan yang ini justru TIDAK boleh punya CSP sama sekali
curl -sI https://ruchel.dev/.well-known/security.txt | grep -ci content-security-policy