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:
| Ancaman | Pertahanan | Sejak |
|---|---|---|
| SQL injection | prepared statements, nilai tidak pernah disisipkan ke string SQL | Bagian 3 |
| XSS pada output | helper e() (htmlspecialchars + ENT_QUOTES) di setiap nilai dinamis | Bagian 1 |
| Halaman admin terbuka | authentication + authorization lewat Auth::jaga() | Bagian 5 |
| Ubah data lewat GET | semua aksi tulis lewat POST + pola PRG | Bagian 4 |
| Kata sandi bocor | bcrypt password_hash/password_verify, pesan galat generik | Bagian 5 |
| CSRF pada formulir | token sesi random_bytes + hash_equals — bagian ini | baru |
| Input tak terkirim | validasi terpusat: judul wajib+maks, slug format+unik, konten wajib | naik 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
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
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:
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
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
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:
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) : falseEnam 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
// 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:
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
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:
validasi : lolos (teks bebas memang sah)
tersimpan: judul mentah = ya (disimpan apa adanya)
<h2><script>alert("XSS")</script></h2>
<p>Paragraf dengan <b>markup</b> & tanda kutip "ganda".</p>
<p><a href="/">← 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
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
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
// 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:
# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8096 public/index.php# 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'HTTP/1.1 303 See Other
Location: /adminFormulir 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):
$ 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>"/' <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:
$ 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>'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:
$ 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>'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:
$ 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"token diambil dari form: panjang 64 karakter$ 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'HTTP/1.1 303 See Other
Location: /admin?flash=simpan&id=4Dan 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):
$ 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'HTTP/1.1 422 Unknown Status Code
<br><small class="galat">Slug sudah dipakai artikel lain.</small>$ curl -sS -b kuki.txt http://127.0.0.1:8096/admin | grep '<h1>' <h1>Daftar Artikel (4)</h1>Formulir hapus ikut diamankan — baris yang sama dipasang di dalam <form> kecilnya:
$ 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 <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 pengecualianabaikanIduntuk mode ubah, konten wajib.src/Csrf.php—token()berbasisrandom_bytes(32)(64 karakter hex, satu per sesi),field()pembuat input tersembunyi,check()denganhash_equals().src/AdminController.php—jalankan()menolak POST tanpa token sah (403) sebelum dispatch;simpanAtauForm()memakaiValidator; methodvalidasi()privat dihapus.templates/admin/post-form.phpdantemplates/admin/posts.php—<?= App\Csrf::field() ?>di setiap<form>.- Audit escaping: 28 echo pendek di seluruh template, semuanya lolos kecuali slot
$kontenlayout 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):
# 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.phpDua 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
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
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];
}
}