Mencegah XSS intinya satu: data dari user diubah jadi teks tepat di tempat dia ditaruh. Bayangin koran yang mengutip ucapan narasumber. Kutipannya diberi tanda petik, jadi pembaca tahu itu kata-kata orang lain, bukan pernyataan redaksi. Escape adalah tanda petik untuk browser: < ditulis <, jadi browser menampilkan tanda <, tapi gak membuka tag.
🧪 Tebakan cepat: sebuah fungsi escape mengganti
<dulu, baru&. Apa hasilnya untuka & b < c? Jawabannya ada di bagian "Escape HTML: lima karakter".
Escape HTML: lima karakter
Lima karakter punya arti khusus di HTML. Escape menggantinya dengan entity, kode yang dibaca browser sebagai karakter biasa:
| Karakter | Jadi |
|---|---|
& |
& |
< |
< |
> |
> |
" |
" |
' |
' |
Di latihan, fungsinya bernama sanitizeHTML:
function sanitizeHTML(input) {
return input
.replace(/&/g, "&") // harus pertama
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
console.log(sanitizeHTML("<script>alert(1)</script>")); // <script>alert(1)</script>
console.log(sanitizeHTML('Halo "Budi"')); // Halo "Budi"
console.log(sanitizeHTML("a & b < c")); // a & b < c
- Namanya
sanitizeHTML, tapi kerjanya escape: semua karakter tetap ada, cuma ditulis sebagai entity. Browser menampilkan<script>alert(1)</script>sebagai tulisan. /&/g: flaggmengganti semua kemunculan, bukan cuma yang pertama.&harus diganti pertama. Kalau<duluan,a & b < cjadia & b &lt; c:&milik<ikut diganti, dan halaman menampilkan tulisan<. Itu jawaban tebakannya.'boleh ditulis'atau', artinya sama.
Tergantung tempatnya
Penangkal yang benar tergantung di mana data ditaruh. Lab memakai satu template untuk tiap tempat:
| Tempat | Template | Penangkal | a & b < c jadi |
|---|---|---|---|
| Isi elemen | <p>Komentar: ...</p> |
sanitizeHTML |
a & b < c |
| Nilai atribut | <input value="..."> |
sanitizeHTML, atribut selalu bertanda kutip |
a & b < c |
| URL | <a href="/cari?q=..."> |
encodeURIComponent |
a%20%26%20b%20%3C%20c |
- Di atribut, karakter paling berbahaya adalah
". Tanpa escape,<input value="Halo "Budi"">putus diHalo, danbudi""jadi atribut baru. Dengan", value-nya utuhHalo "Budi". - Di URL,
&memisahkan parameter dan spasi gak boleh ada. Tanpa encode, server membacaqsebagaiasaja.encodeURIComponentmengubah&jadi%26dan spasi jadi%20. - Kalau seluruh URL datang dari user, misalnya kolom website di profil, cek juga awalannya harus
https:atauhttp:, supaya linkjavascript:ditolak.
Biarkan template yang escape
Template engine modern sudah escape otomatis. Yang perlu kamu kenali justru jalan pintas yang mematikannya.
// PHP biasa: escape sendiri
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
{{-- Blade --}}
<p>{{ $comment->body }}</p> {{-- aman: di-escape --}}
<p>{!! $comment->body !!}</p> {{-- bahaya: dicetak mentah --}}
// React: {name} di-escape otomatis
<h1>Halo, {name}</h1>
// dangerouslySetInnerHTML melewati escape: jangan untuk teks user
<div dangerouslySetInnerHTML={{ __html: userInput }} />
htmlspecialcharsmengubah& < > " 'jadi entity, persis sepertisanitizeHTML.ENT_QUOTESmemastikan kedua jenis tanda kutip ikut diubah (sejak PHP 8.1 sudah jadi default, tapi menulisnya tetap kebiasaan baik), dan'UTF-8'menyebut encoding-nya.- Blade
{{ }}memanggilhtmlspecialcharsotomatis, jadi aman. - Blade
{!! !!}mencetak mentah, tanpa escaping. Kalaubodyberisi script, terjadi XSS. Pakai{{ }}, atau sanitize dulu kalau isinya memang HTML.
Kalau memang butuh HTML: sanitizer
Kadang komentar boleh berisi teks tebal dan miring. Escape akan menampilkan <b> sebagai tulisan, jadi yang dipakai sanitizer allowlist: daftar tag yang boleh, sisanya dibuang.
import DOMPurify from "dompurify";
const komentar = "<b>Budi</b> <img src=x onerror=alert(1)>";
const bersih = DOMPurify.sanitize(komentar, { ALLOWED_TAGS: ["b", "i"], ALLOWED_ATTR: [] });
console.log(JSON.stringify(bersih)); // "<b>Budi</b> "
box.innerHTML = bersih;
- Allowlist (daftar yang boleh) lebih aman dari blocklist (daftar yang dilarang): tag yang gak kamu kenal otomatis dibuang.
ALLOWED_ATTR: []membuang semua atribut, termasukonerror.<img>gak ada di daftar, jadi hilang seluruhnya.- Pakai library yang terawat seperti DOMPurify. Jangan menulis regex sendiri untuk membuang tag.
💡 CSP: jaring pengaman terakhir
Content Security Policy (CSP) adalah header yang memberi tahu browser script mana yang boleh jalan:
Content-Security-Policy: script-src 'self' 'nonce-abc123'
'self': file JavaScript dari domainmu sendiri boleh jalan.'nonce-abc123': script inline yang punya atributnonce="abc123"boleh jalan. Nonce dibuat acak baru di setiap response.- Script inline lain dan handler seperti
onerrorditolak. Kalau ada escape yang terlewat, script-nya tetap gak jalan. - Hindari
'unsafe-inline': dia membuka lagi semua script inline.
CSP itu jaring pengaman, bukan pengganti escape. Detailnya ada di materi Content Security Policy.
⚠️ Jebakan yang sering ketemu
&gak diganti pertama. Hasilnya escape dua kali,&lt;, dan halaman menampilkan kode entity-nya.- Satu penangkal untuk semua tempat. URL butuh
encodeURIComponent. Atribut tanpa tanda kutip bisa dimulai ulang cuma dengan spasi, jadi selalu pakai tanda kutip. {!! !!}ataudangerouslySetInnerHTMLuntuk data user. Keduanya mematikan escape bawaan.- Escape saat menyimpan, lalu menampilkan mentah. Data yang sama bisa muncul di tempat lain (JSON, atribut, email). Escape saat menampilkan, sesuai tempatnya.
TL;DR: Ubah data user jadi teks tepat di tempat dia ditaruh. Di HTML, escape lima karakter: & jadi & (pertama), < jadi <, > jadi >, " jadi ", ' jadi '. Di atribut, selalu pakai tanda kutip; di URL, pakai encodeURIComponent. PHP: htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8'). Blade {{ }} escape otomatis, {!! !!} mentah. Kalau HTML-nya memang boleh, pakai sanitizer allowlist seperti DOMPurify. CSP jadi jaring pengaman terakhir, bukan pengganti escape.