Kolom ulasan di toko Budi menerima teks dari siapa pun yang ingin menulis. Kebanyakan isinya wajar — pujian untuk buku, keluhan pengiriman, pertanyaan stok. Tetapi sebuah halaman web tidak punya cara bawaan untuk membedakan markup milik halaman dan markup yang diselundupkan di dalam teks pengunjung; begitu string pengunjung disisipkan lewat innerHTML, browser mem-parsing-nya seperti markup lain, dan node hasil parsing menjadi bagian halaman kita.

Bila string itu membawa perintah JavaScript, halaman kita yang menjalankannya — dengan semua hak yang dimiliki kode halaman: membaca cookie, mengirim data ke server mana pun, mengganti tampilan. Pola ini dikenal sebagai Cross-Site Scripting (XSS): data dari pihak lain diperalatan menjadi kode yang berjalan di halaman kita. Namanya berasal dari masa ketika vektor utamanya melintasi situs lewat tautan; intinya hari ini tetap sama — data dari satu konteks berpindah menjadi kode di konteks lain. Chapter pembuka section ini memetakan tiga bentuknya dan membuktikan mekanisme injeksinya pada struktur DOM; pertahanan satu per satu menyusul di chapter-chapter berikutnya.

Tiga Bentuk XSS #

Ketiga bentuk berbagi inti yang sama: string dari luar mencapai parser HTML halaman. Yang membedakan adalah perjalanan string itu sampai ke titik tersebut.

BentukPerjalanan stringContoh skenario
Reflectedmasuk lewat permintaan saat itu — parameter URL atau isi form — dan langsung ditampilkan ulanghalaman hasil menampilkan kembali isian ?cari= tanpa disaring
Storeddisimpan dulu di server, lalu disajikan kepada setiap pengunjungsatu ulasan berisi markup tampil bagi semua pembuka halaman produk
DOM-baseddiproses sepenuhnya oleh JavaScript klien, tanpa pernah menyentuh serverisi location.hash ditulis langsung ke innerHTML panel hasil

Untuk toko Budi, bentuk stored adalah yang paling sering: ulasan berbahaya cukup dikirim sekali, lalu setiap pembaca halaman produk menjadi jalurnya. Reflected biasanya perlu menggoda pengguna menekan tautan yang sudah membawa markup di dalamnya. DOM-based murni urusan kode klien yang kita tulis sendiri — sumbernya URL atau input, tujuannya DOM.

Dua istilah yang membantu membaca ketiganya: sumber (source) adalah tempat data dari luar masuk — parameter URL, isi input, balasan server — dan sink adalah tempat data itu mencapai parser atau pengeksekusi — innerHTML, location.href, eval. XSS terjadi setiap kali ada jalur dari sumber ke sink yang tidak disaring. Alat pemindai keamanan bekerja dengan cara yang sama: memetakan semua pasangan sumber–sink pada kode, lalu menandai jalur yang tidak punya penjaga.

Membuktikan Injeksi pada Struktur DOM #

Kita tidak memerlukan browser sungguhan untuk membuktikan mekanismenya — dan kita memang tidak akan menjalankan skrip yang diinjeksi. Dengan jsdom, lingkungan DOM yang kita pakai sejak section DOM, cukup satu panel kosong dan satu string “ulasan”. Yang dibuktikan hanya pembentukan node, bukan eksekusi kode:

// injeksi-komentar.js — membuktikan bagaimana string pengguna menjadi node DOM
const panel = document.querySelector("#panel-komentar");

// ulasan "pengguna" — satu string biasa, belum berbahaya apa pun
const ulasan = '<img src=x onerror="alert(\'Punten\')">';

// 1. jalur berbahaya: string disisipkan sebagai markup
panel.innerHTML = ulasan;
const img = panel.querySelector("img");
console.log("elemen img terbentuk :", panel.querySelectorAll("img").length);
console.log("isi atribut onerror  :", img.getAttribute("onerror"));
console.log("isi atribut src      :", img.getAttribute("src"));
console.log("innerHTML terbaca    :", panel.innerHTML);

// 2. tag <script> lewat innerHTML: ter-parse, tidak pernah dieksekusi
panel.innerHTML = '<script>console.log("dari string pengguna")</script>';
console.log("node script ada      :", panel.querySelector("script") !== null);

// 3. jalur aman: string yang sama diperlakukan sebagai teks
panel.innerHTML = "";
panel.textContent = ulasan;
console.log("elemen img (teks)    :", panel.querySelectorAll("img").length);
console.log("isi panel            :", panel.innerHTML);

Output yang Dihasilkan (console browser):

text
elemen img terbentuk : 1
isi atribut onerror  : alert('Punten')
isi atribut src      : x
innerHTML terbaca    : <img src="x" onerror="alert('Punten')">
node script ada      : true
elemen img (teks)    : 0
isi panel            : &lt;img src=x onerror="alert('Punten')"&gt;

