Bagian 5 berhasil mengunci pintu admin — tapi pintu yang terkunci tetap punya celah di jendela formulirnya. Dua kelemahan masih terbuka. Pertama, validasi form masih hidup sebagai method privat AdminController; slug duplikat baru ketahuan setelah INSERT melempar PDOException, dan judul sepanjang apa pun diterima. Kedua, dan ini yang lebih serius: seluruh POST admin belum membawa token CSRF, sehingga halaman jahat di tab lain bisa mengirim POST ke blog kita atas nama pengguna yang sedang login — cookie sesinya ikut terkirim otomatis oleh browser. Pada Bagian 6 ini kita mengerasi keduanya dengan dua class baru: Validator yang menaikkan validasi menjadi unit mandiri yang bisa diuji, dan Csrf yang memasang token rahasia pada setiap formulir admin.

Peta Pertahanan Sejauh Ini #

Sebelum menambah pertahanan baru, mari petakan yang sudah terpasang — keamanan adalah hasil bertumpuknya lapisan-lapisan kecil, bukan satu tembok ajaib:

AncamanPertahananSejak
SQL injectionprepared statements, nilai tidak pernah disisipkan ke string SQLBagian 3
XSS pada outputhelper e() (htmlspecialchars + ENT_QUOTES) di setiap nilai dinamisBagian 1
Halaman admin terbukaauthentication + authorization lewat Auth::jaga()Bagian 5
Ubah data lewat GETsemua aksi tulis lewat POST + pola PRGBagian 4
Kata sandi bocorbcrypt password_hash/password_verify, pesan galat generikBagian 5
CSRF pada formulirtoken sesi random_bytes + hash_equals — bagian inibaru
Input tak terkirimvalidasi terpusat: judul wajib+maks, slug format+unik, konten wajibnaik kelas

Dua baris terakhir adalah pekerjaan kita sekarang. Perhatikan bahwa XSS dan SQL injection sudah tertangani sejak awal — bukan karena kita menambahnya di akhir, tetapi karena kebiasaan baiknya ditanam sejak Bagian 1 dan 3. Bagian ini mengaudit ulang kebiasaan itu secara sistematis, lalu menutup celah yang tersisa.

Validator: Validasi Naik Kelas #

Method validasi() privat pada AdminController bergerak keluar menjadi class sendiri dengan tiga peningkatan: batas panjang judul, pemeriksaan keunikan slug sebelum INSERT, dan parameter abaikanId untuk mode ubah. Berikut src/Validator.php lengkap:

php
<?php

declare(strict_types=1);

namespace App;

/**
 * Validator — validasi input formulir artikel (Bagian 6).
 *
 * Evolusi dari method privat AdminController::validasi() pada Bagian 4
 * menjadi class tersendiri dengan tiga peningkatan:
 * - batas panjang judul (mencegah judul kacau panjang ribuan karakter);
 * - slug diperiksa bentuk DAN keunikannya terhadap database, sehingga
 *   duplikat tertolak sebelum INSERT — pesan dari validator, bukan
 *   dari PDOException yang ditangkap belakangan;
 * - parameter abaikanId untuk mode ubah: slug yang dipertahankan oleh
 *   artikelnya sendiri tidak dihitung duplikat.
 *
 * Kontrak sama seperti Bagian 4: mengembalikan pasangan [post bersih,
 * peta galat]. Peta kosong berarti data layak disimpan.
 */
final class Validator
{
    /** Batas panjang judul dalam karakter. */
    public const JUDUL_MAKS = 120;

    public function __construct(private readonly PostRepository $posts)
    {
    }

    /**
     * Validasi data formulir artikel.
     *
     * @param array<string,mixed> $data      isi $_POST mentah
     * @param int|null            $abaikanId id artikel saat mode ubah
     *
     * @return array{0: array<string,string>, 1: array<string,string>}
     *         pasangan [post bersih, peta galat per kolom]
     */
    public function validate(array $data, ?int $abaikanId = null): array
    {
        $post = [
            'slug' => trim((string) ($data['slug'] ?? '')),
            'judul' => trim((string) ($data['judul'] ?? '')),
            'konten' => trim((string) ($data['konten'] ?? '')),
            'dibuat_pada' => trim((string) ($data['dibuat_pada'] ?? '')),
        ];

        $galat = [];

        if ($post['judul'] === '') {
            $galat['judul'] = 'Judul wajib diisi.';
        } elseif (mb_strlen($post['judul']) > self::JUDUL_MAKS) {
            $galat['judul'] = 'Judul maksimal ' . self::JUDUL_MAKS . ' karakter '
                . '(sekarang ' . mb_strlen($post['judul']) . ').';
        }

        if ($post['slug'] === '') {
            $galat['slug'] = 'Slug wajib diisi.';
        } elseif (preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $post['slug']) !== 1) {
            $galat['slug'] = 'Slug hanya boleh huruf kecil, angka, dan tanda hubung '
                . 'di antara kata (bukan di awal/akhir).';
        } else {
            $lain = $this->posts->cariSlug($post['slug']);

            if ($lain !== null && (int) $lain['id'] !== $abaikanId) {
                $galat['slug'] = 'Slug sudah dipakai artikel lain.';
            }
        }

