Sepanjang section ini kita menangkal XSS dengan satu senjata utama: escaping output saat menulis chapter tentang injeksi skrip. Content Security Policy (CSP) menambahkan lapisan kedua — sebuah daftar izin yang dipatuhi browser, membatasi dari mana skrip boleh dimuat, gaya boleh berasal, dan frame boleh menampilkan halaman kita. Bila suatu saat ada satu titik output yang lupa di-escape, CSP yang ketat membuat skrip suntikan tersebut tetap tidak dieksekusi. Pada chapter ini kita bangun kebijakan minimal yang realistis, atasi masalah skrip inline dengan nonce dan hash, lalu pelajari cara meluncurkannya secara bertahap tanpa merusak halaman.

CSP sebagai Lapisan Kedua Pertahanan XSS #

Pertahanan berlapis (defense in depth) menerima kenyataan bahwa kode sebesar apa pun bisa punya celah yang terlewat. Escaping tetap menjadi lapisan pertama dan wajib; CSP berdiri di belakangnya sebagai jaring pengaman yang bekerja dengan cara sama sekali berbeda. Alih-alih membersihkan input atau output, CSP menyatakan allowlist sumber daya: browser yang menghormati kebijakan akan menolak mengeksekusi skrip dari sumber yang tidak tercantum, termasuk skrip inline hasil injeksi pada celah XSS .

Syarat keberhasilannya satu: kebijakan harus benar-benar ketat. Kebijakan yang menambahkan 'unsafe-inline' hanya demi membuat error hilang sama saja dengan mematikan proteksi untuk seluruh direktif tersebut. Karena itu pendekatan yang kita pakai di sini adalah memulai dari kebijakan kecil yang aman, lalu menambahkan sumber satu per satu hanya ketika benar-benar dibutuhkan.

Direktif Utama Kebijakan #

Sebuah kebijakan CSP adalah rangkaian directive yang masing-masing mengatur satu jenis sumber daya. Tabel berikut merangkum directive yang paling sering dipakai:

DirectiveYang diaturContoh nilai
default-srcFallback untuk semua directive yang tidak ditulis'self'
script-srcSumber JavaScript'self' https://cdn.example.com
style-srcSumber CSS'self'
img-srcSumber gambar'self' data:
object-srcPlugin <object> / <embed>'none'
frame-ancestorsSiapa yang boleh me-frame halaman (anti-clickjacking)'self'

Dua direktif pada tabel tersebut layak mendapat perhatian khusus. default-src berlaku sebagai nilai jatuh — directive yang tidak disebut secara eksplisit mewarisi nilai dari default-src, sehingga kebijakan tetap rapat meski belum semua direktif kita tulis. frame-ancestors adalah direktif anti-clickjacking yang sudah kita pakai pada chapter Clickjacking ; di sini direktif tersebut menyatu sebagai bagian dari satu kebijakan utuh. Di luar tabel, connect-src (target fetch()/XHR) dan form-action (tujuan submit formulir) adalah dua direktif lanjutan yang layak ditambahkan seiring bertambahnya kebutuhan.

Membangun Kebijakan Minimal #

Menulis kebijakan sebagai satu string panjang rentan salah; jauh lebih terkelola bila kebijakan disusun sebagai array asosiatif yang kemudian dirangkai menjadi string. Fungsi berikut melakukan persis itu:

php
<?php
function buildCspPolicy(array $directives): string
{
    $parts = [];
    foreach ($directives as $directive => $sources) {
        $parts[] = $directive . ' ' . implode(' ', $sources);
    }

    return implode('; ', $parts);
}

$policy = buildCspPolicy([
    'default-src' => ["'self'"],
    'script-src' => ["'self'", 'https://cdn.example.com'],
    'style-src' => ["'self'"],
    'img-src' => ["'self'", 'data:'],
    'object-src' => ["'none'"],
    'frame-ancestors' => ["'self'"],
]);

echo $policy . "\n";

Output yang Dihasilkan:

text
default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'self'