Keluaran dicatat dari eksekusi nyata di lingkungan jsdom dengan halaman uji yang hanya berisi <div id="panel-komentar"></div>. Mari kita bedah:

  1. Baris 5 (const ulasan = ...): satu string biasa. Sebagai data, string ini belum berbahaya apa pun — bahaya lahir dari cara halaman memperlakukannya.
  2. Baris 8 (panel.innerHTML = ulasan): properti innerHTML memanggil parser HTML atas string itu. Parser tidak tahu (dan tidak peduli) dari mana string berasal; ia melihat <img ...> dan membangun elemen img sungguhan, lengkap dengan atribut onerror berisi teks program.
  3. Baris 10–13 (pembacaan struktur): querySelectorAll("img") menemukan satu elemen, getAttribute("onerror") mengembalikan kode yang siap dijalankan, dan pembacaan kembali innerHTML memperlihatkan parser sudah menyeragamkan tanda kutip (src=x menjadi src="x") — tanda bahwa string itu benar-benar telah diproses sebagai markup, bukan disimpan sebagai teks.
  4. Baris 16–17 (tag script): node <script> dari string pengguna tetap ter-parse (true), tetapi keluaran dari string pengguna tidak pernah muncul di mana pun. Spesifikasi HTML memang tidak mengeksekusi <script> yang disisipkan lewat innerHTML.
  5. Baris 21–23 (jalur textContent): string yang sama ditugaskan lewat textContent. Pemeriksaan menemukan nol elemen img, dan isi panel tersimpan dalam bentuk entitas (&lt;img ...&gt;). Ketikan pengguna hanya pernah menjadi teks.

Pada halaman sungguhan, baris 8 adalah titik insiden: gambar dengan src=x gagal dimuat, peristiwa error terpicu, dan penangan onerror berjalan. Pengujian kita aman karena jsdom tidak memuat sumber daya apa pun dan kita tidak mengeksekusi string yang disisipkan — struktur yang terbentuk sudah cukup untuk memahami masalahnya.

Apa yang Bisa Dilakukan Skrip yang Lolos #

Skrip yang berjalan di halaman tidak mendapat kotak pasir khusus; ia bekerja sebagai bagian dari halaman, dengan akses yang sama seperti kode kita. Perbedaannya dengan kode kita satu-satunya: niatnya. Beberapa kemampuan yang biasa disalahgunakan:

  • membaca document.cookie yang tidak ditandai HttpOnly;
  • membaca seluruh isi localStorage dan sessionStorage;
  • mengirim datanya ke server mana pun lewat fetch;
  • mengubah tampilan dan menyusun form palsu untuk meminta data;
  • merekam aksi pengguna di halaman selama skrip itu hidup.

Daftar ini juga menjelaskan mengapa XSS dianggap masalah serius, bukan sekadar iseng tampilan: satu string yang lolos bisa menyentuh data pengguna, sesi login, dan reputasi situs sekaligus. Bukan kebetulan pula daftar pertahanan kita panjang — setiap butir di atas punya penangkalnya sendiri, dan section ini mengurusnya satu per satu: cara menampilkan data dengan aman (chapter Safe DOM Updates), tidak mengeksekusi string (Avoiding eval), menyaring tautan (URL Validation), menjaga dependensi (Dependency Security), tidak menitipkan rahasia di klien (Secrets dan Client-Side Code), menambah lapisan kebijakan browser (CSP), dan memilih tempat penyimpanan data (Secure Browser Storage).

Kita mulai dari yang paling dekat dengan bukti di atas: kebiasaan memperbarui DOM.

Berkas Sumber

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

injeksi-komentar.js

Unduh berkas
javascript
// injeksi-komentar.js — membuktikan bagaimana string pengguna menjadi node DOM
const panel = document.querySelector("#panel-komentar");

// ulasan "pengguna" — satu string biasa, belum berbahaya apa pun
const ulasan = '<img src=x onerror="alert(\'Punten\')">';

// 1. jalur berbahaya: string disisipkan sebagai markup
panel.innerHTML = ulasan;
const img = panel.querySelector("img");
console.log("elemen img terbentuk :", panel.querySelectorAll("img").length);
console.log("isi atribut onerror  :", img.getAttribute("onerror"));
console.log("isi atribut src      :", img.getAttribute("src"));
console.log("innerHTML terbaca    :", panel.innerHTML);

// 2. tag <script> lewat innerHTML: ter-parse, tidak pernah dieksekusi
panel.innerHTML = '<script>console.log("dari string pengguna")</script>';
console.log("node script ada      :", panel.querySelector("script") !== null);

// 3. jalur aman: string yang sama diperlakukan sebagai teks
panel.innerHTML = "";
panel.textContent = ulasan;
console.log("elemen img (teks)    :", panel.querySelectorAll("img").length);
console.log("isi panel            :", panel.innerHTML);