        if ($post['konten'] === '') {
            $galat['konten'] = 'Konten wajib diisi.';
        }

        if ($post['dibuat_pada'] === '') {
            $post['dibuat_pada'] = date('Y-m-d H:i:s'); // diisi saat disimpan
        }

        return [$post, $galat];
    }
}

Tiga keputusan di dalamnya perlu dibedah. Pertama, mb_strlen() — bukan strlen() — mengukur judul, karena judul boleh berisi karakter apa pun dan “é” harus dihitung satu karakter, bukan dua byte. Kedua, pola slug ^[a-z0-9]+(?:-[a-z0-9]+)*$ lebih ketat dari versi Bagian 4: tanda hubung hanya boleh di antara kata, sehingga -awal- dan dua--hubung ikut ditolak. Ketiga, cabang keunikan dijalankan hanya setelah format lolos — query database tidak perlu disia-siakan untuk slug yang jelas-jelas cacat bentuknya. Kelima kasus berikut membuktikan seluruh cabangnya (class dari Bagian 3 dimuat lewat path folder saudara, seperti pada Bagian 4):

php
<?php

require __DIR__ . '/../bagian-3-database-dan-persistensi/src/Database.php';
require __DIR__ . '/../bagian-3-database-dan-persistensi/src/PostRepository.php';
require __DIR__ . '/src/Validator.php';

use App\Database;
use App\PostRepository;
use App\Validator;

$posts = new PostRepository(Database::siapkan(':memory:'));
$validator = new Validator($posts);

$kasus = [
    'lengkap dan bersih' => [
        'judul' => 'Belajar CSRF dari Nol',
        'slug' => 'belajar-csrf-dari-nol',
        'konten' => 'Token rahasia menjadikan POST hanya sah dari form kita sendiri.',
        'dibuat_pada' => '2026-01-27 08:00:00',
    ],
    'judul kosong + slug aneh + konten kosong' => [
        'judul' => '',
        'slug' => 'Slug Ilegal!',
        'konten' => '',
    ],
    'judul kepanjangan' => [
        'judul' => str_repeat('a', 130),
        'slug' => 'terlalu-panjang',
        'konten' => 'isi',
    ],
    'slug duplikat artikel seed' => [
        'judul' => 'Artikel Baru',
        'slug' => 'kenapa-php-masih-relevan',
        'konten' => 'isi',
    ],
    'slug sama, mode ubah artikelnya sendiri' => [
        'judul' => 'Judul Revisi',
        'slug' => 'kenapa-php-masih-relevan',
        'konten' => 'isi revisi',
    ],
];

foreach ($kasus as $nama => $data) {
    [, $galat] = $validator->validate($data, $nama === 'slug sama, mode ubah artikelnya sendiri' ? 1 : null);
    echo $nama, ":\n";
    if ($galat === []) {
        echo "    (tidak ada galat)\n";
    }
    foreach ($galat as $kolom => $pesan) {
        echo '    ', $kolom, ' => ', $pesan, "\n";
    }
}

Output yang Dihasilkan:

text
lengkap dan bersih:
    (tidak ada galat)
judul kosong + slug aneh + konten kosong:
    judul => Judul wajib diisi.
    slug => Slug hanya boleh huruf kecil, angka, dan tanda hubung di antara kata (bukan di awal/akhir).
    konten => Konten wajib diisi.
judul kepanjangan:
    judul => Judul maksimal 120 karakter (sekarang 130).
slug duplikat artikel seed:
    slug => Slug sudah dipakai artikel lain.
slug sama, mode ubah artikelnya sendiri:
    (tidak ada galat)

Kasus terakhir adalah yang paling halus: pada mode ubah artikel id 1, slug kenapa-php-masih-relevan ditemukan di database tapi pemiliknya adalah artikel yang sedang diedit sendiri — $abaikanId membuatnya lolos, karena mempertahankan slug lama bukanlah duplikat. Bandingkan dengan Bagian 4: kasus ini dulu berakhir sebagai PDOException SQLSTATE 23000 yang diterjemahkan belakangan; kini ia adalah pesan galat yang dirender form di tempat yang sama dengan galat lain.

Token CSRF: Rahasia yang Hanya Diketahui Form Kita #

