Aplikasi web mengambil data dari banyak sumber — form pengguna, database, API pihak ketiga — lalu menyajikannya ke banyak tujuan: halaman HTML, atribut tag, blok JavaScript, hingga URL tautan. Data yang sama bisa jadi jinak di satu tujuan dan berbahaya di tujuan lain: nama Budi & Siti tak masalah di email teks polos, tetapi merusak struktur di HTML; parameter kategori=snack/manis wajar sebagai teks, tetapi membingungkan router bila ditanam mentah ke URL. Output escaping adalah transformasi yang diterapkan tepat sebelum data keluar — mengubah karakter berbahaya menjadi representasi yang tak bermakna di konteks tersebut. Chapter ini memetakan aturan escaping per konteks beserta fungsi PHP yang tepat untuk masing-masing, karena satu-satunya kesalahan yang lebih buruk daripada tidak escaping adalah escaping dengan aturan yang salah.

Escape di Batas Output, Bukan di Penyimpanan #

Keputusan arsitektural pertama: data mentah disimpan apa adanya, escaping dilakukan saat menampilkan. Tiga alasan mendasar. Pertama, konteks tujuan belum diketahui saat data masuk — komentar yang sama bisa berakhir di halaman HTML, email teks polos, dan berkas CSV yang menuntut aturan berbeda. Kedua, menyimpan versi ter-escape membuat data rusak secara diam-diam: nama asli “Siti & Budi” berubah menjadi “Siti & Budi” selamanya. Ketiga, escaping ganda menumpuk entity satu di atas yang lain. Bandingkan dua jalur penampilan data yang sama:

php
<?php
// Data mentah persis seperti yang diketik pengguna — disimpan apa adanya.
$komentar = "Harga <Rp50.000 terlalu murah! (menurut Budi & Siti)";

echo "Tampilan HTML : " . htmlspecialchars($komentar, ENT_QUOTES, "UTF-8") . "\n";
echo "Email plain   : " . $komentar . "\n";

Output yang Dihasilkan:

text
Tampilan HTML : Harga &lt;Rp50.000 terlalu murah! (menurut Budi &amp; Siti)
Email plain   : Harga <Rp50.000 terlalu murah! (menurut Budi & Siti)

Data yang sama ditampilkan dua kali dengan dua perlakuan: versi HTML mengubah < dan & menjadi entity sehingga browser menampilkannya sebagai teks biasa, sedangkan email teks polos memakai data mentah karena email client tidak menginterpretasi HTML. Bila data sudah disimpan dalam bentuk ter-escape, jalur kedua akan mengirim “Rp50.000 … &” kepada pengguna yang bingung.

Konteks HTML: htmlspecialchars #

Untuk konteks badan HTML dan atribut HTML berpetik, alatnya satu: htmlspecialchars() dengan tiga komponen yang tidak boleh dipotong — flag ENT_QUOTES, encoding "UTF-8", dan disiplin tidak meng-escape dua kali:

php
<?php
$input = 'a"b\'c & <d>';

var_dump(htmlspecialchars($input, ENT_QUOTES, "UTF-8"));

// Kebalikan yang salah: string yang sudah di-escape di-escape lagi
var_dump(htmlspecialchars(htmlspecialchars($input, ENT_QUOTES, "UTF-8"), ENT_QUOTES, "UTF-8"));

Output yang Dihasilkan:

text
string(31) "a&quot;b&#039;c &amp; &lt;d&gt;"
string(51) "a&amp;quot;b&amp;#039;c &amp;amp; &amp;lt;d&amp;gt;"

Lima karakter berbahaya berubah seluruhnya: & menjadi &amp;, < menjadi &lt;, > menjadi &gt;, " menjadi &quot;, dan ' menjadi &#039;. Flag ENT_QUOTES wajib karena tanpanya tanda kutip dibiarkan apa adanya — dan tanda kutip adalah pembuka serta penutup nilai atribut. Encoding "UTF-8" memastikan konversi tidak salah mengenali byte multi-byte. Baris kedua output memperlihatkan jebakan double-escape: entity &quot; dari putaran pertama dianggap & biasa pada putaran kedua dan menggelembung menjadi &amp;quot; — teks yang tampil ke pengguna ikut rusak.

Penerapan pada atribut menuntut satu syarat tambahan: nilai atribut selalu diapit tanda kutip. Berikut nama pengguna yang mencoba menyelipkan atribut event:

php
<?php
$nama = "Budi' onmouseover='alert(1)";

echo '<a href="/profil" title="' . htmlspecialchars($nama, ENT_QUOTES, "UTF-8") . '">Profil Budi</a>' . "\n";

Output yang Dihasilkan:

text
<a href="/profil" title="Budi&#039; onmouseover=&#039;alert(1)">Profil Budi</a>

Tanpa ENT_QUOTES, tanda kutip tunggal bebas menutup atribut title dan melahirkan atribut onmouseover baru. Dengan flag tersebut, ' menjadi &#039; — browser memperlakukannya sebagai isi title dan peristiwa klik-hampir-membuka-pintu-XSS itu tidak pernah terjadi.

Peta Konteks dan Fungsi Escape-nya #

Inilah peta lengkapnya — hafalkan barisnya, bukan satu fungsi ajaib:

