Kodeloka
  1. Kodeloka
  2. Kelas PHP
  3. Materi

3 · Web & Keamanan Dasar · Modul 16 dari 25

Perlindungan Cross-Site Request Forgery (CSRF), random_bytes(), dan Pertahanan Berlapis

Membedah anatomi serangan Cross-Site Request Forgery (CSRF), mengimplementasikan Synchronizer Token Pattern dengan random_bytes() dan hash_equals(), memanfaatkan header Sec-Fetch-Site / Origin, serta melindungi endpoint AJAX/fetch() modern.

1. Memahami Anatomi Serangan CSRF secara Mendalam

Cross-Site Request Forgery (CSRF) adalah serangan yang memaksa peramban pengguna yang sedang terautentikasi (sudah login) untuk mengirimkan permintaan pengubahan data yang tidak disengaja ke aplikasi web Anda.

Mengapa peramban mau mengirimkan cookie sesi milik situs Anda ketika pemicunya berasal dari situs luar jahat.example? Karena secara bawaan, mekanisme HTTP Cookie menempel pada domain tujuan permintaan, bukan domain tempat formulir HTML itu berada:

1. Korban sudah login di https://kodeloka.example (Memiliki Cookie Sesi aktif)
2. Korban membuka tab baru ke https://kuis-berhadiah.example (Situs milik penyerang)
3. Halaman penyerang diam-diam memuat formulir tersembunyi:
   <form action="https://kodeloka.example/akun/ganti-surel" method="POST">
     <input type="hidden" name="surel_baru" value="[email protected]">
   </form>
   <script>document.forms[0].submit();</script>
4. Jika /akun/ganti-surel tidak memeriksa token CSRF (dan cookie tidak terlindungi),
   server mengira permintaan itu benar-benar dikirim oleh korban!

Aturan Pertama Pencegahan CSRF: Jangan Pernah Mengubah Data pada Permintaan GET!

Permintaan HTTP GET (dan HEAD / OPTIONS) **wajib bersifat Safe (Hanya Baca / tidak mengubah state di server)! Jika Anda membuat tautan <a href="/catatan/hapus?id=5">Hapus</a> yang langsung menghapus data lewat metode GET, maka atribut cookie SameSite=Lax sekalipun tidak akan bisa melindungi Anda (karena navigasi tautan tingkat atas GET tetap menyertakan cookie SameSite=Lax; dan pada cookie yang tidak dibatasi SameSite, penyerang cukup memasang <img src="https://situs-anda.id/catatan/hapus?id=5">)! Semua aksi yang menambah, mengubah, atau menghapus data wajib menggunakan metode POST, PUT, PATCH, atau DELETE**.

2. Pertahanan Utama: Synchronizer Token Pattern (random_bytes + hash_equals)

Kenapa penyerang di kuis-berhadiah.example bisa mengirimkan formulir ke situs Anda, tetapi tidak bisa menyertakan Token CSRF rahasia milik korban? Karena Same-Origin Policy (SOP) di peramban melarang keras skrip di kuis-berhadiah.example membaca isi halaman HTML dari kodeloka.example!

Berikut adalah implementasi kelas PelindungCsrf yang lengkap, mendukung rotasi token, dan siap dipakai untuk formulir tradisional maupun permintaan fetch() AJAX:

<?php

declare(strict_types=1);

namespace Kodeloka\Keamanan;

use RuntimeException;

final class PelindungCsrf
{
    private const string KUNCI_SESI = '_kodeloka_csrf_token';