Serangan CSRF memanfaatkan fakta bahwa browser mengirim cookie otomatis: halaman jahat.example bisa memuat formulir tersembunyi yang POST ke blog-kita.example/admin/tulis, dan cookie sesi admin yang masih hidup membuat request itu tampak sah. Kita tidak bisa mencegah cookie terkirim — tapi kita bisa menuntut bukti kedua yang situs lain tidak mungkin punya: token rahasia yang disimpan di sesi pengguna dan disisipkan ke setiap form. Kebijakan same-origin browser mencegah halaman lain membaca isi form kita, sehingga tebakan token 64 karakter hex praktis mustahil. Berikut src/Csrf.php lengkap:

php
<?php

declare(strict_types=1);

namespace App;

/**
 * Csrf — token sinkronisasi untuk formulir (Bagian 6).
 *
 * Serangan CSRF (Cross-Site Request Forgery) memaksa browser korban
 * mengirim POST ke aplikasi kita dari situs lain; cookie sesi ikut
 * terkirim otomatis sehingga request tampak sah. Pertahanannya:
 * tiap formulir menyimpan token rahasia yang hanya diketahui sesi
 * pengguna, dan tiap POST diperiksa ulang token-nya — situs lain
 * tidak bisa membaca token itu karena kebijakan same-origin browser.
 *
 * Token dibuat dari random_bytes(32) (sumber acak kriptografis) dan
 * disimpan di $_SESSION['csrf']; satu token dipakai sepanjang umur
 * sesi sehingga tab banyak dan navigasi maju-mundur tetap aman.
 */
final class Csrf
{
    /** Token aktif untuk sesi ini — dibuat pada panggilan pertama. */
    public static function token(): string
    {
        self::mulaiSesi();

        if (empty($_SESSION['csrf'])) {
            $_SESSION['csrf'] = bin2hex(random_bytes(32));
        }

        return (string) $_SESSION['csrf'];
    }

    /**
     * Field input tersembunyi siap sisip di dalam <form>.
     *
     * Nilai token hanya berisi hex (a-f, 0-9) sehingga aman untuk
     * atribut, tetap di-escape demi kebiasaan yang seragam.
     */
    public static function field(): string
    {
        return '<input type="hidden" name="csrf_token" value="'
            . htmlspecialchars(self::token(), ENT_QUOTES, 'UTF-8') . '">';
    }

    /**
     * Periksa token kiriman terhadap token sesi.
     *
     * hash_equals() membandingkan dua string dalam waktu konstan —
     * perbandingan === biasa berhenti di byte pertama yang berbeda,
     * membocorkan seberapa mirip tebakan penyerang (timing attack).
     */
    public static function check(?string $token): bool
    {
        self::mulaiSesi();

        $aktif = (string) ($_SESSION['csrf'] ?? '');

        return $aktif !== '' && $token !== null && hash_equals($aktif, $token);
    }

    /** Pastikan sesi aktif — id sesi satu kali perlu dipasang pemanggil. */
    private static function mulaiSesi(): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }
    }
}

Tiga methodnya membentuk siklus satu putaran: token() membuat rahasia itu sekali per sesi, field() menyisipkannya ke HTML sebagai input tersembunyi, dan check() memverifikasi kiriman POST. Karena random_bytes() menghasilkan nilai acak setiap eksekusi, demo CLI hanya mencetak fakta struktur token — panjang, jenis karakter, dan perilaku — bukan nilainya:

php
<?php

require __DIR__ . '/src/Csrf.php';

use App\Csrf;

// Sesi deterministik untuk demo CLI: id tetap SEBELUM sesi mulai.
if (session_status() === PHP_SESSION_ACTIVE) {
    $_SESSION = [];
    session_destroy();
}
session_id('fixed-bagian6-csrf');

// --- operasi sesi dan token selesai sebelum output pertama ---

$token = Csrf::token();
$tokenLagi = Csrf::token(); // panggilan kedua: token sesi yang sama

$field = Csrf::field();
$polanya = '/^<input type="hidden" name="csrf_token" value="([0-9a-f]{64})">$/';
preg_match($polanya, $field, $cocok);

$cekBenar = Csrf::check($token);
$cekSalah = Csrf::check(str_repeat('0', 64));
$cekKosong = Csrf::check(null);

// --- struktur token dicetak, nilainya tidak ---

echo 'token              : ', strlen($token), ' karakter, hanya hex = ',
    ctype_xdigit($token) ? 'ya' : 'tidak', "\n";
echo 'panggilan kedua    : ', $token === $tokenLagi ? 'token sama (dipakai ulang)' : 'token baru', "\n";
echo 'bentuk field()     : ',
    str_replace($cocok[1], '<64-karakter-hex>', $field), "\n";
