Petunjuk XSS pada DOM

Bahaya innerHTML dan dasar Content Security Policy.

Daftar isi

Topik penutup modul ini kembali ke innerHTML, tetapi kali ini dari sisi bahayanya. Celah yang dibahas bernama XSS, singkatan dari cross-site scripting. Serangan ini memasukkan script asing ke halaman lewat data yang tampak biasa. Bagian berikut menelusuri satu skenario nyata, perbaikannya dengan textContent dan escaping, lalu Content Security Policy sebagai lapisan pengaman kedua.

Skenario injeksi pada buku tamu#

Bayangkan satu halaman buku tamu yang menampilkan entri pengunjung. Kodenya merangkai teks entri langsung ke dalam markup, lalu memasangnya dengan innerHTML. Cara ini terasa sederhana dan hasilnya benar untuk entri biasa, tetapi teks entri sepenuhnya milik pengunjung, dan markup apa pun di dalamnya ikut dijalankan browser.

js Contoh: baris yang menjadi pintu injeksi
// tidak-dijalankan
const entri = entriTerbaruDariServer; // teks bebas dari pengguna

daftar.innerHTML += "<li>" + entri + "</li>";

Seorang pengunjung yang berniat jahat menulis entri berisi tag seperti <img src=x onerror="...">, dan browser menjalankan isi onerror tersebut di halaman setiap pengunjung lain yang membacanya. Script asing itu bisa membaca document.cookie, menyamarkan isi halaman, atau mengirim data pengunjung ke situs lain tanpa diketahui. Celah semacam ini terbagi dua bentuk. Bentuk pertama tersimpan pada data seperti buku tamu, sedangkan bentuk kedua terpantul dari masukan seperti parameter URL, dan keduanya bertemu pada pintu keluaran yang sama. Serangan sebesar ini lahir dari satu baris penggabungan string, tanpa error yang memperingatkan penulisnya.

Perbaikan: textContent dan escaping#

Perbaikan pertama sudah dikenal sejak topik manipulasi konten, yaitu memasang teks pengguna dengan textContent. Properti ini memperlakukan seluruh string sebagai teks murni, sehingga tag apa pun yang diketik pengunjung hanya tampil sebagai tulisan dan tidak pernah dijalankan. Dengan satu pergantian properti, jalur injeksi pada skenario buku tamu tertutup sepenuhnya.

js Contoh: teks pengguna dipasang sebagai teks murni
// tidak-dijalankan
const item = document.createElement("li");
item.textContent = entriTerbaruDariServer;
daftar.append(item);

Kasus tersisa adalah konten yang memang harus menjadi markup, misalnya pesan sistem yang sengaja memuat teks tebal. Untuk keadaan itu dipakai escaping, yakni penggantian karakter khusus HTML menjadi bentuk amannya, & menjadi &amp;, < menjadi &lt;, dan > menjadi &gt;. String yang sudah melalui escaping boleh dipasang dengan innerHTML karena tag yang menyusup kehilangan maknanya dan hanya tampil sebagai teks.

js Contoh: menyaring karakter khusus sebelum masuk markup
// tidak-dijalankan
function escapeHtml(teks) {
  return teks
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;");
}

kotak.innerHTML = "<li>" + escapeHtml(teksTamu) + "</li>";

Content Security Policy sebagai lapisan kedua#

Content Security Policy (CSP) adalah aturan yang memberi tahu browser sumber mana saja yang boleh menyuplai skrip dan gaya sebuah halaman. Aturannya dipasang lewat header HTTP atau meta tag, dan browser menolak mengeksekusi skrip dari sumber lain, termasuk skrip inline yang menyusup lewat injeksi. CSP tidak memperbaiki baris innerHTML yang keliru, tetapi ia menaikkan pagar tambahan ketika kekeliruan itu lolos.

html Contoh: meta tag Content Security Policy
<meta http-equiv="Content-Security-Policy" content="script-src 'self'">

Dengan aturan script-src 'self' di atas, halaman hanya menerima skrip dari origin yang sama, sedangkan handler inline seperti onerror="..." ikut diblokir pada kebijakan yang ketat. Arahan lain melengkapi pagar yang sama, misalnya default-src yang menetapkan batas untuk semua jenis sumber sekaligus, dan pelanggaran kebijakan dapat dilaporkan ke alamat khusus untuk dipantau. Urutannya menjadi jelas, yaitu textContent menutup pintu injeksi, dan CSP berjaga di belakangnya bila pintu itu masih terbuka di tempat lain.

Demo berikut memperagakan kedua arah pada satu teks buku tamu yang sama, lengkap dengan tag gambar yang memicu script. Tekan Render aman dan teksnya tampil apa adanya tanpa efek, lalu tekan Render rentan dan perhatikan spanduk yang muncul, bukti bahwa script tersembunyi berhasil dijalankan browser pada panel tersebut. Tombol Bersihkan mengembalikan kedua panel ke keadaan awalnya agar percobaan bisa diulang dengan teks lain.

