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.
| Bentuk | Perjalanan string | Contoh skenario |
|---|---|---|
| Reflected | masuk lewat permintaan saat itu — parameter URL atau isi form — dan langsung ditampilkan ulang | halaman hasil menampilkan kembali isian ?cari= tanpa disaring |
| Stored | disimpan dulu di server, lalu disajikan kepada setiap pengunjung | satu ulasan berisi markup tampil bagi semua pembuka halaman produk |
| DOM-based | diproses sepenuhnya oleh JavaScript klien, tanpa pernah menyentuh server | isi 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):
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 : <img src=x onerror="alert('Punten')">Keluaran dicatat dari eksekusi nyata di lingkungan jsdom dengan halaman uji yang hanya berisi <div id="panel-komentar"></div>. Mari kita bedah:
- Baris 5 (
const ulasan = ...): satu string biasa. Sebagai data, string ini belum berbahaya apa pun — bahaya lahir dari cara halaman memperlakukannya. - Baris 8 (
panel.innerHTML = ulasan): propertiinnerHTMLmemanggil parser HTML atas string itu. Parser tidak tahu (dan tidak peduli) dari mana string berasal; ia melihat<img ...>dan membangun elemenimgsungguhan, lengkap dengan atributonerrorberisi teks program. - Baris 10–13 (pembacaan struktur):
querySelectorAll("img")menemukan satu elemen,getAttribute("onerror")mengembalikan kode yang siap dijalankan, dan pembacaan kembaliinnerHTMLmemperlihatkan parser sudah menyeragamkan tanda kutip (src=xmenjadisrc="x") — tanda bahwa string itu benar-benar telah diproses sebagai markup, bukan disimpan sebagai teks. - Baris 16–17 (tag script): node
<script>dari string pengguna tetap ter-parse (true), tetapi keluarandari string penggunatidak pernah muncul di mana pun. Spesifikasi HTML memang tidak mengeksekusi<script>yang disisipkan lewatinnerHTML. - Baris 21–23 (jalur textContent): string yang sama ditugaskan lewat
textContent. Pemeriksaan menemukan nol elemenimg, dan isi panel tersimpan dalam bentuk entitas (<img ...>). 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.cookieyang tidak ditandaiHttpOnly; - membaca seluruh isi
localStoragedansessionStorage; - 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// 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);