echo 'check(token benar) : ', $cekBenar ? 'true' : 'false', "\n";
echo 'check(token salah) : ', $cekSalah ? 'true' : 'false', "\n";
echo 'check(null)        : ', $cekKosong ? 'true' : 'false', "\n";

Output yang Dihasilkan:

text
token              : 64 karakter, hanya hex = ya
panggilan kedua    : token sama (dipakai ulang)
bentuk field()     : <input type="hidden" name="csrf_token" value="<64-karakter-hex>">
check(token benar) : true
check(token salah) : false
check(null)        : false

Enam baris itu merangkum kontrak class-nya: token selalu 64 karakter hex, stabil sepanjang sesi (dipakai ulang, bukan dibuang tiap request), dan check() menerima jawaban true hanya dari token yang persis sama. Satu token per sesi — bukan per request — adalah pilihan sadar: pengguna dengan dua tab admin tetap bisa menyimpan dari keduanya tanpa halaman “token kedaluwarsa”.

Audit Escaping pada Template #

Pertahanan XSS kita berdiri di atas satu kebiasaan: setiap <?= ... ?> di template harus melewati e(). Kelemahan kebiasaan adalah ia bisa dilanggar tanpa disadari — maka mari jadikan mesin. Cuplikan berikut memindai seluruh template proyek (bundle Bagian 1 sampai 5), mengklasifikasi setiap echo pendek, dan melaporkan yang tidak lolos:

php
<?php

// Audit escaping: pindai SEMUA template proyek (bundle Bagian 1-5) dan
// pastikan setiap echo pendek melewati helper yang meng-escape.

$berkas = array_merge(
    glob(__DIR__ . '/../bagian-*/templates/*.php'),
    glob(__DIR__ . '/../bagian-*/templates/admin/*.php'),
);
sort($berkas);

// Bentuk echo pendek yang sudah aman secara struktur.
$polaAman = ['e(', 'old(', 'count(', '(int)'];
// Slot fragmen HTML terpercaya di layout — raw by design (lihat Bagian 1).
$rawDisengaja = ['$konten'];

$total = $lolos = 0;
$temuan = [];

foreach ($berkas as $file) {
    $isi = (string) file_get_contents($file);
    preg_match_all('/<\?=\s*(.*?)\s*\?>/S', $isi, $cocok, PREG_SET_ORDER);

    $nama = basename(dirname($file)) === 'admin'
        ? 'templates/admin/' . basename($file)
        : 'templates/' . basename($file);
    $amanDiFile = 0;

    foreach ($cocok as $m) {
        $total++;
        $ekspresi = $m[1];

        if (in_array($ekspresi, $rawDisengaja, true)) {
            $temuan[] = [$nama, $ekspresi, 'raw disengaja (fragmen HTML terpercaya)'];
            continue;
        }

        $aman = false;
        foreach ($polaAman as $awalan) {
            if (str_starts_with($ekspresi, $awalan)) {
                $aman = true;
                break;
            }
        }

        if ($aman) {
            $lolos++;
            $amanDiFile++;
        } else {
            $temuan[] = [$nama, $ekspresi, 'RAW — perlu e()'];
        }
    }

    printf("%-32s %2d echo pendek, %2d lolos audit\n", $nama, count($cocok), $amanDiFile);
}

printf("\nTOTAL: %d echo pendek, %d lolos, %d temuan\n", $total, $lolos, count($temuan));
foreach ($temuan as [$nama, $ekspresi, $status]) {
    printf("  %-32s <?= %s ?>  -> %s\n", $nama, $ekspresi, $status);
}

Output yang Dihasilkan:

text
templates/home.php                4 echo pendek,  4 lolos audit
templates/layout.php              2 echo pendek,  1 lolos audit
templates/post.php                3 echo pendek,  3 lolos audit
templates/admin/post-form.php    10 echo pendek, 10 lolos audit
templates/admin/posts.php         7 echo pendek,  7 lolos audit
templates/login.php               2 echo pendek,  2 lolos audit

TOTAL: 28 echo pendek, 27 lolos, 1 temuan
  templates/layout.php             <?= $konten ?>  -> raw disengaja (fragmen HTML terpercaya)

Hasilnya: 28 echo pendek di seluruh proyek, 27 lolos, dan satu-satunya temuan justru <?= $konten ?> di layout — slot fragmen HTML yang memang dipakai mentah sejak Bagian 1 karena isinya hasil render template lain, bukan data pengguna. Audit seperti ini layak dijalankan setiap kali template baru ditulis; pada proyek nyata ia menjadi satu test PHPUnit yang gagal keras setiap kali ada <?= $nilai mentah ?> lolos.