    public function ambilToken(): string
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            throw new RuntimeException('Sesi harus aktif sebelum membuat token CSRF.');
        }

        if (empty($_SESSION[self::KUNCI_SESI]) || !is_string($_SESSION[self::KUNCI_SESI])) {
            // 32 byte acak kriptografis = 64 karakter heksadesimal (256-bit entropi)
            $_SESSION[self::KUNCI_SESI] = bin2hex(random_bytes(32));
        }

        return $_SESSION[self::KUNCI_SESI];
    }

    public function rotasiToken(): string
    {
        unset($_SESSION[self::KUNCI_SESI]);
        return $this->ambilToken();
    }

    /**
     * Memeriksa token CSRF baik dari input form ($_POST['_csrf'])
     * maupun dari header khusus permintaan fetch/AJAX (X-CSRF-Token).
     */
    public function verifikasiPermintaan(): bool
    {
        $tokenSesi = $_SESSION[self::KUNCI_SESI] ?? '';
        if (!is_string($tokenSesi) || $tokenSesi === '') {
            return false;
        }

        // Ambil dari $_POST['_csrf'] atau dari header HTTP 'X-CSRF-Token'
        $tokenMasukan = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
        if (!is_string($tokenMasukan) || $tokenMasukan === '') {
            return false;
        }

        // Perbandingan waktu-konstan untuk mencegah timing attack
        return hash_equals($tokenSesi, $tokenMasukan);
    }

    public function cetakInputHidden(): string
    {
        $tokenAman = htmlspecialchars($this->ambilToken(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        return sprintf('<input type="hidden" name="_csrf" value="%s">', $tokenAman);
    }
}

Bagaimana Mengirimkan Token CSRF pada Permintaan fetch() di JavaScript?

Untuk halaman yang mengirim data menggunakan fetch() JSON tanpa <form>, sematkan token CSRF di dalam tag <meta name="csrf-token" content="<?= e($csrf->ambilToken()) ?>"> di <head>, lalu baca dan kirimkan melalui header X-CSRF-Token:

// Di sisi front-end JavaScript:
const tokenCsrf = document.querySelector('meta[name="csrf-token"]')?.content ?? "";

const respons = await fetch("/api/catatan/simpan", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": tokenCsrf
  },
  body: JSON.stringify({ judul: "Belajar Keamanan CSRF" })
});

(Keuntungan tambahan: Situs luar tidak diizinkan oleh peramban untuk menambahkan header kustom X-CSRF-Token pada permintaan lintas-origin tanpa izin preflight CORS!)

3. Pertahanan Berlapis Modern: Memeriksa Header Sec-Fetch-Site dan Origin

Selain Token CSRF dan atribut cookie SameSite=Lax, semua peramban modern kini otomatis mengirimkan header Fetch Metadata (Sec-Fetch-Site) yang tidak dapat diubah oleh kode JavaScript penyerang (forbidden header name):

  • Sec-Fetch-Site: same-origin (Permintaan berasal dari domain Anda sendiri — Aman).
  • Sec-Fetch-Site: none (Pengguna mengetik langsung di bilah alamat).
  • Sec-Fetch-Site: cross-site (Permintaan dipicu dari situs luar!).

Anda dapat menambahkan pemeriksaan ringan ini sebagai lapis pertahanan tambahan untuk seluruh permintaan POST/PUT/DELETE:

<?php

declare(strict_types=1);

function pastikanBukanLintasSitus(): void
{
    $secFetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';

    // Jika peramban modern melaporkan bahwa permintaan POST/DELETE ini berasal dari 'cross-site',
    // langsung tolak dengan 403 Forbidden!
    if ($secFetchSite !== '' && $secFetchSite !== 'same-origin' && $secFetchSite !== 'none') {
        http_response_code(403);
        exit('Permintaan lintas situs (Cross-Site) ditolak oleh kebijakan keamanan.');
    }
}

Latihan Praktik: Middleware Pemeriksa CSRF dan Fetch Metadata

Buatlah berkas MiddlewareCsrf.php:

  1. Buat fungsi periksaKeamananMutasi() yang langsung meloloskan metode baca (GET, HEAD, OPTIONS), tetapi untuk metode mutasi (POST, PUT, PATCH, DELETE) memeriksa header HTTP_SEC_FETCH_SITE sekaligus memverifikasi token CSRF menggunakan PelindungCsrf::verifikasiPermintaan().
  2. Pastikan ketika pengguna berhasil login atau logout, PelindungCsrf::rotasiToken() ikut dipanggil agar token CSRF terikat pada sesi yang baru!