Mari kita baca kebijakan yang dihasilkan baris demi baris:

  1. default-src 'self' — semua jenis sumber daya hanya boleh berasal dari origin sendiri; ini pundi fallback yang membuat kebijakan rapat sejak awal.
  2. script-src 'self' https://cdn.example.com — JavaScript tambahan hanya dikenal dari satu CDN tepercaya; skrip dari domain lain, termasuk hasil injeksi, ditolak.
  3. img-src 'self' data: — gambar lokal ditambah gambar inline data: untuk ikon kecil.
  4. object-src 'none' — plugin <object> dan <embed> dimatikan total, menutup vektor serangan lama.
  5. frame-ancestors 'self' — hanya halaman sendiri yang boleh menampilkan halaman ini di frame.

Perhatikan bahwa kata kunci 'self', 'none', dan 'unsafe-inline' selalu ditulis dengan tanda kutip — self tanpa kutip akan dibaca sebagai nama host, bukan kata kunci.

Memancarkan Header dari PHP #

Kebijakan dikirim melalui header Content-Security-Policy pada setiap respons. Fungsi pembangkit header berikut sekaligus menyiapkan mode report-only yang akan kita pakai nanti:

php
<?php
function cspHeader(string $policy, bool $reportOnly = false): array
{
    $name = $reportOnly
        ? 'Content-Security-Policy-Report-Only'
        : 'Content-Security-Policy';

    return [$name => $policy];
}

foreach (cspHeader("default-src 'self'") as $name => $value) {
    echo "$name: $value\n";
}

foreach (cspHeader("default-src 'self'; script-src 'self' 'nonce-CONTOH123'", true) as $name => $value) {
    echo "$name: $value\n";
}

Output yang Dihasilkan:

text
Content-Security-Policy: default-src 'self'
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-CONTOH123'

Pemancaran sebenarnya memanggil header() dari front controller — tempat yang sama dengan set security headers kita sebelumnya:

php
<?php
// Panggil di awal setiap request, sebelum output apa pun.
foreach (cspHeader($policy) as $name => $value) {
    header("$name: $value");
}

Masalah Skrip Inline #

Kejutan pertama pengguna CSP baru hampir selalu sama: seluruh <script> inline di halaman berhenti dieksekusi. Ini justru perilaku yang diinginkan — skrip suntikan XSS biasanya berupa inline — tetapi berarti kode lama yang memang bergantung pada inline harus dimigrasi. Ada dua jalan resmi untuk mengecualikan inline tertentu tanpa membuka celah bagi semua inline: nonce dan hash.

Nonce adalah nilai acak sekali pakai yang disisipkan pada header dan tag <script> sekaligus — hanya skrip yang membawa nonce yang sama dengan header yang dieksekusi:

php
<?php
$nonce = base64_encode(random_bytes(16));

echo 'Panjang nonce: ' . strlen($nonce) . " karakter\n";
echo 'Format base64 valid: ' . (preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $nonce) === 1 ? 'ya' : 'tidak') . "\n";

Output yang Dihasilkan:

text
Panjang nonce: 24 karakter
Format base64 valid: ya

Output di atas sengaja menampilkan struktur, bukan nilainya — nonce memang harus berbeda pada setiap respons agar penyerang tidak bisa menebaknya untuk disuntikkan bersama skripnya. Pemakaiannya tampak seperti ini: header membawa script-src 'self' 'nonce-NILAI-ACAK', dan halaman memuat <script nonce="NILAI-ACAK">...</script> dengan nilai yang persis sama. Karena nonce baru dibuat setiap request di PHP, skrip suntikan yang tidak mengetahui nilai tersebut langsung diblokir.

Alternatif kedua adalah hash: alih-alih nilai acak, kita mencantumkan sidik jari isi skrip. Hash dihitung dari konten skrip yang tetap, sehingga nilainya deterministik dan bisa diverifikasi langsung:

php
<?php
$inlineScript = "document.getElementById('sapaan').textContent = 'Halo, Budi!';";
$hash = 'sha256-' . base64_encode(hash('sha256', $inlineScript, true));

echo "script-src 'self' '$hash'\n";
echo 'Panjang nilai hash: ' . strlen($hash) . " karakter\n";

Output yang Dihasilkan:

text
script-src 'self' 'sha256-dcfEOJCfWZGH3xyNomIF0rMC9pvP2EK9IrhxYrMGcek='
Panjang nilai hash: 51 karakter