Lalu bagaimana dengan data yang memang jahat tapi sah secara format? Judul <script>alert("XSS")</script> lolos validasi — dan memang harus lolos, karena judul bebas teks. Jalur lengkapnya bisa diperiksa dari CLI: validasi meloloskan, repository menyimpan mentah, template menampilkan ter-escape:

php
<?php

require __DIR__ . '/../bagian-3-database-dan-persistensi/src/Database.php';
require __DIR__ . '/../bagian-3-database-dan-persistensi/src/PostRepository.php';
require __DIR__ . '/src/Validator.php';

use App\Database;
use App\PostRepository;
use App\Validator;

function render(string $template, array $data = []): string
{
    extract($data, EXTR_SKIP);
    ob_start();
    require __DIR__ . '/../bagian-2-post-dan-detail-artikel/templates/' . $template;

    return (string) ob_get_clean();
}

$posts = new PostRepository(Database::siapkan(':memory:'));
$validator = new Validator($posts);

// Kiriman "jahat" seolah dari form admin — validasinya memang meloloskan,
// karena judul dan konten dibebaskan teks apa pun.
$kiriman = [
    'judul' => '<script>alert("XSS")</script>',
    'slug' => 'artikel-berbahaya',
    'konten' => 'Paragraf dengan <b>markup</b> & tanda kutip "ganda".',
    'dibuat_pada' => '2026-01-27 09:30:00',
];

[$post, $galat] = $validator->validate($kiriman);
echo 'validasi : ', $galat === [] ? 'lolos (teks bebas memang sah)' : 'ditolak', "\n";

$posts->simpan($post);
$tersimpan = $posts->cariSlug('artikel-berbahaya');
echo 'tersimpan: judul mentah = ', $tersimpan['judul'] === $kiriman['judul']
    ? 'ya (disimpan apa adanya)' : 'tidak', "\n";

// Tampilkan lewat template yang meng-escape setiap output dinamis.
$html = render('post.php', ['post' => $tersimpan]);
foreach (explode("\n", $html) as $baris) {
    $baris = trim($baris);
    if (str_starts_with($baris, '<h2>') || str_starts_with($baris, '<p>')) {
        echo $baris, "\n";
    }
}

Output yang Dihasilkan:

text
validasi : lolos (teks bebas memang sah)
tersimpan: judul mentah = ya (disimpan apa adanya)
<h2>&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;</h2>
<p>Paragraf dengan &lt;b&gt;markup&lt;/b&gt; &amp; tanda kutip &quot;ganda&quot;.</p>
<p><a href="/">&larr; Kembali ke beranda</a></p>

Merakit di Front Controller #

Dua perubahan kecil menuntup semuanya. Pertama, AdminController::jalankan() mendapat penjaga POST di baris paling atas — sebelum dispatch ke method mana pun:

php
<?php

declare(strict_types=1);

namespace App;

use PDOException;

final class AdminController
{
    // ... constructor seperti Bagian 4 ...

    public function jalankan(array $request): array
    {
        $path = (string) ($request['path'] ?? '/admin');
        $method = strtoupper((string) ($request['method'] ?? 'GET'));

        // Bagian 6: POST tanpa token CSRF yang sah ditolak di pintu.
        if ($method === 'POST') {
            $token = isset($request['post']['csrf_token'])
                ? (string) $request['post']['csrf_token']
                : null;

            if (!Csrf::check($token)) {
                return $this->halaman(
                    '<h1>403 — Token CSRF Tidak Valid</h1>'
                    . '<p>Muat ulang formulirnya lalu kirim sekali lagi.</p>'
                    . '<p><a href="/admin">Kembali ke daftar artikel</a>.</p>',
                    403
                );
            }
        }

        // ... dispatch method/path seperti Bagian 4 ...
    }
}

Kedua, simpanAtauForm() menukar method privat lama dengan Validator — termasuk abaikanId yang dulu tidak ada — sementara catch (PDOException) tetap berdiri sebagai jaring pengaman bila dua request lolos validasi nyaris bersamaan (race yang hanya bisa ditahan constraint database):

php
<?php

declare(strict_types=1);

namespace App;

final class AdminController
{
    // ... jalankan() dengan penjaga CSRF di atas ...

    private function simpanAtauForm(array $data, ?int $id): array
    {
        [$post, $galat] = (new Validator($this->posts))->validate($data, $id);
        $aksi = $id === null ? '/admin/tulis' : '/admin/edit/' . $id;
        $mode = $id === null ? 'tulis' : 'ubah';

        // ... sisanya sama persis dengan Bagian 4: redirect 303 saat
        //     $galat kosong, form ulang 422 saat ada galat, dan
        //     catch (PDOException) sebagai lapisan terakhir ...
    }
}

