Cross-Site Scripting (XSS) terjadi ketika penyerang berhasil menyelipkan kode — hampir selalu JavaScript — yang kemudian dieksekusi oleh browser korban di halaman situs kita. Karena berjalan di konteks situs yang sah, kode penyerang itu memperoleh semua yang bisa dicapai script sah milik situs: membaca DOM, mengirim permintaan sambil membawa cookie korban, hingga mengubah tampilan halaman. Ini salah satu kerentanan web tertua dan tetap menjadi salah satu yang paling banyak ditemukan di aplikasi PHP dunia nyata — sering kali cukup satu echo tanpa escaping untuk meruntuhkan seluruh pertahanan autentikasi yang kita bangun sepanjang section sebelumnya. Pada chapter ini kita bedah tiga wujud serangannya, dampaknya, dan pertahanan berlapisnya, selalu dengan versi kode rentan berdampingan versi amannya.
Anatomi: Kapan String Berubah Menjadi Kode #
Browser tidak membedakan “HTML buatan pengembang” dan “HTML yang berasal dari input pengguna” — begitu keduanya bergabung dalam satu dokumen, semuanya adalah markup yang akan diinterpretasi. XSS lahir tepat di momen batas itu: data yang seharusnya tetap berstatus teks berbalik menjadi bagian dari struktur halaman. Perhatikan bahwa masalahnya bukan fungsi echo itu sendiri, melainkan konteks tujuannya: string yang ditujukan menjadi isi halaman justru diperlakukan sebagai penyusun halaman. Seluruh pertahanan XSS karena itu bermuara pada satu disiplin: menjaga data melintasi batas tersebut sebagai teks, dengan aturan escaping yang sudah kita petakan pada chapter Output Escaping
.
Reflected XSS: Payload yang Dipantulkan #
Bentuk paling sederhana: input pengguna langsung dipantulkan ke halaman respons — kotak hasil pencarian, pesan galat, atau sapaan personal. Penyerang memancing korban mengeklik tautan berisi payload, dan halaman hasil memantulkannya sebagai kode hidup. Berikut simulasinya dengan nilai query string tiruan:
<?php
// Simulasi $_GET["q"] — di aplikasi nyata: $q = $_GET["q"] ?? "";
$q = '"><script>alert("XSS")</script>';
echo "RENDER RENTAN (tanpa escape):\n";
echo "<p>Hasil pencarian untuk " . $q . "</p>\n";
echo "RENDER AMAN (htmlspecialchars):\n";
echo "<p>Hasil pencarian untuk " . htmlspecialchars($q, ENT_QUOTES, "UTF-8") . "</p>\n";Output yang Dihasilkan:
RENDER RENTAN (tanpa escape):
<p>Hasil pencarian untuk "><script>alert("XSS")</script></p>
RENDER AMAN (htmlspecialchars):
<p>Hasil pencarian untuk "><script>alert("XSS")</script></p>Baca kedua hasilnya sebagaimana parser HTML membacanya. Pada render rentan, "> di awal payload menutup atribut dan tag <p> yang kita susun, lalu <script>alert("XSS")</script> menjadi tag script sungguhan yang dieksekusi browser — tanda kutip dalam payload sengaja melengkapi jebakan. Pada render aman, kelima karakter berbahaya telah menjadi entity ("><script>...), sehingga keseluruhan payload tampil sebagai teks biasa “dipajang” di halaman, bukan dieksekusi. Perhatikan pula payload "> di awal: inilah mengapa escaping harus memakai ENT_QUOTES — tanpanya tanda kutip yang membangun atribut bisa dibajak untuk melahirkan atribut event seperti onmouseover.
Stored XSS: Payload yang Menetap #
Reflected XSS mengharuskan korban mengklik tautan tertentu; stored XSS jauh lebih berbahaya karena payloadnya disimpan di database — dalam komentar, nama tampilan, atau judul — lalu ditampilkan kepada setiap pengunjung yang membuka halaman itu. Penyerang cukup menanam sekali, dan seluruh pengunjung menjadi sasaran, termasuk admin yang membuka halaman moderasi. Simulasinya dengan SQLite:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
isi TEXT NOT NULL
)");
$pdo->exec("INSERT INTO comments (nama, isi) VALUES ('Siti', 'Artikelnya sangat membantu!')");
$pdo->exec("INSERT INTO comments (nama, isi) VALUES ('Budi', 'Bagus! <script>alert(\"XSS\")</script>')");
$rows = $pdo->query("SELECT nama, isi FROM comments")->fetchAll(PDO::FETCH_ASSOC);
echo "RENDER RENTAN (tanpa escape):\n";
foreach ($rows as $row) {
echo "<li><b>{$row['nama']}</b>: {$row['isi']}</li>\n";
}
echo "RENDER AMAN (escape di output):\n";
foreach ($rows as $row) {
$nama = htmlspecialchars($row["nama"], ENT_QUOTES, "UTF-8");
$isi = htmlspecialchars($row["isi"], ENT_QUOTES, "UTF-8");
echo "<li><b>$nama</b>: $isi</li>\n";
}Output yang Dihasilkan:
RENDER RENTAN (tanpa escape):
<li><b>Siti</b>: Artikelnya sangat membantu!</li>
<li><b>Budi</b>: Bagus! <script>alert("XSS")</script></li>
RENDER AMAN (escape di output):
<li><b>Siti</b>: Artikelnya sangat membantu!</li>
<li><b>Budi</b>: Bagus! <script>alert("XSS")</script></li>Perhatikan sesuatu yang penting dari hasil di atas: payload tetap tersimpan mentah di database — baris komentar Budi berisi <script> apa adanya — dan justru itu yang benar. Keamanan tidak diperoleh dengan “membersihkan” data saat penyimpanan, melainkan dengan escaping pada setiap titik output, persis prinsip chapter Output Escaping
. Ada dua alasan praktis: titik output ada banyak (halaman publik, panel admin, feed RSS, email notifikasi) dan masing-masing wajib meng-escape; serta versi mentah adalah satu-satunya sumber kebenaran yang bisa di-render ulang ke konteks apa pun. Bila satu titik output saja lupa, stored XSS itu tetap meledak — kerap bukan di halaman publik, melainkan di halaman admin yang jarang diuji.
DOM XSS: Bahaya di Sisi Browser #
Dua bentuk sebelumnya terjadi karena server menggabungkan data ke HTML; DOM XSS terjadi sepenuhnya di browser, tanpa server pernah mengirim HTML yang salah. JavaScript milik aplikasi sendiri yang mengambil data tidak tepercaya lalu menuliskannya ke halaman lewat properti yang menginterpretasi HTML:
<!-- Rentan: innerHTML memperlakukan string sebagai markup -->
<div id="sapaan"></div>
<script>
const params = new URLSearchParams(location.search);
document.getElementById("sapaan").innerHTML = "Halo, " + params.get("nama");
</script>
<!-- Aman: textContent memperlakukan string sebagai teks biasa -->
<script>
document.getElementById("sapaan").textContent = "Halo, " + params.get("nama");
</script>Sumber data yang berbahaya bahkan bisa berada di fragment URL (#...) yang secara tradisional tidak pernah dikirim ke server — memvalidasi di sisi server saja tidak menyentuhnya. Peran PHP di sini adalah memastikan data yang disuntikkan ke dalam blok <script> dilewatkan json_encode() dengan flag JSON_HEX_*, pola dari chapter Output Escaping
; selebihnya, disiplin penggunaan textContent di atas innerHTML menjadi tanggung jawab kode front-end.
Dampak: Apa yang Bisa Dilakukan Kode Penyerang #
XSS sering diremehkan sebagai “hanya muncul dialog alert” — padahal dialog itu hanya kesepakatan penyerang untuk menunjukkan diri. Kode yang berjalan di halaman situs kita dapat melakukan hal-hal berikut:
- Mencuri session: membaca
document.cookielalu mengirimkannya ke server penyerang; cookie session tanpa flaghttponlyikut terbaca, dan penyerang tinggal memakai nilai itu untuk berpura-pura menjadi korban. - Bertindak sebagai korban: mengirim permintaan AJAX
POSTdari halaman — transfer dana, ganti email, ubah kata sandi — karena permintaan dari browser korban otomatis membawa kredensialnya. Flaghttponlytidak menahan jalur ini. - Membajak tampilan: menimpa isi halaman dengan form login palsu di atas domain asli, atau mengalihkan ke situs phishing — phishing paling meyakinkan adalah yang beralamatkan domain korban sendiri.
- Merekam ketikan: memasang listener keyboard untuk mencuri kata sandi yang diketik korban di halaman yang tampak normal.
- Menyebar sendiri: stored XSS di halaman yang dilihat banyak pengguna bisa diprogram menyuntikkan payload ke profil setiap pengunjung — pola worm seperti kasus Samy di MySpace (2005) yang menginfeksi lebih dari satu juta profil dalam 20 jam.
Pertahanan Berlapis #
Lapisan pertama dan utama tetap escaping sesuai konteks pada setiap titik output — tidak ada penggantinya. Lapisan berikutnya mengasumsikan yang terburuk: bagaimana bila satu titik lolos. Cookie session dengan httponly membuat JavaScript tidak dapat membacanya meski kode penyerang berhasil masuk:
<?php
// Diatur sebelum session_start(): cookie session tak terbaca JavaScript.
ini_set("session.cookie_httponly", "1");
ini_set("session.cookie_secure", "1");
ini_set("session.cookie_samesite", "Strict");Lapisan ketiga, Content Security Policy, memerintahkan browser menolak mengeksekusi script inline maupun dari domain asing — payload yang menyusup pun gagal mengeksekusi:
<?php
// Kirim sebelum output apa pun; rincian directive dibahas chapter CSP.
header("Content-Security-Policy: default-src 'self'; script-src 'self'");Ketiganya saling menopang: escaping mencegah penyusupan, httponly meredam pencurian session, CSP memutus jalur eksekusi. Rincian ketiga flag cookie dibahas pada chapter Cookie Options
, dan menyusun kebijakan CSP yang tidak merusak aplikasi dibedah tuntas pada chapter Content Security Policy
.
Bila Pengguna Memang Boleh Mengirim HTML #
Kadang kebutuhan bisnis menuntut konten kaya: pengguna menulis artikel dengan <b>, <i>, dan tautan. Di titik ini escaping justru salah — misinya menampilkan tag, bukan menetralkannya. Solusinya adalah sanitasi dengan allowlist: urai HTML, pertahankan hanya tag dan atribut yang diizinkan, buang sisanya. Berikut inti idenya dalam bentuk sederhana:
<?php
function sanitizeAllowlist(string $html, array $tagDiizinkan): string
{
return preg_replace_callback(
'/<[^>]+>/',
function (array $cocok) use ($tagDiizinkan): string {
if (preg_match('/^<\s*\/?\s*([a-zA-Z0-9]+)/', $cocok[0], $tag) !== 1) {
return "";
}
$namaTag = strtolower($tag[1]);
if (!in_array($namaTag, $tagDiizinkan, true)) {
return "";
}
// Hanya tag polos tanpa atribut yang dipertahankan.
return $cocok[0] === "<$namaTag>" || $cocok[0] === "</$namaTag>" ? $cocok[0] : "";
},
$html
);
}
$input = 'Halo <b>Budi</b>! <script>alert(1)</script> <a href="javascript:alert(1)">klik</a> <i>penting</i>';
echo sanitizeAllowlist($input, ["b", "i"]) . "\n";Output yang Dihasilkan:
Halo <b>Budi</b>! alert(1) klik <i>penting</i>Dari input yang penuh jebakan, hanya <b> dan <i> polos yang selamat: tag <script> dan <a> dibuang (teks di antaranya, alert(1) dan klik, tetap tampil sebagai teks biasa), dan versi tag beratribut apa pun ditolak. Namun perhatikan kata “intinya” — contoh di atas edukatif, bukan produksi.
Referensi #
Dokumentasi resmi untuk pendalaman lebih lanjut:
Ringkasan #
XSS mengeksploitasi momen data berubah menjadi markup: reflected lewat pantulan input pada respons, stored lewat payload yang menetap di database, dan DOM lewat JavaScript aplikasi yang memakai innerHTML sembarangan. Dampaknya berlapis — dari pencurian session hingga kode penyerang yang bertindak penuh sebagai korban — sehingga pertahanannya pun berlapis: escaping sesuai konteks di setiap titik output, cookie session httponly, dan CSP sebagai jaring pengaman. Untuk konten yang memang berupa HTML, sanitasi allowlist dengan library teruji menggantikan escaping. XSS membuat kode penyerang berjalan di dalam browser korban; serangan tetangga yang akan kita bahas berikutnya jauh lebih sederhana — CSRF tidak butuh menyusupkan kode apa pun, cukup meminjam browser korban untuk mengirim permintaan.