XSS: Memahami Titik Rawan di Aplikasi Modern

Salah satu kesalahpahaman yang sering saya temui di tim frontend maupun backend adalah anggapan bahwa "framework modern sudah aman dari XSS". Vue, React, dan Blade memang meng-escape output secara otomatis by default, tapi itu bukan jaminan mutlak. Begitu ada satu baris kode yang sengaja "melewati" mekanisme escaping itu — entah karena butuh render HTML dari CMS, preview markdown, atau sekadar copy-paste dari Stack Overflow — celah XSS bisa terbuka lagi. Buat saya yang sehari-hari kerja di sistem yang menyimpan data sensitif, memahami titik-titik ini bukan sekadar teori keamanan, tapi kebiasaan wajib.

Kenapa auto-escape saja tidak cukup

Secara default, {{ $variable }} di Blade dan interpolasi {{ }} di Vue akan meng-escape karakter seperti <, >, dan " menjadi entity HTML. Ini menutup jalur XSS paling umum: user memasukkan <script> di form, lalu skrip itu ikut tereksekusi saat ditampilkan ke user lain. Tapi ada dua situasi yang membuat proteksi ini tidak berlaku:

  • Ketika developer secara eksplisit minta output tidak di-escape, misalnya lewat v-html di Vue, dangerouslySetInnerHTML di React, atau {!! $variable !!} di Blade.
  • Ketika output itu disuntikkan bukan lewat template engine sama sekali, misalnya manipulasi innerHTML langsung di JavaScript, atau membangun HTML string manual lalu di-inject ke DOM.

Di kedua kasus ini, browser akan memperlakukan string itu sebagai markup sungguhan, bukan teks polos. Kalau string itu berasal dari input user — sekecil apa pun kemungkinannya — itu sudah jadi vektor serangan.

Kapan raw HTML benar-benar dibutuhkan

Kebutuhan render HTML mentah itu nyata dan wajar: hasil dari WYSIWYG editor, konten CMS, atau markdown yang sudah dikonversi ke HTML memang perlu ditampilkan apa adanya, bukan sebagai teks ter-escape penuh tag. Yang sering luput adalah asumsi bahwa "karena datanya dari database kami sendiri, berarti aman". Padahal kalau data itu awalnya diketik oleh user (misalnya lewat form artikel atau komentar), database hanya menyimpan apa yang pernah masuk lewat input — termasuk payload berbahaya kalau tidak pernah disaring sejak awal.

Prinsip yang saya pegang: sanitasi dilakukan sedekat mungkin dengan titik di mana HTML itu akan dirender, bukan hanya sekali saat data disimpan. Pakai library sanitizer yang battle-tested — di ekosistem JS misalnya DOMPurify, di PHP ada HTML Purifier — untuk membersihkan tag dan atribut berbahaya seperti <script>, event handler onerror/onclick, atau skema URL javascript:. Contoh pola sederhana di Vue:

<div v-html="sanitize(article.bodyHtml)"></div>

Fungsi sanitize di sini menjalankan whitelist tag/atribut sebelum string masuk ke v-html, jadi meskipun sumber datanya user-generated, yang sampai ke DOM sudah bersih.

Lapisan pertahanan tambahan

Sanitasi output itu penting, tapi jangan jadi satu-satunya lapisan. Beberapa kebiasaan yang saya anggap wajib:

  1. Terapkan Content Security Policy (CSP) yang membatasi dari mana skrip boleh dimuat. Ini tidak mencegah XSS terjadi, tapi membatasi dampaknya kalau lolos.
  2. Beri atribut HttpOnly pada cookie sesi, supaya sekalipun ada skrip asing yang jalan, ia tidak bisa membaca cookie login lewat document.cookie.
  3. Jangan pernah percaya validasi di sisi klien saja — validasi dan sanitasi input tetap harus terjadi di server, karena request bisa dikirim langsung tanpa lewat UI.

Sebagai ilustrasi, di tim yang menangani modul input klinis atau catatan medis, kebiasaan ini terasa lebih krusial lagi karena datanya sering dibaca ulang oleh banyak pengguna berbeda peran — sekali ada payload yang lolos, dampaknya menyebar ke banyak sesi sekaligus.

Takeaway

Aturan sederhananya: escape output secara default, dan setiap kali kamu sengaja mematikan escaping — lewat v-html, innerHTML, atau {!! !!} — anggap itu sebagai keputusan sadar yang wajib disertai sanitasi eksplisit. Jangan tunggu sampai ada laporan bug untuk menyadari bahwa satu baris v-html tanpa sanitasi ternyata sudah lama nangkring di production.