Di sisi template, kedua formulir admin tinggal menyisipkan satu baris setelah tag pembuka <form> — helper old() dan e() yang sudah ada tidak tersentuh. Potongan berikut adalah bagian atas templates/admin/post-form.php pada keadaan baru:

php
<?php

// templates/admin/post-form.php — Bagian 6: formulir kini bertoken.
// (Helper e() dan old() beserta nilai bawaan variabel tidak berubah.)

$post ??= null;
$galat ??= [];
$aksi ??= '/admin/tulis';
$mode ??= 'tulis';
?>
        <form method="post" action="<?= e($aksi) ?>">
            <?= App\Csrf::field() ?>
            <p>
                <label for="judul">Judul</label><br>

Formulir hapus di templates/admin/posts.php mendapat baris yang sama persis di dalam <form> kecilnya. Perhatikan pola pemanggilan App\Csrf::field() langsung dari template: karena helper ini statis, murni, dan mengembalikan string, ia aman dipanggil dari lapisan presentasi tanpa menyeret dependensi ke sana.

Menguji dengan Web Server Bawaan #

Rakit ulang proyek lengkap (Bagian 1-6), buat admin seperti biasa, nyalakan server, lalu login — curl menyimpan cookie sesi di kuki.txt:

bash
# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8096 public/index.php
bash
# terminal 2
$ curl -sS -i -c kuki.txt -X POST -d "[email protected]" \
    -d "sandi=sandi-admin-2026" http://127.0.0.1:8096/login | grep -E 'HTTP/|^Location'
text
HTTP/1.1 303 See Other
Location: /admin

Formulir tulis kini membawa input tersembunyi (nilai token disamarkan oleh sed agar layak dicetak di halaman ini — di terminal Anda nilai 64 karakter hex akan tampil utuh):

bash
$ curl -sS -b kuki.txt http://127.0.0.1:8096/admin/tulis \
    | grep -E 'action="/admin/tulis"|csrf_token' \
    | sed 's/value="[a-f0-9]\{64\}"/value="<token-64-hex>"/'
text
        <form method="post" action="/admin/tulis">
            <input type="hidden" name="csrf_token" value="<token-64-hex>">            <p>

Ujian pertama: POST tanpa token — persis yang dikirim halaman jahat yang tidak bisa membaca form kita:

bash
$ curl -sS -i -b kuki.txt -X POST -d "judul=Serangan CSRF" -d "slug=serangan-csrf" \
    -d "konten=isi" http://127.0.0.1:8096/admin/tulis | grep -E 'HTTP/|<h1>'
text
HTTP/1.1 403 Forbidden
<h1>403 — Token CSRF Tidak Valid</h1><p>Muat ulang formulirnya lalu kirim sekali lagi.</p><p><a href="/admin">Kembali ke daftar artikel</a>.</p>

Token palsu bernasib sama — hash_equals() tidak memberi ruang tebak-tebakan:

bash
$ curl -sS -i -b kuki.txt -X POST -d "csrf_token=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \
    -d "judul=Serangan CSRF" -d "slug=serangan-csrf" -d "konten=isi" \
    http://127.0.0.1:8096/admin/tulis | grep -E 'HTTP/|<h1>'
text
HTTP/1.1 403 Forbidden
<h1>403 — Token CSRF Tidak Valid</h1><p>Muat ulang formulirnya lalu kirim sekali lagi.</p><p><a href="/admin">Kembali ke daftar artikel</a>.</p>

Sekarang alur yang benar: ambil token dari form, kirim bersama data valid:

bash
$ TOKEN=$(curl -sS -b kuki.txt http://127.0.0.1:8096/admin/tulis \
    | grep -o 'name="csrf_token" value="[a-f0-9]*"' | cut -d'"' -f4)
$ echo "token diambil dari form: panjang ${#TOKEN} karakter"
text
token diambil dari form: panjang 64 karakter
bash
$ curl -sS -i -b kuki.txt -X POST -d "csrf_token=$TOKEN" \
    --data-urlencode "judul=Menambahkan Token CSRF" \
    --data-urlencode "slug=menambahkan-token-csrf" \
    --data-urlencode "konten=Formulir admin kini menuntut token rahasia dari sesinya sendiri." \
    --data-urlencode "dibuat_pada=2026-01-27 10:00:00" \
    http://127.0.0.1:8096/admin/tulis | grep -E 'HTTP/|^Location'
text
HTTP/1.1 303 See Other
Location: /admin?flash=simpan&id=4

Dan galat validasi dari Validator kini tampil di form tanpa pernah menyentuh database (web server bawaan PHP menampilkan frasa Unknown Status Code untuk 422; artinya tetap 422):

bash
$ curl -sS -i -b kuki.txt -X POST -d "csrf_token=$TOKEN" -d "judul=Artikel Kembar" \
    -d "slug=menambahkan-token-csrf" -d "konten=isi" \
    http://127.0.0.1:8096/admin/tulis | grep -E 'HTTP/|galat'
