CSP sebagai Pertahanan

Content Security Policy sebagai lapisan pertahanan: header policy, nonce, dan mode report-only.

Daftar isi

Content Security Policy (CSP) adalah mekanisme browser yang membatasi sumber daya apa saja yang boleh dimuat dan dijalankan sebuah halaman. Kebijakan dikirim sebagai header HTTP, dievaluasi browser sebelum skrip apa pun berjalan, dan berlaku untuk seluruh halaman. Dalam pertahanan XSS, CSP berperan sebagai lapisan tambahan sesuai prinsip defense in depth, yaitu menumpuk beberapa lapisan pertahanan yang saling mengisi, sedangkan pengodean keluaran tetap menjadi pertahanan utama yang menutup celahnya. Topik ini membahas penyusunan directive, penggunaan nonce, serta peluncuran bertahap dengan mode report-only.

Directive dan sumber#

Sebuah policy terdiri atas serangkaian directive, yaitu aturan per jenis sumber daya yang boleh diambil halaman. Setiap directive menerima daftar sumber, misalnya 'self' untuk origin yang sama, 'none' untuk melarang semuanya, atau host yang ditulis apa adanya. Directive yang tidak ditulis umumnya mengikuti default-src, tetapi ada pengecualian penting: base-uri dan frame-ancestors tidak mewarisinya, sehingga keduanya dinyatakan eksplisit pada policy yang rapi. Tabel berikut merangkum directive yang paling sering digunakan.

DirectiveMengatur
default-srcNilai bawaan bagi directive pengambilan sumber daya lainnya
script-srcSumber skrip yang boleh dijalankan
style-srcSumber lembar gaya
img-srcSumber gambar
object-srcObjek tersemat seperti <object> dan <embed>
base-uriURL dasar yang boleh digunakan elemen <base>
frame-ancestorsHalaman lain yang boleh menyematkan halaman ini

Bentuk header yang dirujuk oleh seluruh bagian topik ini adalah berikut ini, dan contoh policy serupa digunakan pada skrip validasi di bagian akhir.

text Contoh: header Content-Security-Policy
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'

Policy yang salah eja tidak menghentikan server karena header sekadar dikirim apa adanya, sedangkan browser mengabaikan directive yang tidak dikenal dengan hanya menampilkan peringatan di konsol. Keheningan ini membuat policy yang rusak terasa aman padahal tidak bekerja, sehingga isi header perlu diperiksa secara terprogram sebagaimana ditunjukkan pada bagian akhir.

Demo: memblokir skrip inline#

Demo berikut memasang policy script-src 'self' pada halamannya sendiri, lalu memuat dua skrip, yaitu satu skrip inline dan satu berkas skrip same-origin. Jalankan demo, kemudian amati kedua statusnya. Skrip eksternal tetap berjalan karena berasal dari origin yang sama, sedangkan skrip inline diblokir karena policy tanpa 'unsafe-inline' menolak skrip yang tidak dapat membuktikan asalnya.