Konteks tujuanAlat yang tepatCatatan
Badan HTML (teks antar tag)htmlspecialchars(..., ENT_QUOTES, "UTF-8")Lima karakter berbahaya menjadi entity
Atribut HTML berpetikhtmlspecialchars(..., ENT_QUOTES, "UTF-8")Nilai atribut wajib diapit "..." atau '...'
String di dalam <script>json_encode() + flag JSON_HEX_*htmlspecialchars tidak cukup di sini
CSS / atribut styleHindari data pengguna; bila terpaksa, sanitasi ketat allowlistTidak ada fungsi escape CSS bawaan PHP
Parameter URL / segmen pathrawurlencode() atau http_build_query()Spasi menjadi %20, karakter khusus dikodekan

Baris CSS sengaja tegas: CSS bukan bahasa teks yang bisa di-escape dengan tabel entity — nilai seperti expression(...) warisan IE atau url(javascript:...) adalah masalah struktur, sehingga solusinya adalah allowlist nilai yang diizinkan, bukan escape. Sisanya kita bedah satu per satu — dua konteks yang paling sering salah ditangani, JavaScript dan URL, berikut ini.

Konteks JavaScript: json_encode #

Data pengguna sering perlu masuk ke variabel JavaScript — misalnya nama pengguna untuk sapaan dinamis. Jebakannya: parser HTML tidak memahami string JavaScript, jadi sekuat apa pun escape string versi JS, urutan </script> di dalam data akan menutup tag script lebih awal dan sisanya diperlakukan sebagai HTML — lahirnya XSS. Solusi PHP yang benar: lebarkan data lewat json_encode() dengan flag JSON_HEX_* yang mengodekan <, >, ', " sebagai urutan \uXXXX:

php
<?php
$nama = '</script><script>alert(1)</script>';

echo '<script>var nama = ' . json_encode($nama, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>' . "\n";

Output yang Dihasilkan:

text
<script>var nama = "\u003C\/script\u003E\u003Cscript\u003Ealert(1)\u003C\/script\u003E";</script>

Baca outputnya dari sisi dua parser yang bekerja berlapis: parser HTML tidak lagi menemukan </script> literal (sudah menjadi \u003C\/script\u003E), dan parser JavaScript menerima string yang valid lalu mengembalikan teks asli lengkap dengan tanda <-nya saat dipakai. Kombinasi keempat flag itulah yang membuat json_encode() sah untuk konteks inline script; tanpa flag, </script> lolos apa adanya.

Konteks URL: rawurlencode dan http_build_query #

URL punya tata bahasanya sendiri: & memisahkan parameter, = memisahkan kunci dan nilai, / memisahkan segmen path, dan spasi sama sekali tidak legal. Menanam data mentah ke URL berarti data bisa “memakan” struktur di sekelilingnya — nilai budi & siti diam-diam melahirkan parameter kosong bernama siti:

php
<?php
$q = "budi & siti";
$kategori = "elektronik/hiburan";

echo "rawurlencode   : https://toko.example/cari?kategori=" . rawurlencode($kategori) . "&q=" . rawurlencode($q) . "\n";
echo "urlencode      : ?q=" . urlencode($q) . "\n";
echo "http_build_query: ?" . http_build_query(["q" => $q, "kategori" => $kategori]) . "\n";

Output yang Dihasilkan:

text
rawurlencode   : https://toko.example/cari?kategori=elektronik%2Fhiburan&q=budi%20%26%20siti
urlencode      : ?q=budi+%26+siti
http_build_query: ?q=budi+%26+siti&kategori=elektronik%2Fhiburan

rawurlencode() mengikuti RFC 3986: spasi menjadi %20, & menjadi %26, / menjadi %2F — aman untuk segmen path maupun nilai query. urlencode() adalah format form-encoding: spasi menjadi +, tepat untuk body application/x-www-form-urlencoded tetapi kurang tepat untuk path. Untuk membangun query string utuh dari array, http_build_query() adalah alat yang tepat — ia mengurus pemisah & dan pengodean tiap nilai sekaligus, seperti terlihat pada baris ketiga output.

Auto-Escaping pada Template Engine #

Prinsip “escape di batas output” bekerja paling mulus bila batas itu punya nama: template. Engine template modern seperti Twig menjadikan escaping perilaku bawaan — setiap {{ }} otomatis melewati htmlspecialchars untuk konteks HTML, sehingga kelalaian individual tidak menempel di ratusan titik echo:

text
{# Twig: keluaran otomatis di-escape untuk konteks HTML #}
{{ user.nama }}

{# Escape dimatikan — hanya untuk HTML yang sudah terpercaya atau ter-sanitasi #}
{{ user.bio|raw }}

Filter |raw yang mematikan escape adalah pintu darurat yang paling sering disalahgunakan: setiap kemunculannya adalah tempat di mana data mentah kembali masuk HTML. Perlakukan setiap |raw sebagai keputusan keamanan yang harus dijustifikasi — untuk konteks non-HTML, Twig juga menyediakan filter per konteks (|escape('js'), |escape('url')) yang mengikuti peta konteks pada tabel di atas.

Referensi #

Dokumentasi resmi untuk pendalaman lebih lanjut:

Ringkasan #

Escaping adalah disiplin konteks: data mentah disimpan apa adanya, lalu ditransformasi tepat sebelum keluar dengan aturan yang mengikuti tujuannya — htmlspecialchars berflag ENT_QUOTES untuk badan dan atribut HTML, json_encode berflag JSON_HEX_* untuk string di dalam script, rawurlencode atau http_build_query untuk URL, dan allowlist ketat untuk CSS. Template engine memperkuat disiplin ini dengan auto-escaping yang membuat jalur aman menjadi jalur bawaan. Bab berikutnya menguji seluruh peta ini dari sisi lawan: bagaimana penyerang memanfaatkan satu titik yang lupa di-escape, dan seberapa luas kerusakan yang bisa ditimbulkan oleh Cross-Site Scripting.