text
HTTP/1.1 422 Unknown Status Code
                <br><small class="galat">Slug sudah dipakai artikel lain.</small>
bash
$ curl -sS -b kuki.txt http://127.0.0.1:8096/admin | grep '<h1>'
text
        <h1>Daftar Artikel (4)</h1>

Formulir hapus ikut diamankan — baris yang sama dipasang di dalam <form> kecilnya:

bash
$ curl -sS -b kuki.txt http://127.0.0.1:8096/admin \
    | grep -A1 'action="/admin/hapus"' \
    | sed 's/value="[a-f0-9]\{64\}"/value="<token-64-hex>"/' | head -n 4
text
                    <form method="post" action="/admin/hapus" style="display: inline">
                        <input type="hidden" name="csrf_token" value="<token-64-hex>">                        <input type="hidden" name="id" value="4">
--
                    <form method="post" action="/admin/hapus" style="display: inline">

Empat artikel tersimpan (artikel id 4 yang baru ditambahkan tampil paling atas karena terbaru), satu-satunya POST yang diterima adalah yang membawa token sah, dan pesan “Slug sudah dipakai artikel lain.” kini berasal dari Validator — bukan dari exception database. Pemisah -- adalah cara grep -A1 menandai jeda antar pasangan baris yang cocok. Matikan server dengan Ctrl+C setelah selesai mencoba.

Apa yang Berubah #

Dibandingkan Bagian 5, tahap ini menambahkan dua berkas dan mengubah tiga titik:

  • src/Validator.php — validasi artikel sebagai class mandiri: judul wajib + maksimal 120 karakter (mb_strlen), slug format ketat + unik terhadap database dengan pengecualian abaikanId untuk mode ubah, konten wajib.
  • src/Csrf.php — token() berbasis random_bytes(32) (64 karakter hex, satu per sesi), field() pembuat input tersembunyi, check() dengan hash_equals().
  • src/AdminController.php — jalankan() menolak POST tanpa token sah (403) sebelum dispatch; simpanAtauForm() memakai Validator; method validasi() privat dihapus.
  • templates/admin/post-form.php dan templates/admin/posts.php — <?= App\Csrf::field() ?> di setiap <form>.
  • Audit escaping: 28 echo pendek di seluruh template, semuanya lolos kecuali slot $konten layout yang memang raw by design.

Blog ini kini punya lapisan keamanan yang lengkap untuk ukuran proyek latihan: prepared statements, escaping output, authentication, authorization, dan CSRF. Yang belum ada adalah kebiasaan paling berharga dalam pengembangan profesional — membuktikan bahwa kode ini tetap benar setelah diubah, secara otomatis. Bagian 7 menutup proyek dengan PHPUnit untuk testing dan Dockerfile untuk deployment.

Cara Menjalankan #

Dari direktori bagian-6-validasi-dan-keamanan/ (folder bundle halaman ini):

bash
# 1. Validasi dan CSRF dari CLI murni — salin cuplikan pengujian di atas
#    ke berkas sementara lalu jalankan (memuat class Bagian 3 lewat path saudara)
php uji-validasi.php

# 2. Gabungkan dengan berkas Bagian 1-5, lalu nyalakan server
php buat-admin.php
php -S 127.0.0.1:8096 public/index.php
curl -sS -i -c kuki.txt -X POST -d "[email protected]" -d "sandi=sandi-admin-2026" http://127.0.0.1:8096/login

# 3. Pemeriksaan sintaks berkas baru
php -l src/Validator.php && php -l src/Csrf.php

Dua berkas pendukung halaman ini — src/Validator.php dan src/Csrf.php — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi tentang hash_equals() , random_bytes() , keamanan session , serta lembar OWASP tentang CSRF dan XSS . Dengan pertahanan yang terpasang lengkap, kita lanjut ke ritual penutup setiap proyek serius: menguji dan mengemasnya pada Bagian 7.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/Csrf.php

Unduh berkas
php
<?php

declare(strict_types=1);

namespace App;

/**
 * Csrf — token sinkronisasi untuk formulir (Bagian 6).
 *
 * Serangan CSRF (Cross-Site Request Forgery) memaksa browser korban
 * mengirim POST ke aplikasi kita dari situs lain; cookie sesi ikut
 * terkirim otomatis sehingga request tampak sah. Pertahanannya:
 * tiap formulir menyimpan token rahasia yang hanya diketahui sesi
 * pengguna, dan tiap POST diperiksa ulang token-nya — situs lain
 * tidak bisa membaca token itu karena kebijakan same-origin browser.
 *
 * Token dibuat dari random_bytes(32) (sumber acak kriptografis) dan
 * disimpan di $_SESSION['csrf']; satu token dipakai sepanjang umur
 * sesi sehingga tab banyak dan navigasi maju-mundur tetap aman.
 */
