Perlindungan CSRF untuk Aplikasi PHP Kecil - Jangan Hanya Mengandalkan Session Cookie
Sebuah form pengaturan bisa mewajibkan login yang valid, tetapi tetap menerima request yang tidak pernah dimaksudkan oleh pengguna. Session cookie memberi tahu server session browser mana yang mengirim request, tetapi belum tentu menjelaskan halaman mana yang membuat browser mengirimkannya. Bagaimana aplikasi PHP kecil dapat membedakan form miliknya sendiri dari request palsu?
Inilah masalah di balik Cross-Site Request Forgery, yang biasanya disingkat CSRF. Risiko ini paling relevan ketika browser secara otomatis membawa credential, seperti PHP session cookie, sementara sebuah endpoint mengubah sesuatu: alamat email, password, post, permission, atau pengaturan tagihan. Tujuan pembahasan ini lebih sempit daripada “menyelesaikan keamanan web.” Tujuannya adalah menambahkan satu bukti eksplisit ke request yang mengubah state, lalu mengelilinginya dengan kontrol yang gagal secara aman.
Cookie mengautentikasi session, bukan asal request
Bayangkan seorang pengguna sudah login ke account.example. Di tab lain, halaman yang dikendalikan pihak lain mengirim form ke https://account.example/profile/email. Browser sejak lama mengizinkan form HTML dikirim lintas origin. Bergantung pada atribut cookie dan konteks request, browser mungkin menyertakan session cookie pengguna. Jika endpoint penerima hanya memeriksa cookie tersebut dan field form yang mudah ditebak, request palsu dapat dianggap sebagai request yang memang dimaksudkan.
MDN menjelaskan tiga kondisi dalam kasus klasik: request mengubah state server, cookie menjadi satu-satunya bukti untuk mengenali pengguna, dan parameter request lainnya dapat ditebak. Penyerang tidak perlu membaca response agar tindakan yang tidak diinginkan itu tetap berdampak.
Perbedaan ini mudah terlewat karena autentikasi memang berhasil. Aplikasi mengenali session dengan benar, lalu menjawab pertanyaan yang keliru. “Siapa yang sedang login?” tidak sama dengan “Apakah request ini datang melalui alur yang diterbitkan aplikasi kita?”
Pertama, inventarisasi semua route yang mengubah state
Helper token tidak melindungi apa pun jika satu endpoint lama masih dapat melewatinya. Mulailah dengan daftar route yang membuat, memperbarui, menghapus, menerbitkan, mengunggah, mengundang, login, logout, atau memicu efek samping lain. Sertakan handler lama, bulk action, dan endpoint JavaScript, bukan hanya form yang terlihat.
Perubahan state tidak seharusnya bersembunyi di balik GET. RFC 9110 mendefinisikan GET, HEAD, OPTIONS, dan TRACE sebagai safe methods, artinya semantik yang didefinisikan untuk method tersebut pada dasarnya bersifat read-only. URL seperti /post/delete?id=42 tidak menjadi aman hanya karena disebut action link. Crawler, preview, prefetcher, dan elemen lintas situs dapat mengikuti URL. Gunakan unsafe method seperti POST untuk sebuah perubahan, lalu validasi perlindungan CSRF di server.
POST bukan perlindungan dengan sendirinya. Halaman berbahaya dapat mengirim form HTML lintas origin biasa menggunakan POST. Method menjelaskan sifat operasi; mekanisme lain harus memastikan bahwa request tersebut merupakan bagian dari alur aplikasi yang sah.
Gunakan synchronizer token untuk form berbasis session
Untuk aplikasi stateful, OWASP CSRF Prevention Cheat Sheet merekomendasikan pola synchronizer token. Server membuat nilai rahasia yang tidak dapat diprediksi, menyimpannya di session pengguna, dan menaruh salinannya dalam form yang sah. Saat form dikirim, aplikasi mengharuskan kedua nilai itu cocok.
Session cookie berjalan mengikuti aturan cookie browser. Hidden token berjalan karena aplikasi sengaja menaruhnya di halaman. Situs lain dapat mencoba mengirim nama field yang sama, tetapi same-origin policy biasanya mencegahnya membaca halaman yang sah untuk mengetahui nilainya. Dengan demikian, token menambahkan bukti bahwa request melewati konten yang dibuat untuk session ini.
Buat dan tampilkan token
Contoh native PHP berikut memakai satu token untuk satu session. Contoh ini mengasumsikan konfigurasi session yang aman dan session_start() sudah dijalankan dalam bootstrap bersama.
function csrfToken(): string
{
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
$token = htmlspecialchars(
csrfToken(),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
Manual PHP mendokumentasikan random_bytes() sebagai sumber random bytes yang aman secara kriptografis. Raw bytes dapat berisi karakter yang tidak cocok untuk ditampilkan langsung, sehingga bin2hex() mengubahnya menjadi string yang lebih aman untuk dibawa. Tiga puluh dua byte adalah contoh yang masuk akal, bukan aturan yang diwajibkan setiap standar aplikasi.
Tampilkan nilai yang sudah di-encode di dalam setiap form yang dilindungi:
<form method="post" action="/profile/email">
<input type="hidden" name="csrf_token" value="<?= $token ?>">
<input type="email" name="email" required>
<button type="submit">Update email</button>
</form>
Output encoding tetap tepat meskipun nilai ini dibuat oleh server. Langkah tersebut membuat batas HTML tetap eksplisit dan mencegah perubahan representasi token di masa depan diam-diam menciptakan masalah markup.
Tolak sebelum menjalankan tindakan
Validasi harus dilakukan sebelum penulisan database, pengiriman email, atau efek samping lainnya:
function requireValidCsrfToken(): void
{
$known = $_SESSION['csrf_token'] ?? null;
$submitted = $_POST['csrf_token'] ?? null;
if (!is_string($known)
|| !is_string($submitted)
|| !hash_equals($known, $submitted)
) {
http_response_code(403);
exit('Invalid request token');
}
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
http_response_code(405);
exit('Method not allowed');
}
requireValidCsrfToken();
// Authorize the user, validate the new email, then update it.
Pemeriksaan type membuat nilai yang tidak ada gagal secara tertutup dan mencegah input array yang tidak diharapkan masuk ke fungsi string. Manual PHP menjelaskan hash_equals() sebagai perbandingan yang timing-safe dan mengharuskan nilai server yang diketahui menjadi argumen pertama serta nilai dari pengguna menjadi argumen kedua. Jangan mencatat salah satu token ke log ketika validasi gagal.
Token yang valid tetap bukan authorization. Handler harus memeriksa secara terpisah apakah pengguna ini boleh mengedit profil tersebut dan apakah data yang dikirim dapat diterima. Validasi CSRF menjawab satu pertanyaan tentang integritas request; mekanisme ini tidak boleh menjadi jalan pintas untuk melewati access control.
Pilih lifecycle token dengan sengaja
OWASP mengizinkan token per request atau per session. Rotasi per request mempersempit peluang reuse, tetapi dapat merusak alur tombol back dan beberapa tab yang terbuka. Token per session lebih sederhana untuk aplikasi kecil dan tetap berguna ketika nilainya rahasia, tidak dapat diprediksi, selalu diperiksa, dan diganti mengikuti batas kepercayaan session.
Buat token baru setelah login atau transisi session penting lainnya, terutama jika sebelumnya sudah ada session anonim. Hapus token saat session dihancurkan. Jika seorang pengguna dapat memiliki beberapa login session independen, setiap session sebaiknya memiliki nilainya sendiri. Pilihan ini membuat token mengikuti authenticated session, bukan record pengguna yang permanen.
Jangan menaruh synchronizer token di URL. URL dapat muncul di riwayat browser, access log, analytics, pesan yang disalin, dan informasi referrer. Gunakan hidden form field, JSON body, atau custom request header sesuai kebutuhan. Hidden field tidak dimaksudkan agar tidak terlihat oleh pengguna; sifat keamanannya berasal dari origin lain yang biasanya tidak dapat membaca halaman terlindungi.
Halaman yang memuat token juga membutuhkan cache policy yang dipertimbangkan. Shared cache tidak boleh memberikan form bertoken milik satu pengguna kepada pengguna lain. Ini merupakan bagian dari perlindungan authenticated page secara umum, tetapi CSRF token membuat konsekuensinya sangat jelas.
Tambahkan pertahanan cookie dan konteks request tanpa memercayai satu flag
Session cookie biasanya perlu memakai Secure, HttpOnly, scope host/path yang sempit, dan nilai SameSite yang dipilih dengan sadar. MDN menyebut SameSite=Lax atau Strict sebagai pertahanan CSRF parsial. Strict mencegah lebih banyak penyertaan cookie lintas situs, tetapi dapat mengganggu kedatangan yang sah dari situs lain; Lax mempertahankan lebih banyak perilaku navigasi dan perlindungannya lebih lemah.
Same-site juga lebih luas daripada same-origin. Subdomain yang bersaudara dapat termasuk same-site meskipun berbeda origin, sehingga desain yang menganggap semua subdomain sama-sama tepercaya perlu menyatakannya secara eksplisit. Pertahankan SameSite sebagai defense in depth, bukan menghapus pemeriksaan token karena cookie memiliki satu atribut yang terlihat menjanjikan.
Browser modern dapat mengirim Fetch Metadata headers. Secara khusus, Sec-Fetch-Site menjelaskan request sebagai same-origin, same-site, cross-site, atau none. Server dapat menolak unsafe request yang ditandai cross-site sebelum mencapai business logic. Namun, dokumen W3C Fetch Metadata yang dirujuk masih berstatus Working Draft, dan aplikasi nyata mungkin menghadapi client lama, intermediary, alur lintas origin yang memang disengaja, atau header yang tidak ada. Terapkan policy semacam ini dengan observasi, exception yang eksplisit, dan fallback, bukan dengan asumsi bahwa header selalu tersedia.
Form, JSON API, dan webhook tidak memakai satu aturan yang sama
Untuk JavaScript client, token dapat dibawa dalam custom header seperti X-CSRF-Token. Custom header dan non-simple content type membuat browser menerapkan pemeriksaan same-origin dan CORS, yang dapat membantu. Namun, credentialed CORS yang terlalu longgar dapat membuka kembali batas tersebut, sehingga “ini memakai JSON” bukan argumen keamanan yang lengkap.
Tidak semua endpoint POST seharusnya memakai CSRF token berbasis browser session. Webhook datang dari server lain dan membutuhkan autentikasi sendiri, sering kali berupa signature yang ditentukan provider. Public API yang memakai bearer credential eksplisit dan tidak dipasang otomatis oleh browser memiliki threat model berbeda. Sebaliknya, API yang melakukan autentikasi dengan cookie tetap membutuhkan desain CSRF meskipun response-nya berupa JSON. Pilih kontrol berdasarkan cara credential berpindah, bukan dari ekstensi file atau label framework.
Uji jalur yang ditolak, bukan hanya happy path
Test plan yang aman dapat memverifikasi perilaku tanpa menargetkan sistem milik pihak lain:
- Kirim form terlindungi dengan token session yang valid dan pastikan tindakan yang dimaksud berhasil.
- Hapus token, ganti dengan nilai acak, lalu kirim array sebagai pengganti string; setiap request harus gagal sebelum efek samping terjadi.
- Kirim GET ke setiap route yang mengubah state dan pastikan hasilnya method error, bukan perubahan data.
- Logout lalu replay token dari test session yang sudah berakhir; token itu seharusnya tidak lagi diterima.
- Buka dua form di tab terpisah dan pastikan rotation policy yang dipilih berperilaku sesuai dokumentasi.
- Periksa response cookie dan pastikan pengaturan Secure, HttpOnly, SameSite, host, serta path sesuai dengan desain.
- Jika filtering Fetch Metadata diaktifkan, amati client yang sah terlebih dahulu dan uji setiap exception lintas origin yang didokumentasikan.
Catat route, hasil, dan alasan yang tidak sensitif seperti “missing token” atau “mismatch,” tetapi jangan tokennya. Penolakan dapat berarti serangan, halaman kedaluwarsa, tab lama, atau bug implementasi. Log membantu investigasi; log tidak membuktikan niat dengan sendirinya.
Pahami apa yang tidak diperbaiki perlindungan CSRF
Cross-site scripting dapat merusak pertahanan CSRF karena script yang berjalan di trusted origin mungkin dapat membaca token atau mengirim request melalui konteks aplikasi sendiri. Karena itu, validasi token tidak menggantikan output encoding, Content Security Policy yang baik, atau kontrol XSS lainnya. Mekanisme ini juga tidak memperbaiki authorization yang rusak, session yang dicuri, pemulihan password yang tidak aman, atau operasi yang seharusnya membutuhkan autentikasi baru.
Untuk tindakan dengan konsekuensi sangat besar, meminta password saat ini, WebAuthn assertion, atau interaksi pengguna eksplisit lainnya mungkin tepat. Ambang yang benar bergantung pada kerugian yang dapat ditimbulkan tindakan tersebut. Preferensi blog dan penghapusan account tidak pantas diberi friction yang sama.
Wajibkan dua bukti yang independen
Aplikasi PHP kecil tidak perlu berpura-pura bahwa satu token membuat setiap request tepercaya. Aplikasi membutuhkan batas yang konsisten: tindakan tidak aman tidak memakai GET, form yang diautentikasi dengan session membawa token server-side yang tidak dapat diprediksi, handler menolak nilai yang hilang atau tidak cocok sebelum efek samping, dan authorization tetap berjalan sesudahnya.
Pembatasan cookie dan Fetch Metadata dapat memperkuat batas itu, tetapi keterbatasannya harus tetap terlihat. Pertanyaan yang berguna bukan “Apakah perlindungan CSRF sudah diaktifkan?” melainkan “Dapatkah setiap route yang mengubah state menjelaskan bukti independen apa yang diwajibkan, dan apakah request sudah diuji ketika bukti itu tidak ada?”
