Formulir yang diautentikasi dengan cookie rentan terhadap serangan Cross-Site Request Forgery (CSRF), yaitu permintaan palsu yang dikirim halaman lain atas nama pengguna yang sedang masuk. Browser melampirkan cookie pada setiap permintaan, sehingga server tidak dapat membedakan formulir asli dari formulir palsu hanya dengan cookie. Laravel menutup celah ini dengan token yang disisipkan pada setiap formulir dan diverifikasi oleh middleware pada setiap permintaan POST. Topik ini membahas direktif @csrf, cara token diverifikasi, pengecualian rute, dan error 419 yang muncul saat verifikasi gagal.
Cara kerja proteksi CSRF#
Laravel menghasilkan satu token CSRF untuk setiap session. Token disimpan pada session dan disisipkan ke formulir sebagai hidden input bernama _token. Saat permintaan POST tiba, middleware pada grup web membandingkan nilai _token dari permintaan dengan token yang tersimpan pada session. Verifikasi hanya lolos apabila permintaan berasal dari halaman yang dirender aplikasi itu sendiri, karena halaman dari luar tidak pernah mengetahui isi token.
Halaman milik penyerang tidak dapat membaca token, karena kebijakan same-origin browser melarang halaman dari domain lain membaca isi halaman atau cookie domain aplikasi. Penyerang tetap dapat mengirim formulir palsu, tetapi formulir itu hanya membawa _token yang ditebak. Selama token cukup panjang dan dihasilkan secara acak, permintaan palsu ditolak dengan status 419. Diagram berikut merangkum alur verifikasi pada setiap permintaan POST.
Direktif @csrf pada formulir#
Direktif @csrf adalah cara paling umum menambahkan token pada formulir Blade. Direktif ini merender csrf_field(), yaitu hidden input bernama _token yang nilainya diambil dari session aktif. Tanpa direktif tersebut, formulir terkirim tanpa token dan setiap pengirimannya ditolak.
Selain direktif, helper csrf_token() mengembalikan nilai token session aktif sehingga dapat ditempatkan secara manual, misalnya pada tag <meta> untuk dibaca JavaScript. Keluaran dari kedua cara itu sama, yaitu token milik session yang sedang aktif. Pilihan di antara keduanya bergantung pada tempat token dibutuhkan.
Formulir contoh berikut disiapkan pada proyek aplikasi catatan untuk menguji seluruh perilaku token.
# menyiapkan view formulir contoh pada proyek aplikasi catatan
cd ~/aplikasi-catatan
mkdir -p resources/views/uji-csrf
cat > resources/views/uji-csrf/formulir.blade.php <<'BLADE'
<form method="POST" action="/uji/csrf">
@csrf
<label for="judul">Judul</label>
<input id="judul" name="judul" type="text" value="{{ old('judul') }}">
<button type="submit">Simpan catatan</button>
</form>
BLADEMenguji token yang valid, keliru, dan hilang#
Skrip berikut menjalankan aplikasi contoh tanpa server web dan memasang middleware grup web, yaitu grup yang memuat middleware session beserta middleware CSRF. Empat langkahnya adalah membuka formulir, mengirim POST dengan token yang valid, mengirim POST tanpa token, dan mengirim POST dengan token yang tidak cocok. Nilai token asli disembunyikan pada keluaran karena berubah untuk setiap session.
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
$root = getcwd();
if (! is_file($root.'/bootstrap/app.php')) {
$root = getenv('HOME').'/aplikasi-catatan';
}
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
Route::middleware('web')->group(function () {
Route::view('/uji/csrf/formulir', 'uji-csrf.formulir');
Route::post('/uji/csrf', function (Request $request) {
$data = $request->validate(['judul' => ['required', 'string', 'max:20']]);
return redirect('/uji/csrf/formulir')
->with('sukses', 'Catatan tersimpan: '.$data['judul']);
});
});
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
function teruskanCookie($respons, Request $request): Request
{
foreach ($respons->headers->getCookies() as $cookie) {
$request->cookies->set($cookie->getName(), $cookie->getValue());
}
return $request;
}
// langkah 1: membuka formulir, lalu membaca hidden input _token hasil @csrf
$resp1 = $kernel->handle(Request::create('http://localhost/uji/csrf/formulir', 'GET'));
$html1 = $resp1->getContent();
preg_match('/<input type="hidden" name="_token"[^>]*>/', $html1, $cocok);
$barisToken = preg_replace('/value="[^"]+"/', 'value="(disamarkan)"', $cocok[0]);
preg_match('/name="_token" value="([^"]+)"/', $html1, $tok);
$tokenValid = $tok[1] ?? '';
echo '1. GET formulir: status '.$resp1->getStatusCode().PHP_EOL;
echo ' hidden input: '.$barisToken.PHP_EOL;
// langkah 2: POST dengan token session yang valid, diteruskan bersama cookie session
$req2 = Request::create('http://localhost/uji/csrf', 'POST', [
'judul' => 'Rapat pekanan',
'_token' => $tokenValid,
], [], [], ['HTTP_REFERER' => 'http://localhost/uji/csrf/formulir']);
teruskanCookie($resp1, $req2);
$resp2 = $kernel->handle($req2);
echo '2. POST token valid: status '.$resp2->getStatusCode().PHP_EOL;
// langkah 3: POST tanpa token, ditolak middleware
$req3 = Request::create('http://localhost/uji/csrf', 'POST', [
'judul' => 'Rapat pekanan',
], [], [], ['HTTP_REFERER' => 'http://localhost/uji/csrf/formulir']);
teruskanCookie($resp1, $req3);
$resp3 = $kernel->handle($req3);
echo '3. POST tanpa token: status '.$resp3->getStatusCode().PHP_EOL;
echo ' halaman error memuat teks 419: '.(str_contains($resp3->getContent(), '419') ? 'ya' : 'tidak').PHP_EOL;
// langkah 4: POST dengan token yang tidak cocok dengan session, ditolak juga
$req4 = Request::create('http://localhost/uji/csrf', 'POST', [
'judul' => 'Rapat pekanan',
'_token' => str_repeat('x', 40),
], [], [], ['HTTP_REFERER' => 'http://localhost/uji/csrf/formulir']);
teruskanCookie($resp1, $req4);
$resp4 = $kernel->handle($req4);
echo '4. POST token keliru: status '.$resp4->getStatusCode().PHP_EOL;1. GET formulir: status 200
hidden input: <input type="hidden" name="_token" value="(disamarkan)" autocomplete="off">
2. POST token valid: status 302
3. POST tanpa token: status 419
halaman error memuat teks 419: ya
4. POST token keliru: status 419Langkah pertama memperlihatkan hasil render @csrf berupa hidden input _token. Langkah kedua lolos karena token dikirim bersama cookie session yang sama, dan pengalihannya menuju formulir dengan pesan sukses. Langkah ketiga dan keempat dijawab dengan status 419 karena tokennya tidak ada dan tidak cocok, tanpa memandang data yang dikirim valid atau tidak. Keempat langkah itu berlangsung tanpa server web, karena middleware dijalankan oleh kernel HTTP yang sama dengan saat aplikasi melayani permintaan sungguhan.
Pengecualian rute dan token pada header#
Sebagian endpoint memang tidak mungkin membawa token formulir, misalnya webhook dari layanan pihak ketiga. Rute semacam itu didaftarkan sebagai pengecualian melalui metode validateCsrfTokens() pada bootstrap/app.php, dan pengecualian dibatasi hanya pada rute tersebut agar proteksi tetap menyeluruh.
// tidak-dijalankan
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'webhook/pembayaran',
]);
})Permintaan dari JavaScript pada halaman yang sama tidak menggunakan hidden input, melainkan header. Header X-CSRF-TOKEN diisi token mentah dari csrf_token(), sedangkan header X-XSRF-TOKEN diisi dengan nilai cookie XSRF-TOKEN yang terenkripsi, yang biasanya dikirim oleh library frontend seperti Axios setelah membaca cookie tersebut. Keduanya diperiksa oleh middleware pada titik yang sama dengan _token.
Kesalahan umum#
Kesalahan pertama adalah melupakan @csrf pada satu formulir. Setiap pengirimannya dijawab 419 meskipun data valid, dan ciri khasnya adalah formulir tertentu saja yang gagal sementara formulir lain normal. Periksa keberadaan direktif tersebut atau hidden input _token di dalam tag form bila 419 muncul.
Kesalahan kedua adalah mengirim formulir lama setelah session berganti. Token disimpan pada session, sehingga session yang berakhir atau dibuat ulang, misalnya setelah login, membuat token pada halaman yang masih terbuka tidak cocok lagi. Solusinya adalah memuat ulang halaman formulir agar token baru ikut terrender.
Kesalahan ketiga adalah menghapus middleware CSRF dari grup web agar error 419 tidak muncul lagi. Langkah itu menonaktifkan proteksi untuk seluruh aplikasi, sedangkan kebutuhan nyata biasanya hanya satu atau dua endpoint. Daftarkan pengecualian yang terbatas melalui validateCsrfTokens() dan biarkan middleware tetap terpasang.
Ringkasan#
- Laravel menghasilkan satu token CSRF per session, menyisipkannya melalui
@csrf, dan memverifikasinya melalui middleware pada grupwebuntuk setiap permintaan POST. - Token pada halaman penyerang tidak pernah terbaca karena kebijakan same-origin browser, sehingga permintaan palsu kekurangan token yang cocok.
- POST tanpa token atau dengan token keliru dijawab 419, sedangkan token valid meneruskan permintaan ke aksinya.
- Endpoint tertentu seperti webhook dikeluarkan melalui
validateCsrfTokens(), dan permintaan JavaScript menggunakan headerX-CSRF-TOKENatauX-XSRF-TOKEN.
Referensi#
- CSRF Protection — Laravel — Dokumentasi middleware CSRF, direktif @csrf, header token, dan pengecualian rute.
- Blade Templates — Laravel — Dokumentasi direktif @csrf dan @method yang dipakai pada formulir Blade.