final class Csrf
{
    /** Token aktif untuk sesi ini — dibuat pada panggilan pertama. */
    public static function token(): string
    {
        self::mulaiSesi();

        if (empty($_SESSION['csrf'])) {
            $_SESSION['csrf'] = bin2hex(random_bytes(32));
        }

        return (string) $_SESSION['csrf'];
    }

    /**
     * Field input tersembunyi siap sisip di dalam <form>.
     *
     * Nilai token hanya berisi hex (a-f, 0-9) sehingga aman untuk
     * atribut, tetap di-escape demi kebiasaan yang seragam.
     */
    public static function field(): string
    {
        return '<input type="hidden" name="csrf_token" value="'
            . htmlspecialchars(self::token(), ENT_QUOTES, 'UTF-8') . '">';
    }

    /**
     * Periksa token kiriman terhadap token sesi.
     *
     * hash_equals() membandingkan dua string dalam waktu konstan —
     * perbandingan === biasa berhenti di byte pertama yang berbeda,
     * membocorkan seberapa mirip tebakan penyerang (timing attack).
     */
    public static function check(?string $token): bool
    {
        self::mulaiSesi();

        $aktif = (string) ($_SESSION['csrf'] ?? '');

        return $aktif !== '' && $token !== null && hash_equals($aktif, $token);
    }

    /** Pastikan sesi aktif — id sesi satu kali perlu dipasang pemanggil. */
    private static function mulaiSesi(): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }
    }
}

src/Validator.php

Unduh berkas
php
<?php

declare(strict_types=1);

namespace App;

/**
 * Validator — validasi input formulir artikel (Bagian 6).
 *
 * Evolusi dari method privat AdminController::validasi() pada Bagian 4
 * menjadi class tersendiri dengan tiga peningkatan:
 * - batas panjang judul (mencegah judul kacau panjang ribuan karakter);
 * - slug diperiksa bentuk DAN keunikannya terhadap database, sehingga
 *   duplikat tertolak sebelum INSERT — pesan dari validator, bukan
 *   dari PDOException yang ditangkap belakangan;
 * - parameter abaikanId untuk mode ubah: slug yang dipertahankan oleh
 *   artikelnya sendiri tidak dihitung duplikat.
 *
 * Kontrak sama seperti Bagian 4: mengembalikan pasangan [post bersih,
 * peta galat]. Peta kosong berarti data layak disimpan.
 */
final class Validator
{
    /** Batas panjang judul dalam karakter. */
    public const JUDUL_MAKS = 120;

    public function __construct(private readonly PostRepository $posts)
    {
    }

    /**
     * Validasi data formulir artikel.
     *
     * @param array<string,mixed> $data      isi $_POST mentah
     * @param int|null            $abaikanId id artikel saat mode ubah
     *
     * @return array{0: array<string,string>, 1: array<string,string>}
     *         pasangan [post bersih, peta galat per kolom]
     */
    public function validate(array $data, ?int $abaikanId = null): array
    {
        $post = [
            'slug' => trim((string) ($data['slug'] ?? '')),
            'judul' => trim((string) ($data['judul'] ?? '')),
            'konten' => trim((string) ($data['konten'] ?? '')),
            'dibuat_pada' => trim((string) ($data['dibuat_pada'] ?? '')),
        ];

        $galat = [];

        if ($post['judul'] === '') {
            $galat['judul'] = 'Judul wajib diisi.';
        } elseif (mb_strlen($post['judul']) > self::JUDUL_MAKS) {
            $galat['judul'] = 'Judul maksimal ' . self::JUDUL_MAKS . ' karakter '
                . '(sekarang ' . mb_strlen($post['judul']) . ').';
        }

        if ($post['slug'] === '') {
            $galat['slug'] = 'Slug wajib diisi.';
        } elseif (preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $post['slug']) !== 1) {
            $galat['slug'] = 'Slug hanya boleh huruf kecil, angka, dan tanda hubung '
                . 'di antara kata (bukan di awal/akhir).';
        } else {
            $lain = $this->posts->cariSlug($post['slug']);

            if ($lain !== null && (int) $lain['id'] !== $abaikanId) {
                $galat['slug'] = 'Slug sudah dipakai artikel lain.';
            }
        }

        if ($post['konten'] === '') {
            $galat['konten'] = 'Konten wajib diisi.';
        }

        if ($post['dibuat_pada'] === '') {
            $post['dibuat_pada'] = date('Y-m-d H:i:s'); // diisi saat disimpan
        }

        return [$post, $galat];
    }
}