Kode sumber demo
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="script-src 'self'">
<title>Demo CSP: script-src 'self'</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f8fafc; color: #0f172a; }
  h1 { font-size: 18px; margin: 0 0 4px; }
  .ket { font-size: 12px; color: #475569; margin: 0 0 12px; }
  .ket code { font-family: ui-monospace, monospace; background: #e2e8f0; padding: 1px 5px; border-radius: 4px; }
  ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
  li { background: #fff; border: 1px solid #cbd5e1; border-radius: 8px; padding: 10px 12px; font-size: 13px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
  .badge { font-family: ui-monospace, monospace; font-size: 12px; padding: 3px 8px; border-radius: 999px; background: #e2e8f0; color: #475569; white-space: nowrap; }
  .lolos { background: #dcfce7; color: #166534; }
  .gagal { background: #fee2e2; color: #7f1d1d; }
</style>
</head>
<body>
  <h1>Uji policy di halaman ini</h1>
  <p class="ket">Policy yang aktif: <code>script-src 'self'</code> dikirim lewat elemen <code>&lt;meta&gt;</code>. Halaman memuat satu skrip inline dan satu skrip same-origin.</p>
  <ul>
    <li id="status-inline"><span>Skrip inline pada halaman ini</span><span class="badge" id="badge-inline">memeriksa...</span></li>
    <li id="status-eksternal"><span>Skrip eksternal same-origin (index.js)</span><span class="badge" id="badge-eksternal">memeriksa...</span></li>
  </ul>
  <script>
    // Skrip inline hanya berjalan bila policy tidak aktif; CSP memblokirnya.
    window.__skripInlineBerjalan = true;
  </script>
  <script src="index.js" defer></script>

<script>
(function () {
  if (window.__demoCspSelesai) return;
  window.__demoCspSelesai = true;

  function tandai(idBadge, teks, kelas) {
    var badge = document.getElementById(idBadge);
    if (badge === null) return;
    badge.textContent = teks;
    badge.className = 'badge ' + kelas;
  }

  // Skrip same-origin ini diizinkan oleh script-src 'self'.
  tandai('badge-eksternal', 'berjalan', 'lolos');

  // Skrip inline membaca penanda bila CSP tidak aktif; tanpa penanda berarti diblokir.
  if (window.__skripInlineBerjalan) {
    tandai('badge-inline', 'berjalan (policy tidak aktif)', 'gagal');
  } else {
    tandai('badge-inline', 'diblokir oleh CSP', 'lolos');
  }
})();

</script>
<script>
(function () {
  if (window.__demoCspSelesai) return;
  window.__demoCspSelesai = true;

  function tandai(idBadge, teks, kelas) {
    var badge = document.getElementById(idBadge);
    if (badge === null) return;
    badge.textContent = teks;
    badge.className = 'badge ' + kelas;
  }

  // Skrip same-origin ini diizinkan oleh script-src 'self'.
  tandai('badge-eksternal', 'berjalan', 'lolos');

  // Skrip inline membaca penanda bila CSP tidak aktif; tanpa penanda berarti diblokir.
  if (window.__skripInlineBerjalan) {
    tandai('badge-inline', 'berjalan (policy tidak aktif)', 'gagal');
  } else {
    tandai('badge-inline', 'diblokir oleh CSP', 'lolos');
  }
})();

</script></body>
</html>

Hasil demo memperlihatkan peran CSP dengan tepat. Penyerang yang berhasil menyisipkan <img onerror=...> maupun tag <script> inline tetap gagal mengeksekusinya karena seluruh skrip inline diblokir, sementara aplikasi yang rapi tidak terganggu karena semua skripnya dimuat dari berkas same-origin. Konsekuensinya, migrasi ke CSP biasanya berjalan bersamaan dengan pembersihan skrip inline dan penanganan atribut seperti onclick. Berkas demo sengaja memuat skrip inline agar kebijakannya terlihat bekerja, bukan karena pola itu dianjurkan.

Nonce dan hash#

Policy 'self' semata menolak seluruh skrip inline, padahal banyak aplikasi membutuhkan sedikit skrip inline yang sah, misalnya data awal aplikasi. CSP menyediakan dua mekanisme pemberian izin yang menggantikan 'unsafe-inline'. Yang pertama adalah nonce, yaitu nilai acak sekali pakai yang ditaruh pada header dan pada atribut nonce elemen skrip. Yang kedua adalah hash, yaitu sidik jari SHA-256 dari isi skrip yang dihitung lebih dulu, sehingga skrip hanya berjalan apabila isinya persis sama dengan yang diizinkan.

text Contoh: pasangan header dan tag skrip dengan nonce
Content-Security-Policy: script-src 'nonce-r4nd0mN1ce' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="r4nd0mN1ce">aplikasi.mulai();</script>

Nilai nonce wajib baru untuk setiap respons dan tidak boleh digunakan ulang, karena nonce yang statis pada semua respons hanya menjadi dekorasi. Sumber 'strict-dynamic' meneruskan kepercayaan dari skrip yang sudah diizinkan ke skrip yang dimuatnya secara dinamis, sehingga aplikasi modular tetap berjalan tanpa membuka pintu untuk skrip yang disisipkan penyerang. Kedua mekanisme ini memindahkan syarat eksekusi dari tempat asal skrip menjadi bukti identitasnya, sehingga skrip yang disisipkan penyerang tidak dapat menyamar sebagai skrip aplikasi.

Peluncuran bertahap dengan Report-Only#

Header Content-Security-Policy-Report-Only memerintahkan browser mengevaluasi policy tanpa memblokir apa pun, lalu mengirim laporan setiap pelanggaran ke endpoint yang ditunjuk. Mode inilah yang menjadikan CSP dapat diluncurkan tanpa risiko merusak aplikasi yang sedang berjalan. Proses peluncurannya berlangsung dalam tiga langkah.

  1. Pasang policy versi report-only beserta endpoint pelaporannya.
  2. Amati laporan pelanggaran selama beberapa pekan, lalu rapikan skrip inline lama hingga laporan mereda.
  3. Ubah nama header menjadi Content-Security-Policy biasa agar policy mulai memblokir.
text Contoh: peluncuran bertahap dengan mode report-only
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-lapor

Endpoint pelaporan menerima dokumen JSON yang memuat halaman pelanggar, directive yang dilanggar, serta potongan policy-nya, sehingga tim dapat menemukan bagian aplikasi yang belum patuh. Data yang sama juga berguna sebagai pemantauan setelah policy aktif, karena lonjakan laporan menandakan regresi baru. Peluncuran bertahap seperti ini memisahkan pembetulan aplikasi dari penegakan policy, sehingga keduanya dapat dikerjakan tanpa saling menunggu. Tahapan yang sama dapat diulang per directive, misalnya menegakkan script-src lebih dulu sementara style-src masih diamati, karena setiap directive berdiri sendiri.

Memvalidasi string policy#

String policy mudah salah ketik, dan kesalahannya tidak menghentikan server karena header dikirim apa adanya. Menguraikan string policy secara terprogram membantu memeriksa directive yang terpasang sebelum header sampai ke browser. Skrip berikut membedah contoh policy dari bagian directive menjadi pasangan directive dan sumbernya.

python urai-csp.py
# urai-csp.py — membedah string policy CSP menjadi daftar directive-nya.
policy = (
    "default-src 'self'; "
    "script-src 'self' 'nonce-R4nd0mN1ce' 'strict-dynamic'; "
    "style-src 'self'; "
    "img-src 'self' data:; "
    "object-src 'none'; "
    "base-uri 'none'; "
    "frame-ancestors 'none'"
)

for bagian in policy.split(";"):
    bagian = bagian.strip()
    if not bagian:
        continue
    kata = bagian.split()
    print(f"{kata[0]:<17} -> {' '.join(kata[1:])}")
text Keluaran
default-src       -> 'self'
script-src        -> 'self' 'nonce-R4nd0mN1ce' 'strict-dynamic'
style-src         -> 'self'
img-src           -> 'self' data:
object-src        -> 'none'
base-uri          -> 'none'
frame-ancestors   -> 'none'

Keluaran memperlihatkan bahwa setiap directive membawa daftar sumbernya sendiri. Pemeriksaan seperti ini dapat dijadikan langkah pipeline, misalnya memastikan object-src dan base-uri bernilai 'none' sebelum konfigurasi diterapkan ke server produksi.

Pemeriksaan lain yang berguna adalah membandingkan policy produksi dengan policy acuan tim. Skrip kecil dapat membaca header dari respons aplikasi, menguraikannya dengan cara yang sama, lalu melaporkan directive yang hilang atau nilai yang menyimpang. Pemeriksaan semacam ini menutup celah konfigurasi antar lingkungan, misalnya header yang lengkap di lingkungan staging tetapi tidak lengkap di produksi. Penguraian sederhana seperti di atas tidak menggantikan pengujian di browser, tetapi ia menangkap kesalahan paling umum sebelum halaman sempat dimuat, dan kombinasinya jauh lebih murah daripada policy yang diam-diam tidak bekerja.

Kesalahan umum#

Kesalahan pertama adalah membiarkan 'unsafe-inline' demi menghindari pembersihan skrip lama. Policy dengan sumber itu menyerahkan kembali seluruh skrip inline kepada penyerang, sehingga lapisan pertahanannya hanya berlaku untuk sumber daya lain. Kebiasaan ini biasanya lahir dari aplikasi lama yang penuh skrip inline, dan jalan keluarnya adalah migrasi bertahap dengan nonce daripada membuka pintu untuk semuanya. Perbaikannya tetap berupa penggunaan nonce atau hash sambil merapikan skrip inline yang sah.

Kesalahan kedua adalah mengandalkan default-src saja. Directive base-uri dan frame-ancestors tidak mewarisinya, sehingga policy tanpa keduanya tetap membuka penyisipan elemen <base> maupun penyematan halaman di situs lain. Pengecualian ini sering terlewat karena policy terlihat lengkap sekilas, sehingga pemeriksaan keduanya perlu masuk ke daftar periksa rilis. Kesalahan ketiga adalah membiarkan mode report-only berjalan tanpa batas waktu. Laporan yang tidak pernah ditindaklanjuti membuat policy tidak pernah menegakkan apa pun, sehingga jadwal peluncurannya ditetapkan sejak awal beserta pemilik tindak lanjutnya. Ketiganya bersumber dari keputusan yang sama, yaitu mempercepat rilis dengan melemahkan policy, sehingga gerbang rilis yang menampung pemeriksaan ini adalah tempat perbaikan yang paling murah.

Ringkasan#

  • CSP membatasi sumber daya yang boleh dimuat dan dijalankan halaman, dan berperan sebagai lapisan tambahan di belakang pengodean keluaran.
  • Directive ditulis per jenis sumber daya, dengan base-uri dan frame-ancestors yang harus dinyatakan eksplisit karena tidak mewarisi default-src.
  • Nonce dan hash menggantikan 'unsafe-inline' dengan memberi izin kepada skrip tertentu berdasarkan nilai acak atau sidik jari isinya.
  • Mode report-only mengevaluasi policy tanpa memblokir, sehingga peluncuran berlangsung bertahap dari pengamatan menuju penegakan.

Referensi#