CSRF memaksa server mengeksekusi aksi palsu; Cross-Site Scripting (XSS) lebih personal lagi — penyerang menyuntikkan kode JavaScript miliknya agar dieksekusi di browser korban, dengan seluruh hak akses korban di aplikasi kita. Skrip itu bisa membaca cookie, mengubah DOM, atau mencuri data yang tampil di halaman. Laravel memasang perisai bawaan yang nyaris tak terlihat: setiap {{ }} di Blade meng-escape output secara otomatis. Bahaya justru muncul saat kita sengaja mematikan perisai itu lewat {!! !!}. Chapter ini menguji keduanya dengan output nyata, membahas dua wajah serangan (stored dan reflected), dan menutup dengan lapisan pertahanan tambahan untuk kasus yang benar-benar membutuhkan HTML mentah.
Dua Wajah Serangan #
XSS stored terjadi ketika payload tersimpan permanen di database — misalnya judul postingan berisi <script> — lalu dieksekusi setiap kali halaman dibuka siapa pun. XSS reflected lebih singkat: payload berjalan sekali lewat URL yang dikorbankan mengklik, misalnya ?q=<script>.... Keduanya mengeksploitasi titik yang sama: output user yang dirender tanpa escape. Karena itu obatnya pun satu.
Perisai Bawaan: Escape Otomatis {{ }} #
Kita simpan judul postingan yang mengandung payload, lalu render lewat Blade biasa:
<div class="post">
<h2>{{ $judul }}</h2>
</div>use App\Models\Post;
use App\Models\User;
$budi = User::firstWhere('email', '[email protected]');
$post = Post::create([
'user_id' => $budi->id,
'judul' => "<script>alert('xss')</script>Hadiah gratis",
'isi' => 'Klik sekarang.',
]);
echo trim(view('xss-escape', ['judul' => $post->judul])->render());Output yang Dihasilkan:
<div class="post">
<h2><script>alert('xss')</script>Hadiah gratis</h2>
</div>Payload tersimpan apa adanya di database, tetapi saat dirender Blade mengubah setiap karakter berbahaya menjadi entity: < menjadi <, > menjadi >, ' menjadi '. Browser menampilkan teks <script>... secara literal — pengunjung hanya melihat tulisan aneh, bukan dialog alert. Perhatikan bahwa payload justru tetap tersimpan di database; perlindungan bekerja di lapisan output. Inilah alasan {{ }} harus menjadi default mental kita setiap menampilkan data user.
Saat Perisai Dilepas: Bahaya {!! !!} #
Directive {!! !!} mematikan escape. Output yang sama menjadi berbahaya:
<div class="post">
<h2>{!! $judul !!}</h2>
</div>Output yang Dihasilkan:
<div class="post">
<h2><script>alert('xss')</script>Hadiah gratis</h2>
</div>Kali ini tag <script> utuh dikirim ke browser — dan begitu halaman dirender, skrip penyerang dieksekusi. Untuk judul postingan, tidak ada alasan memakai {!! !!}. Kasus wajarnya adalah konten rich text editor (WYSIWYG) yang memang menghasilkan HTML seperti <p>Halo</p><script>alert('xss')</script>. Di sinilah aturannya: {!! !!} hanya untuk data yang sudah disanitasi. Paket ezyang/htmlpurifier (populer lewat wrapper mews/purifier) memfilter HTML dengan whitelist elemen dan atribut yang diizinkan — <p> lolos, <script> dibuang — sebelum konten disimpan atau dirender.
Konteks Atribut: Skema javascript: yang Lolos dari Escape #
Escape entitas melindungi konteks teks, tetapi atribut punya lubang tersendiri. URL yang ditaruh pengguna pada href bisa memakai skema javascript:, dan escape Blade tidak memblokirnya:
$url = "javascript:alert('xss')";
echo trim(view('xss-link', ['url' => $url])->render());Output yang Dihasilkan:
<a href="javascript:alert('xss')">Klik di sini</a>Tanda kutip ter-escape dengan benar, tetapi skema javascript: tetap utuh — sekali diklik, skrip berjalan. Solusinya adalah memeriksa skema URL sebelum meletakkannya di atribut:
function urlAman(?string $url): string
{
$skema = strtolower(parse_url($url ?? '', PHP_URL_SCHEME) ?? '');
if (in_array($skema, ['http', 'https'], true) || str_starts_with($url ?? '', '/')) {
return htmlspecialchars($url ?? '', ENT_QUOTES, 'UTF-8');
}
return '#';
}Output yang Dihasilkan:
javascript:alert(1) -> #
https://laravel.com -> https://laravel.com
/postingan/1 -> /postingan/1Hanya skema http, https, dan path relatif yang diizinkan; sisanya diganti tautan mati #. Aturan umumnya: escape menangani karakter, validasi menangani makna — URL butuh keduanya.
Lapisan Kedua: Content-Security-Policy #
Saat kode atau konfigurasi bocor satu titik, header Content-Security-Policy (CSP) membatasi kerusakan dengan menentukan sumber yang boleh menjalankan skrip. Laravel memungkinkan kita menempelkan header ini pada response apa pun:
$res = response()->view('xss-escape', ['judul' => 'Judul aman'])
->withHeaders(['Content-Security-Policy' => "default-src 'self'"]);
echo 'header CSP: '.$res->headers->get('Content-Security-Policy')."\n";Output yang Dihasilkan:
header CSP: default-src 'self'Dengan kebijakan default-src 'self', skrip inline yang tersuntik tidak dieksekusi karena hanya skrip dari domain sendiri yang diizinkan. CSP bukan alasan untuk bermalas-malasan dalam escape — ia jaring pengaman ketika lapisan pertama gagal. Detail perilaku escape Blade dapat dibaca di dokumentasi Blade
.
Dengan lapisan output terkunci, kita berbalik ke lapisan input: bagaimana satu field form ekstra bisa mengubah hak akses pengguna — inti chapter Mass Assignment Protection .