Tiga Bentuk yang Perlu Dibedakan
Tersimpan (stored) payload masuk basis data, menyerang setiap pengunjung Terpantul (reflected) payload ada di URL, menyerang korban yang membuka tautan Berbasis DOM payload tidak pernah menyentuh server sama sekali
XSS berbasis DOM sering luput dari pengujian karena payload-nya berada setelah tanda pagar dan tidak pernah dikirim ke server — sehingga tidak muncul di log mana pun.
// Rentan — fragmen URL langsung ditulis ke halaman
document.getElementById("salam").innerHTML =
"Halo, " + location.hash.slice(1);
// https://contoh.id/#<img src=x onerror=alert(1)>
Pengodean Bergantung pada Konteks
Tidak ada satu fungsi "escape" yang benar di segala tempat. Karakter yang berbahaya berbeda-beda menurut posisi penyisipan:
Isi elemen HTML < > & → < > & Nilai atribut kutip, spasi → selalu bungkus dengan kutip Dalam JavaScript kutip, backslash → JSON.stringify, bukan escape HTML Dalam URL seluruh komponen → encodeURIComponent Dalam CSS kurung, kutip → hindari nilai dari pengguna
Menyisipkan data ke dalam <script> dengan pengodean HTML tidak berpengaruh, karena isi elemen script tidak diurai sebagai HTML. Nilai " tetap terbaca sebagai tanda kutip oleh mesin JavaScript, dan payload lolos. Gunakan JSON.stringify, atau lebih baik lagi taruh data pada atribut data- lalu baca dari JavaScript.
<!-- Rentan meski sudah "di-escape" -->
<script>const nama = "{{ nama|escape }}";</script>
<!-- Lebih aman -->
<div id="data" data-nama="{{ nama|escape }}"></div>
<script>
const nama = document.getElementById("data").dataset.nama;
</script>
Kerangka Kerja Modern Aman — Sampai Dilangkahi
// Aman — React meng-escape otomatis
<div>{komentarPengguna}</div>
// Berbahaya — pintu keluar yang menonaktifkan proteksi
<div dangerouslySetInnerHTML={{ __html: komentarPengguna }} />
// Vue
<div v-html="komentarPengguna"></div> // sama berbahayanya
<div>{{ komentarPengguna }}</div> // aman
Bila HTML Memang Harus Diizinkan
import DOMPurify from "dompurify";
const bersih = DOMPurify.sanitize(masukan, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li"],
ALLOWED_ATTR: ["href", "title"],
ALLOWED_URI_REGEXP: /^https?:\/\//i
});
Menguraikan HTML dengan benar jauh lebih rumit daripada yang terlihat: tag yang tidak ditutup, atribut tanpa kutip, entitas bertingkat, penanganan berbeda antar peramban. Pustaka mapan seperti DOMPurify sudah menutup ratusan kasus tepi yang ditemukan bertahun-tahun. Regex buatan sendiri hampir selalu bisa dilewati.
Content Security Policy
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{ACAK}';
style-src 'self';
img-src 'self' data: https:;
connect-src 'self' https://api.contoh.id;
frame-ancestors 'none';
base-uri 'self';
object-src 'none'
CSP tidak mencegah XSS terjadi — ia membatasi apa yang dapat dilakukan payload setelah berhasil disisipkan. Skrip dari domain asing diblokir, eval ditolak, dan pengiriman data curian ke server penyerang gagal karena connect-src. Kombinasi pengodean yang benar plus CSP jauh lebih kuat daripada salah satunya saja.
Direktif ini sering ditambahkan agar skrip inline yang sudah ada tetap berjalan. Tetapi justru skrip inline itulah bentuk utama payload XSS. Gunakan nonce yang dihasilkan acak per permintaan, atau hash untuk skrip inline yang isinya tetap.
Cookie yang Tidak Dapat Dibaca Skrip
Set-Cookie: sesi=abc123;
HttpOnly; // tidak terbaca document.cookie
Secure; // hanya melalui HTTPS
SameSite=Lax; // batasi pengiriman lintas situs
Path=/;
Max-Age=3600
Dengan HttpOnly, XSS tidak lagi otomatis berarti pencurian sesi. Penyerang masih dapat bertindak atas nama pengguna selama halaman terbuka, tetapi tidak dapat mengambil token sesi untuk dipakai di tempat lain.