Kode sumber demo
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <title>Demo render aman versus render rentan</title>
  <style>
    body { font-family: "Segoe UI", Arial, sans-serif; margin: 14px; font-size: 14px; color: #1f2937; }
    textarea { width: 380px; max-width: 100%; height: 56px; padding: 8px; font: 13px Consolas, monospace; }
    button { padding: 6px 12px; margin: 10px 8px 0 0; cursor: pointer; }
    .panel { border: 1px solid #94a3b8; border-radius: 8px; padding: 8px 12px; max-width: 400px; min-height: 34px; margin-top: 8px; background: #ffffff; }
    .panel h3 { margin: 0 0 6px; font-size: 13px; color: #64748b; }
    .spanduk { margin-top: 12px; padding: 10px; border-radius: 8px; max-width: 400px; min-height: 18px; }
    .spanduk.aktif { background: #fee2e2; border: 1px solid #b91c1c; color: #b91c1c; font-weight: 700; }
  </style>
</head>
<body>
  <label for="masukan"><strong>Entri buku tamu</strong> (teks bebas dari pengunjung):</label>
  <textarea id="masukan" autocomplete="off"></textarea>
  <br>
  <button id="tombol-aman" type="button">Render aman</button>
  <button id="tombol-rentan" type="button">Render rentan</button>
  <button id="reset" type="button">Bersihkan</button>
  <div id="panel-aman" class="panel"><h3>Panel aman: textContent</h3></div>
  <div id="panel-rentan" class="panel"><h3>Panel rentan: innerHTML</h3></div>
  <div id="spanduk" class="spanduk"></div>
  <script src="index.js"></script>

<script>
// Demo XSS: render textContent dan innerHTML pada teks tamu yang sama.

const area = document.querySelector("#masukan");
const panelAman = document.querySelector("#panel-aman");
const panelRentan = document.querySelector("#panel-rentan");
const spanduk = document.querySelector("#spanduk");

const teksTamu = 'Halo, buku tamu! <img src=x onerror="tandaiSerangan()"> <b>Agenda kelas</b>';

area.value = teksTamu;

function tandaiSerangan() {
  spanduk.textContent = "Script asing berhasil dijalankan lewat panel rentan. Inilah XSS.";
  spanduk.className = "spanduk aktif";
}

document.querySelector("#tombol-aman").addEventListener("click", () => {
  const isian = document.createElement("p");
  isian.textContent = area.value;
  panelAman.append(isian);
});

document.querySelector("#tombol-rentan").addEventListener("click", () => {
  const isian = document.createElement("p");
  isian.innerHTML = area.value;
  panelRentan.append(isian);
});

document.querySelector("#reset").addEventListener("click", () => {
  panelAman.innerHTML = "<h3>Panel aman: textContent</h3>";
  panelRentan.innerHTML = "<h3>Panel rentan: innerHTML</h3>";
  spanduk.textContent = "";
  spanduk.className = "spanduk";
});

</script>
<script>
// Demo XSS: render textContent dan innerHTML pada teks tamu yang sama.

const area = document.querySelector("#masukan");
const panelAman = document.querySelector("#panel-aman");
const panelRentan = document.querySelector("#panel-rentan");
const spanduk = document.querySelector("#spanduk");

const teksTamu = 'Halo, buku tamu! <img src=x onerror="tandaiSerangan()"> <b>Agenda kelas</b>';

area.value = teksTamu;

function tandaiSerangan() {
  spanduk.textContent = "Script asing berhasil dijalankan lewat panel rentan. Inilah XSS.";
  spanduk.className = "spanduk aktif";
}

document.querySelector("#tombol-aman").addEventListener("click", () => {
  const isian = document.createElement("p");
  isian.textContent = area.value;
  panelAman.append(isian);
});

document.querySelector("#tombol-rentan").addEventListener("click", () => {
  const isian = document.createElement("p");
  isian.innerHTML = area.value;
  panelRentan.append(isian);
});

document.querySelector("#reset").addEventListener("click", () => {
  panelAman.innerHTML = "<h3>Panel aman: textContent</h3>";
  panelRentan.innerHTML = "<h3>Panel rentan: innerHTML</h3>";
  spanduk.textContent = "";
  spanduk.className = "spanduk";
});

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

Kesalahan umum#

  1. Escaping hanya pada sebagian titik keluaran. Serangan cukup menemukan satu tempat yang terlewat, karena titik keluaran yang lemah sudah menjadi jalur masuk paling mudah. Terapkan textContent atau escaping pada setiap titik keluaran tanpa kecuali, bukan hanya pada tempat yang paling sering dikunjungi.
  2. Melonggarkan CSP demi skrip lama. Menambahkan 'unsafe-inline' pada script-src mengembalikan celahnya, skrip inline hasil injeksi menjadi sah kembali di mata kebijakan itu. Pindahkan skripnya ke berkas terpisah terlebih dahulu, baru ketatkan kebijakannya.
  3. Mengira framework menutup semuanya. Pustaka antarmuka modern memang meng-escape teks secara bawaan, tetapi properti seperti dangerouslySetInnerHTML pada React tetap membuka jalur yang sama bila diberi masukan mentah. Aturan textContent untuk data pengguna tetap berlaku di atas pustaka mana pun.

Ringkasan#

  • XSS terjadi ketika masukan pengguna dipasang sebagai markup, karena tag dan atribut di dalamnya dijalankan browser pada semua pengunjung halaman.
  • textContent memperlakukan string sebagai teks murni dan menjadi pilihan bawaan untuk semua konten dari luar program.
  • Konten yang memang harus menjadi markup disaring lewat escaping, yaitu penggantian &, <, dan > menjadi bentuk amannya sebelum dipasang dengan innerHTML.
  • Content Security Policy membatasi sumber skrip halaman dan menjadi lapisan kedua di belakang kebiasaan aman, bukan penggantinya.

Referensi#