Setelah kita memperbaiki keamanan sesi login pada chapter sebelumnya, kini kita hadapi serangan yang sama sekali tidak membajak sesi, melainkan meminjamnya. Clickjacking — sering disebut UI redressing — menipu pengguna agar mengklik elemen halaman Anda tanpa sadar, sementara cookie autentikasi yang ikut terkirim membuat aksi tersebut tampak sah di mata server. Serangan ini tidak memerlukan celah pada kode PHP Anda; yang dieksploitasi adalah kebebasan browser menampilkan halaman apa pun di dalam iframe. Pada chapter ini kita bedah mekanisme overlay transparan, lalu membangun pertahanan berbasis header X-Frame-Options dan frame-ancestors yang dipancarkan dari PHP.
Cara Kerja Serangan Overlay #
Penyerang menyiapkan satu halaman di domain miliknya, misalnya jahat.example, lalu menyembunyikan iframe berisi halaman korban — misalnya formulir transfer bank.example — di balik elemen umpan yang terlihat menarik. Dengan CSS, iframe tersebut diposisikan menutupi seluruh layar, diberi opasitas nyaris nol, dan ditumpuk di atas tombol umpan. Ketika pengguna berniat mengklik tombol umpan, klik tersebut jatuh ke elemen di dalam iframe yang tidak terlihat, misalnya tombol “Kirim” pada formulir transfer. Karena iframe memuat halaman asli bank.example, browser melampirkan cookie sesi pengguna sehingga server memproses transfer seolah-olah permintaan tersebut sah.
Alur lengkap serangan dapat diringkas dalam diagram berikut:
Diagram di atas menunjukkan bahwa seluruh serangan berlangsung di sisi browser; server korban hanya menerima satu klik yang tampak normal. Berikut potongan halaman penyerang yang menerapkan trik overlay tersebut (diperlihatkan untuk konteks edukasi, jangan digunakan):
<!-- Halaman milik penyerang di jahat.example -->
<style>
.target-frame {
position: absolute; /* ditempatkan bebas di atas dokumen */
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.0001; /* nyaris transparan, tak terlihat mata */
z-index: 2; /* berada DI ATAS tombol umpan */
}
.umpan {
position: absolute;
top: 120px;
left: 40px;
z-index: 1; /* selalu di bawah iframe */
}
</style>
<h1>Klaim voucher gratis!</h1>
<p class="umpan"><button>Klik di sini untuk klaim</button></p>
<iframe class="target-frame"
src="https://bank.example/transfer?rekening=9999&jumlah=5000000"></iframe>Mari kita bedah properti kunci pada kode di atas:
position: absolutemelepasiframedari alur normal dokumen sehingga penyerang bebas menempatkannya tepat di atas tombol umpan.opacity: 0.0001membuatiframenyaris transparan — pengguna sama sekali tidak melihat halamanbank.exampleyang sebenarnya diklik.z-index: 2memastikaniframeberada pada lapisan paling atas; tombol umpan denganz-index: 1hanya berfungsi sebagai umpan visual.src="https://bank.example/transfer?..."memuat halaman asli beserta formulirnya, sehingga klik pengguna mengenai tombol asli di halaman korban.
Proteksi Klasik dengan X-Frame-Options #
Cara pertahanan tertua adalah memberi tahu browser bahwa halaman Anda tidak boleh dirender di dalam frame. Header respons X-Frame-Options sudah didukung semua browser sejak lama dan dibaca sebelum halaman dirender, sehingga iframe penyerang langsung ditolak. Tiga nilai yang pernah didefinisikan dapat diringkas berikut ini:
| Nilai | Perilaku browser |
|---|---|
DENY | Halaman tidak boleh ditampilkan dalam frame/iframe apa pun. |
SAMEORIGIN | Hanya boleh di-embed oleh halaman dengan origin yang sama persis. |
ALLOW-FROM uri | Usang — diabaikan oleh browser modern, jangan digunakan. |
Di PHP, header dipancarkan dengan fungsi header() yang wajib dipanggil sebelum output apa pun dikirim. Agar logika mudah diuji tanpa server, kita tulis dulu sebagai fungsi murni yang mengembalikan array header:
<?php
function frameGuardHeaders(string $mode = 'sameorigin'): array
{
return match (strtolower($mode)) {
'deny' => ['X-Frame-Options' => 'DENY'],
'sameorigin' => ['X-Frame-Options' => 'SAMEORIGIN'],
default => throw new InvalidArgumentException("Mode tidak dikenal: $mode"),
};
}
foreach (['deny', 'sameorigin'] as $mode) {
foreach (frameGuardHeaders($mode) as $name => $value) {
echo "$name: $value\n";
}
}Output yang Dihasilkan:
X-Frame-Options: DENY
X-Frame-Options: SAMEORIGINMari kita telaah fungsi di atas:
match (strtolower($mode))memetakan mode ke nilai header secara eksplisit; input huruf besar maupun kecil diperlakukan sama berkatstrtolower().- Cabang
defaultmelemparInvalidArgumentExceptionketika mode tidak dikenal — kegagalan cepat lebih aman daripada memancarkan header kosong. - Nilai balik berupa array memisahkan keputusan keamanan dari pengiriman header, sehingga fungsi ini dapat diuji di CLI dan dipanggil dari route mana pun.
Pemakaian sesungguhnya di dalam router web terlihat seperti ini — kode memanggil header() sehingga perlu server nyata untuk melihat hasilnya:
<?php
// proteksi-router.php — gabungkan dengan fungsi frameGuardHeaders()
// di atas dalam satu berkas, lalu jalankan dengan php -S.
foreach (frameGuardHeaders('deny') as $name => $value) {
header("$name: $value");
}
header('Content-Type: text/html; charset=utf-8');
echo "<p>Halaman dashboard hanya boleh dibuka langsung.</p>";Perintah berikut menjalankan server pengembangan bawaan PHP lalu memeriksa header respons dengan curl -I:
php -S 127.0.0.1:8099 proteksi-router.php
curl -I http://127.0.0.1:8099/Transcript nyata dari sesi pengujian tersebut menunjukkan header proteksi telah terkirim:
HTTP/1.1 200 OK
Host: 127.0.0.1:8099
Date: Tue, 13 Oct 2026 16:03:30 GMT
Connection: close
X-Powered-By: PHP/8.4.23
X-Frame-Options: DENY
Content-Type: text/html; charset=utf-8Baris Date akan berbeda setiap detik dan X-Powered-By muncul karena server pengembangan PHP menambahkannya secara otomatis; intinya adalah baris X-Frame-Options: DENY yang kini hadir di setiap respons. Ingat bahwa php -S hanya untuk pengembangan — di produksi header sebaiknya dipancarkan dari middleware atau konfigurasi server.
frame-ancestors sebagai Standar Modern #
X-Frame-Options punya dua keterbatasan: nilainya hanya satu (tidak bisa menyetujui beberapa origin mitra sekaligus) dan tidak ada mekanisme pelaporan pelanggaran. Directive Content Security Policy bernama frame-ancestors menjawab keduanya — daftar origin dipisah spasi, misalnya 'self' https://widget.example.com. Pada browser modern, keberadaan frame-ancestors membuat browser mengabaikan X-Frame-Options; praktik terbaiknya adalah mengirim keduanya sekaligus agar browser lama tetap terlindungi.
Fungsi berikut mengembalikan pasangan header untuk dua skenario umum:
<?php
function clickjackingProtection(array $allowedAncestors = []): array
{
if ($allowedAncestors === []) {
return [
'X-Frame-Options' => 'DENY',
'Content-Security-Policy' => "frame-ancestors 'none'",
];
}
return [
'X-Frame-Options' => 'SAMEORIGIN',
'Content-Security-Policy' => 'frame-ancestors ' . implode(' ', $allowedAncestors),
];
}
echo "Halaman privat (tidak boleh di-embed):\n";
foreach (clickjackingProtection() as $name => $value) {
echo " $name: $value\n";
}
echo "Halaman widget untuk situs mitra:\n";
foreach (clickjackingProtection(["'self'", 'https://widget.example.com']) as $name => $value) {
echo " $name: $value\n";
}Output yang Dihasilkan:
Halaman privat (tidak boleh di-embed):
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Halaman widget untuk situs mitra:
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self' https://widget.example.comMari kita bedah logika fungsi tersebut:
- Daftar kosong (
[]) berarti tidak ada origin yang diizinkan —frame-ancestors 'none'dipasangkan denganX-Frame-Options: DENY, kombinasi paling ketat untuk halaman privat seperti dashboard. implode(' ', $allowedAncestors)merangkai daftar origin menjadi satu nilai directive;'self'ditulis dengan tanda kutip karena merupakan kata kunci CSP, bukan nama host.- Fallback
SAMEORIGINdipilih saat ada daftar mitra karenaX-Frame-Optionsmemang tidak mampu menyatakan banyak origin; ketelitian penuh tetap dipegang olehframe-ancestors.
Kelemahan Framebusting JavaScript Lawas #
Sebelum header proteksi populer, banyak situs memakai teknik framebusting — JavaScript yang memaksa halaman keluar dari frame:
<script>
// Teknik lawas: pindahkan jendela teratas bila halaman berada di dalam iframe
if (top !== self) {
top.location = self.location;
}
</script>Teknik ini bekerja dengan mengganti lokasi jendela teratas agar pengguna “dilempar” keluar dari halaman penyerang. Sayangnya, pertahanan tersebut mudah dilumpuhkan: penyerang cukup menambahkan atribut sandbox="allow-scripts" pada iframe — tanpa token allow-top-navigation, browser memblokir penugasan top.location dan skrip gagal secara diam-diam. Selain itu, JavaScript bisa dimatikan oleh pengguna atau ekstensi browser, sementara pertahanan berbasis header dibaca sebelum render sehingga tidak bisa dilewati.
Menguji Proteksi di Lapangan #
Proteksi baru bermakna bila terbukti bekerja, dan ada tiga cara cepat memeriksanya. Pertama, jalankan curl -I https://situs-anda.example/ dan pastikan kedua header muncul pada respons — cara ini persis seperti transcript php -S di atas. Kedua, buka DevTools di bagian Network, klik salah satu permintaan dokumen, lalu periksa kolom Response Headers pada panel detail. Ketiga, buat berkas HTML lokal berisi <iframe src="https://situs-anda.example/">; browser yang menerima DENY atau frame-ancestors 'none' akan menolak merender iframe dan menampilkan pesan kesalahan pada konsol.
Untuk pemeriksaan menyeluruh, pemindai daring securityheaders.com memberi nilai huruf untuk seluruh set header keamanan Anda — bukan hanya proteksi clickjacking. Set header lain seperti X-Frame-Options yang baru saja kita bahas merupakan bagian dari set yang lebih besar; chapter berikutnya merangkai seluruhnya menjadi satu middleware keamanan siap pakai, sementara pembahasan CSP lengkap menanti di chapter Content Security Policy
.
Ringkasan #
Clickjacking memanfaatkan iframe transparan untuk membelokkan klik pengguna ke halaman Anda yang asli, dengan cookie sesi yang membuat aksi tersebut sah. Pertahanannya bukan JavaScript, melainkan header respons: X-Frame-Options untuk browser lama dan frame-ancestors pada CSP untuk browser modern, keduanya dipancarkan dari PHP melalui fungsi murni yang mudah diuji. Dua header ini baru sebagian kecil dari set keamanan HTTP yang lengkap — mari kita susun set tersebut pada chapter Security Headers
.
Referensi #
- OWASP — Clickjacking
- MDN — X-Frame-Options
- MDN — Content-Security-Policy: frame-ancestors
- php.net — header()
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
contoh.php
Unduh berkas<?php
/* contoh.php - proteksi clickjacking dalam satu berkas.
* Jalankan dengan: php contoh.php
*/
function clickjackingProtection(array $allowedAncestors = []): array
{
if ($allowedAncestors === []) {
return [
'X-Frame-Options' => 'DENY',
'Content-Security-Policy' => "frame-ancestors 'none'",
];
}
return [
'X-Frame-Options' => 'SAMEORIGIN',
'Content-Security-Policy' => 'frame-ancestors ' . implode(' ', $allowedAncestors),
];
}
echo "Respons untuk halaman yang tidak boleh di-embed sama sekali:\n";
foreach (clickjackingProtection() as $name => $value) {
echo " $name: $value\n";
}
echo "Respons untuk halaman yang boleh di-embed situs mitra:\n";
foreach (clickjackingProtection(["'self'", 'https://widget.example.com']) as $name => $value) {
echo " $name: $value\n";
}