Mari kita bedah kedua pendekatan tersebut:

  1. hash('sha256', $inlineScript, true) menghasilkan 32 byte mentah; base64_encode() mengubahnya menjadi 44 karakter, dan prefiks sha256- membentuk nilai total 51 karakter seperti pada output.
  2. Nonce cocok untuk inline yang isinya berubah-ubah (misalnya skrip yang menyuntik data dari PHP) karena tidak bergantung pada konten.
  3. Hash cocok untuk inline statis — konten berubah satu karakter pun, hash tidak lagi cocok dan browser memblokirnya; ini sekaligus memaksa kita sadar setiap perubahan.

Mode Report-Only dan Peluncuran Bertahap #

Menerapkan CSP ketat secara langsung ke produksi berisiko merusak halaman yang tidak kita sadari memuat sumber tak terdaftar. Untuk itu ada header Content-Security-Policy-Report-Only — browser tidak memblokir apa pun, hanya melaporkan pelanggaran ke endpoint yang kita tentukan lewat directive report-uri atau report-to. Mode ini sudah disiapkan oleh fungsi cspHeader() di atas melalui parameter $reportOnly.

Alur peluncuran yang aman berlangsung bertahap:

  1. Terapkan kebijakan dalam mode Report-Only beserta directive report-uri /pelaporan-csp.
  2. Kumpulkan laporan selama beberapa hari dari kunjungan nyata.
  3. Perbaiki pelanggaran yang sah — pindahkan inline ke berkas, daftarkan CDN yang memang dipakai.
  4. Alihkan header ke mode penegakan (Content-Security-Policy).
  5. Pertajam kebijakan secara berkala; setiap sumber yang dihapus memperkecil permukaan serangan.

Kegagalan Umum dan Solusinya #

Beberapa pola kerusakan muncul berulang kali saat migrasi ke CSP. Gaya inline (style="..." atau blok <style>) ditolak oleh style-src 'self' — solusinya pindahkan ke berkas CSS, atau gunakan nonce/hash dengan cara yang sama seperti skrip. Skrip CDN yang gagal dimuat biasanya berarti host CDN belum terdaftar di script-src; tambahkan host persisnya, bukan membuka https:. Gambar avatar dari domain pihak ketiga memerlukan host tersebut di img-src, dan gambar inline data: perlu didaftarkan eksplisit seperti contoh kita. Terakhir, pustaka lawas yang memanggil eval() atau new Function() akan rusak total di bawah CSP ketat — inilah biaya nyata menutup celah yang selama ini dimanfaatkan loader skrip dinamis.

Ringkasan #

CSP melengkapi escaping sebagai lapisan kedua anti-XSS: kebijakan allowlist di header memberi tahu browser sumber daya mana saja yang sah, sehingga skrip suntikan tidak dieksekusi meski celah output lolos. Kita membangun kebijakan dari array direktif, memancarkannya lewat front controller, menangani inline dengan nonce dan hash, dan meluncurkannya melalui mode Report-Only agar tidak ada halaman yang rusak mendadak. Dengan XSS dan CSRF tertutup berlapis, perhatian kita bergeser ke satu kelas aset yang paling sering bocor dari repositori kode: secret dan kredensial, topik chapter Secrets Management berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

contoh.php

Unduh berkas
php
<?php
/* contoh.php - pembangun kebijakan CSP dan header terkaitnya.
 * Jalankan dengan: php contoh.php
 */

function buildCspPolicy(array $directives): string
{
    $parts = [];
    foreach ($directives as $directive => $sources) {
        $parts[] = $directive . ' ' . implode(' ', $sources);
    }

    return implode('; ', $parts);
}

function cspHeader(string $policy, bool $reportOnly = false): array
{
    $name = $reportOnly
        ? 'Content-Security-Policy-Report-Only'
        : 'Content-Security-Policy';

    return [$name => $policy];
}

$policy = buildCspPolicy([
    'default-src' => ["'self'"],
    'script-src' => ["'self'", 'https://cdn.example.com'],
    'style-src' => ["'self'"],
    'img-src' => ["'self'", 'data:'],
    'object-src' => ["'none'"],
    'frame-ancestors' => ["'self'"],
]);

foreach (cspHeader($policy) as $name => $value) {
    echo "$name: $value\n";
}

foreach (cspHeader($policy, true) as $name => $value) {
    echo "$name: $value\n";
}