Cross-site scripting (XSS) adalah kerentanan yang membuat skrip buatan penyerang berjalan di browser korban seolah-olah skrip itu bagian dari halaman yang sah. Pada OWASP Top 10:2025, XSS termasuk kategori A05 Injection bersama keluarga serangan injeksi lainnya. Topik ini membedah anatomi XSS melalui tiga variannya, yaitu reflected, stored, dan DOM-based, beserta lokasi payload dan pemicu eksekusinya. Setiap varian direproduksi di lab lokal yang aman, sehingga mekanismenya dapat diamati secara langsung tanpa menyentuh sistem pihak lain.
Tiga varian XSS#
Ketiga varian XSS berakhir pada akibat yang sama, yaitu eksekusi kode di browser korban. Pembedanya terletak pada perjalanan payload sebelum tiba di browser, dan pembeda ini menentukan cara celah dicari sekaligus cara ditambal. Payload dapat bergerak lewat permintaan, lewat penyimpanan server, atau hanya lewat sisi klien, dan masing-masing jalur meninggalkan jejak yang berbeda. Tabel berikut merangkum ketiganya.
| Varian | Lokasi payload | Pemicu eksekusi |
|---|---|---|
| Reflected | Permintaan, misalnya query string | Korban membuka tautan berpayload |
| Stored | Basis data server | Korban membuka halaman yang menampilkan data itu |
| DOM-based | URL atau fragmen halaman | Skrip halaman menulisnya ke DOM yang berbahaya |
Istilah reflected menunjuk pada payload yang datang dalam satu permintaan lalu dipantulkan kembali oleh respons server. Istilah stored menunjuk pada payload yang bertahan di penyimpanan server sehingga hidup lebih lama dari satu permintaan. Istilah DOM-based menunjuk pada payload yang tidak pernah diolah server, karena penguraian dan penulisannya terjadi sepenuhnya di sisi klien. Ketiganya dibedah satu per satu pada bagian berikut, lalu varian reflected direproduksi di lab.
Reflected XSS#
Reflected XSS terjadi ketika server mengambil data dari permintaan, misalnya parameter pencarian, lalu menulisnya kembali ke respons tanpa pengodean. Payload tidak tersimpan di mana pun, sehingga umurnya hanya sepanjang permintaan itu. Korban harus memicu permintaan berpayload sendiri, dan cara klasiknya adalah tautan berpayload yang disebar melalui pesan, halaman komentar, maupun layanan pemendek tautan. Skrip berjalan begitu halaman terbuka karena payload sudah menjadi bagian dari HTML respons.
Contoh paling sederhana ada pada mesin pencari internal yang menampilkan kata kunci di halaman hasilnya. Apabila kata kunci ditulis apa adanya ke dalam HTML, payload seperti <img src=x onerror=...> ikut tercetak dan parser browser membacanya sebagai elemen, bukan sebagai teks. Isi atribut onerror kemudian dieksekusi karena gambar x gagal dimuat. Pola pantulan seperti ini muncul di banyak fitur, mulai dari hasil pencarian, pesan error formulir, hingga pratinjau dokumen.
Karena payloadnya menempel pada tautan, varian ini sering diremehkan, padahal akibatnya identik dengan varian lain, yaitu kode yang berjalan dengan identitas situs yang sah. Pencarian celahnya pun sederhana, yaitu memantulkan nilai setiap parameter dengan penanda unik lalu memeriksa apakah penanda itu tampil tanpa pengodean pada responsnya.
Stored XSS#
Stored XSS terjadi ketika payload berhasil disimpan oleh aplikasi, misalnya lewat kolom komentar, nama profil, atau catatan yang dibagikan. Server menyimpan payload apa adanya, kemudian menuliskannya kembali ke halaman setiap kali data itu ditampilkan. Korban tidak perlu membuka tautan apa pun dan cukup membuka halaman yang memuat data tersebut. Cakupan korbannya jauh lebih luas dibanding varian reflected karena satu payload menjangkau semua pembaca data itu, sehingga varian ini umumnya diperlakukan sebagai temuan dengan dampak tertinggi di antara ketiganya.
Varian ini sering lahir dari alur tulis-baca yang tidak simetris. Masukan difilter pada satu fitur, tetapi fitur lain membaca data yang sama tanpa pengodean, sehingga celahnya justru berada di sisi pembaca. Data lama yang tersimpan sebelum perbaikan tetap berbahaya, sehingga pembenahan perlu mencakup data yang sudah ada di basis data, bukan hanya kode yang menulisnya. Pengujian pun tidak berhenti pada satu halaman, melainkan mengikuti perjalanan data dari setiap tempat menulis hingga setiap tempat membacanya.
DOM-based XSS#
DOM-based XSS terjadi ketika skrip halaman sendiri yang membawa payload ke tempat berbahaya. Sumber datanya bisa berupa location.search, location.hash, maupun pesan postMessage, sedangkan tujuannya adalah fungsi yang menulis ke DOM seperti innerHTML, document.write, atau eval. Pasangan sumber dan tujuan ini dikenal sebagai source dan sink, yaitu titik tempat data dibaca dan titik tempat data mulai diperlakukan sebagai kode. Server tidak terlibat sama sekali, sehingga jejak pada log server biasanya tidak ada.
Sebagai contoh, skrip yang membaca location.search lalu menampilkannya dengan innerHTML telah menciptakan jalur lengkap dari URL menuju eksekusi. Perbaikannya berada di kode klien, yaitu mengganti sink dengan penulisan teks seperti textContent, bukan merapikan sisi server. Karena itu pula pengujian varian ini dilakukan dengan membaca kode JavaScript halaman, bukan hanya dengan mengamati respons HTTP. Pembersihan berbasis WAF (web application firewall) juga tidak membantu karena payload tidak pernah diproses server.
Lab: reflected XSS pada halaman pencarian#
Demo berikut menyimulasikan halaman pencarian yang menulis ulang kata kunci ke area hasil. Jalankan demo, klik tombol “Isi payload contoh” untuk mengisi kolom pencarian dengan <img src=x onerror="hasilKompromi()">, lalu klik “Cari (innerHTML)” dan amati pita merah yang muncul sebagai tanda fungsi penyerang tereksekusi. Setelah itu klik “Cari aman (textContent)” untuk melihat payload yang sama tampil sebagai teks biasa. Demo juga menerima parameter ?q= pada URL-nya, sehingga alur tautan berpayload dapat dicoba dengan membuka berkasnya langsung di browser.
Kode sumber demo
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<title>Lab reflected XSS</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; }
form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
label { font-size: 13px; font-weight: 600; }
input { flex: 1 1 220px; padding: 6px 8px; border: 1px solid #94a3b8; border-radius: 6px; font-family: ui-monospace, monospace; font-size: 13px; }
button { padding: 6px 10px; border: 0; border-radius: 6px; font-size: 13px; cursor: pointer; }
#tombol-rentan { background: #b91c1c; color: #fff; }
#tombol-aman { background: #15803d; color: #fff; }
#tombol-payload { background: #e2e8f0; color: #0f172a; }
.hasil { background: #fff; border: 1px solid #cbd5e1; border-radius: 8px; min-height: 96px; padding: 10px 12px; font-size: 13px; }
.judul-hasil { margin: 0 0 8px; font-weight: 600; }
.placeholder { margin: 0; color: #94a3b8; }
.pwned { margin: 8px 0 0; padding: 8px 10px; background: #fee2e2; border: 1px solid #b91c1c; border-radius: 6px; color: #7f1d1d; font-family: ui-monospace, monospace; font-size: 12px; }
</style>
</head>
<body>
<h1>Mesin pencari catatan (lab rentan)</h1>
<p class="ket">Halaman pencarian yang menulis ulang kata kunci ke area hasil. Jalur rentan menggunakan innerHTML, jalur aman menggunakan textContent.</p>
<form id="form-cari">
<label for="kueri">Kata kunci</label>
<input id="kueri" type="text" value="catatan">
<button type="submit" id="tombol-rentan">Cari (innerHTML)</button>
<button type="button" id="tombol-aman">Cari aman (textContent)</button>
<button type="button" id="tombol-payload">Isi payload contoh</button>
</form>
<div id="hasil" class="hasil"><p class="placeholder">Hasil pencarian muncul di sini.</p></div>
<script src="index.js" defer></script>
<script>
(function () {
if (window.__labXssAktif) return;
window.__labXssAktif = true;
var input = document.getElementById('kueri');
var hasil = document.getElementById('hasil');
// "Skrip penyerang": dipanggil dari atribut onerror saat payload tereksekusi.
window.hasilKompromi = function () {
if (hasil.querySelector('.pwned')) return;
var tanda = document.createElement('p');
tanda.className = 'pwned';
tanda.textContent = 'onerror tereksekusi: skrip penyerang berjalan di halaman.';
hasil.appendChild(tanda);
};
// Jalur rentan: nilai masukan digabung ke markah, lalu diurai sebagai HTML.
function cariRentan() {
hasil.innerHTML = '<p class="judul-hasil">Hasil untuk: ' + input.value + '</p>';
}
document.getElementById('form-cari').addEventListener('submit', function (e) {
e.preventDefault();
cariRentan();
});
// Jalur aman: nilai masukan ditempel sebagai teks, tanpa penguraian markah.
document.getElementById('tombol-aman').addEventListener('click', function () {
hasil.textContent = '';
var baris = document.createElement('p');
baris.className = 'judul-hasil';
baris.textContent = 'Hasil untuk: ' + input.value;
hasil.appendChild(baris);
});
document.getElementById('tombol-payload').addEventListener('click', function () {
input.value = '<img src=x onerror="hasilKompromi()">';
});
// Simulasi tautan berpayload: halaman dibuka dengan ?q=... langsung memantulkan isinya.
var q = new URLSearchParams(location.search).get('q');
if (q !== null) {
input.value = q;
cariRentan();
}
})();
</script>
<script>
(function () {
if (window.__labXssAktif) return;
window.__labXssAktif = true;
var input = document.getElementById('kueri');
var hasil = document.getElementById('hasil');
// "Skrip penyerang": dipanggil dari atribut onerror saat payload tereksekusi.
window.hasilKompromi = function () {
if (hasil.querySelector('.pwned')) return;
var tanda = document.createElement('p');
tanda.className = 'pwned';
tanda.textContent = 'onerror tereksekusi: skrip penyerang berjalan di halaman.';
hasil.appendChild(tanda);
};
// Jalur rentan: nilai masukan digabung ke markah, lalu diurai sebagai HTML.
function cariRentan() {
hasil.innerHTML = '<p class="judul-hasil">Hasil untuk: ' + input.value + '</p>';
}
document.getElementById('form-cari').addEventListener('submit', function (e) {
e.preventDefault();
cariRentan();
});
// Jalur aman: nilai masukan ditempel sebagai teks, tanpa penguraian markah.
document.getElementById('tombol-aman').addEventListener('click', function () {
hasil.textContent = '';
var baris = document.createElement('p');
baris.className = 'judul-hasil';
baris.textContent = 'Hasil untuk: ' + input.value;
hasil.appendChild(baris);
});
document.getElementById('tombol-payload').addEventListener('click', function () {
input.value = '<img src=x onerror="hasilKompromi()">';
});
// Simulasi tautan berpayload: halaman dibuka dengan ?q=... langsung memantulkan isinya.
var q = new URLSearchParams(location.search).get('q');
if (q !== null) {
input.value = q;
cariRentan();
}
})();
</script></body>
</html>Perbedaan yang muncul di demo berasal dari dua metode penulisan DOM yang bekerja sangat berbeda. Metode innerHTML menguraikan nilai yang diberikan sebagai markah (markup), sehingga setiap tag di dalamnya menjadi elemen sungguhan beserta atributnya. Metode textContent menyimpan nilai sebagai rangkaian karakter tanpa penguraian, sehingga <img> yang sama hanya berupa teks. Skrip berikut meniru langkah penguraian tersebut di Node.js untuk menunjukkan token yang akan ditemukan parser browser.
// uji-innerhtml.js — membandingkan jalur innerHTML dan textContent.
// Node.js tidak memiliki DOM, sehingga langkah penguraian markah browser
// ditiru dengan mencari token yang akan ditemukan oleh parser HTML.
const payload = '<img src=x onerror="hasilKompromi()">';
// Jalur innerHTML: nilai digabung ke markah, lalu diurai sebagai HTML.
const markah = `<p class="judul-hasil">Hasil untuk: ${payload}</p>`;
const tagDitemukan = markah.match(/<[a-z]+[^>]*>/gi) || [];
const namaTag = tagDitemukan.map((tag) => tag.match(/[a-z]+/i)[0]);
const atributEvent = (markah.match(/\son[a-z]+=/g) || []).length;
console.log('Jalur innerHTML (nilai diurai sebagai markah):');
console.log(markah);
console.log(`Tag yang dibuat parser : ${namaTag.join(', ')}`);
console.log(`Atribut event yang diaktifkan: ${atributEvent}`);
// Jalur textContent: nilai hanya teks, tidak ada penguraian markah.
const teks = 'Hasil untuk: ' + payload;
console.log('');
console.log('Jalur textContent (nilai disimpan apa adanya sebagai teks):');
console.log(teks);
console.log('Tag yang dibuat parser : tidak ada, karena tidak ada penguraian markah');Jalur innerHTML (nilai diurai sebagai markah):
<p class="judul-hasil">Hasil untuk: <img src=x onerror="hasilKompromi()"></p>
Tag yang dibuat parser : p, img
Atribut event yang diaktifkan: 1
Jalur textContent (nilai disimpan apa adanya sebagai teks):
Hasil untuk: <img src=x onerror="hasilKompromi()">
Tag yang dibuat parser : tidak ada, karena tidak ada penguraian markahKeluaran menunjukkan bahwa jalur innerHTML menghasilkan dua elemen, yaitu p dan img, serta satu atribut event yang aktif. Jalur textContent menghasilkan nol elemen karena tidak ada penguraian yang dilakukan. Prinsip inilah dasar pengodean keluaran, yaitu memastikan data tetap berupa data pada saat ditulis ke halaman.
Kesalahan umum#
Kesalahan pertama adalah memfilter masukan berbasis daftar hitam tag, misalnya membuang substring <script> saja. Payload tetap lolos melalui <img onerror=...>, <svg onload=...>, maupun bentuk seperti <scr<script>ipt> yang kembali utuh setelah difilter. Memperpanjang daftar hitam hanya mengandalkan keberuntungan, karena daftar semacam itu selalu tertinggal satu langkah dari teknik yang belum dikenal, sedangkan perbaikan yang sah adalah melakukan pengodean pada saat data ditulis.
Kesalahan kedua adalah menjadikan validasi masukan sebagai pertahanan tunggal. Validasi tetap berguna untuk menjaga kualitas data, tetapi data yang sah secara format tetap dapat berbahaya apabila konteks keluarannya berubah, misalnya nama pengguna dengan tanda kutip yang kelak tampil di dalam atribut HTML. Pertahanan ditempatkan di titik keluar karena hanya di sanalah konteksnya diketahui.
Kesalahan ketiga adalah menganggap atribut HttpOnly pada cookie menyelesaikan masalah XSS. Atribut itu memang menyembunyikan cookie dari document.cookie, tetapi skrip penyerang tetap berjalan di dalam halaman dan dapat mengirim permintaan dengan kredensial korban, membaca isi halaman, maupun menulis ulang seluruh antarmuka.
Ringkasan#
- XSS mengeksekusi skrip di browser korban karena data diperlakukan sebagai bagian dari halaman, dan masuk ke kategori A05 Injection pada OWASP Top 10:2025.
- Reflected membawa payload pada permintaan, stored menyimpannya di server, sedangkan DOM-based bergerak sepenuhnya di sisi klien dari source menuju sink.
innerHTMLmenguraikan nilai sebagai markah sehingga tag di dalamnya hidup, sedangkantextContentmenyimpan nilai sebagai teks tanpa penguraian.- Pengodean pada titik penulisan adalah perbaikannya, bukan daftar hitam pada titik masukan.
Referensi#
- Cross-site scripting — PortSwigger Web Security Academy — Materi pembelajaran resmi tentang ketiga varian XSS beserta konteks eksploitasinya.
- Cross Site Scripting Prevention Cheat Sheet — OWASP — Panduan pertahanan XSS termasuk aturan pengodean per konteks.
- OWASP Top 10:2025 — OWASP — Daftar resmi risiko aplikasi web edisi 2025 yang menempatkan Injection pada A05.