Lewati ke konten

CodeIgniter 4 Shieldv1.2.x

Registrasi & Urutan Filter

Halaman ini menjelaskan apa yang sebenarnya didaftarkan oleh ShieldServiceProvider, kapan filter Shield berjalan, dan bagaimana membuat request 404/scanner tetap diperiksa sebelum router tanpa memeriksa request dua kali.

ShieldServiceProvider::register() bukan hanya memasang filter. Ia mendaftarkan:

Yang didaftarkan Keterangan
shield.resolver Resolver konfigurasi, engine, cache, dan challenge.
shield.config Konfigurasi Shield yang sudah dibangun.
shield.engine Mesin keputusan Shield.
shield.cache Adapter cache CodeIgniter.
shield.challenge Driver challenge (Turnstile, reCAPTCHA, atau null).
Alias shield.firewall Alias untuk SecurityFirewallFilter.
globals['before'][] = 'shield.firewall' Penempatan filter default.
Command shield:* Hanya jika aplikasi punya Config\Commands; jika tidak, CodeIgniter menemukannya lewat PSR-4 package.

Memanggil provider berulang aman: service dibangun ulang dari konfigurasi terkini. Tanpa provider, komponen Shield masih punya fallback internal, tetapi alias filter, service bersama, dan penempatan filter default tidak terpasang.

Urutan eksekusi filter di CodeIgniter 4 adalah:

required['before'] → routing → globals['before'] → controller
→ globals['after'] → required['after']

Konsekuensinya penting:

  • required['before'] berjalan sebelum router. Filter di sini tetap dijalankan meskipun tidak ada route yang cocok, termasuk untuk /.env, /wp-login.php, dan path acak.
  • globals['before'] berjalan setelah router. Filter di sini hanya melihat request yang berhasil di-route. Request ke path tanpa route sudah berhenti di PageNotFoundException sebelum filter sempat berjalan.
  • required['after'] tetap berjalan untuk response 404. Ini yang dipakai untuk menghitung not_found pada path yang tidak punya route.

Registrasi tanpa gate (tidak disarankan untuk aplikasi publik)

Section titled “Registrasi tanpa gate (tidak disarankan untuk aplikasi publik)”

Registrasi paling sederhana adalah memanggil provider di app/Config/Events.php:

Events::on('pre_system', static function (): void {
\Ganadev\Shield\Codeigniter\ShieldServiceProvider::register();
});

Dengan cara ini Shield berjalan di globals['before'] — perilaku bawaan paket. Ini hanya cocok kalau:

  • aplikasi tidak menghadap publik langsung, atau
  • path scanner sudah ditangani WAF/edge di depan server.

Batasan yang harus Anda sadari: /.env, path traversal, dan scanner path acak tidak diperiksa oleh Shield karena router sudah lebih dulu melempar 404. Karena itu Instalasi memakai pola gate di bawah.

Untuk aplikasi publik, pola yang dipakai di Instalasi adalah memindahkan Shield ke required['before'] lewat filter gate. Shield tetap memeriksa semua request, tetapi halaman publik yang normal tidak menambah behavior counter.

Provider tetap wajib dipanggil karena ia mendaftarkan service, alias, dan konfigurasi bersama.

2. Hapus shield.firewall dari global filter

Section titled “2. Hapus shield.firewall dari global filter”

Provider menambahkan shield.firewall ke globals['before']. Kalau gate juga dipasang di required, request akan diperiksa dua kali dan counter bisa naik dua kali. Hapus penempatan global itu setelah provider dipanggil:

Events::on('pre_system', static function (): void {
\Ganadev\Shield\Codeigniter\ShieldServiceProvider::register();
/*
* Provider memasang Shield sebagai filter global, yang berjalan setelah
* routing. Kita memakai App\Filters\ShieldGateFilter di required filter
* agar path tanpa route juga diperiksa. Hapus yang global supaya request
* tidak diperiksa dua kali.
*/
$filters = config('Filters');
$filters->globals['before'] = array_values(array_filter(
$filters->globals['before'] ?? [],
static fn ($alias): bool => $alias !== 'shield.firewall',
));
});

Kalau app/Config/Events.php Anda sudah berisi kode lain, gabungkan potongan di atas dengan isi yang sudah ada; jangan menimpa seluruh file.

Buat app/Filters/ShieldGateFilter.php:

<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Ganadev\Shield\Codeigniter\Cache\CodeIgniterCacheAdapter;
use Ganadev\Shield\Codeigniter\Filters\SecurityFirewallFilter;
/**
* Menjalankan Shield sebelum router, tetapi mencegah kunjungan normal ke
* halaman publik menambah behavior counter. Ban dan challenge yang sudah ada
* tetap ditegakkan.
*/
final class ShieldGateFilter implements FilterInterface
{
private ?SecurityFirewallFilter $firewall = null;
public function before(RequestInterface $request, $arguments = null): ?ResponseInterface
{
if (! $this->isPublicPage($request)) {
return $this->firewall()->before($request, $arguments);
}
// Shield menaikkan counter unique/path sebelum memutuskan. Simpan nilai
// lama agar kenaikan itu bisa dibatalkan untuk page view normal.
$snapshot = $this->snapshotCounters($request);
$result = $this->firewall()->before($request, $arguments);
// Kalau Shield mengizinkan, kembalikan counter. Kalau block/challenge,
// biarkan kenaikannya agar histori tetap tercatat.
if ($result === null) {
$this->restoreCounters($snapshot);
}
return $result;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
if ($this->isPublicPage($request) || $response->hasHeader('X-Shield-Blocked')) {
return;
}
$this->firewall()->after($request, $response, $arguments);
}
private function firewall(): SecurityFirewallFilter
{
return $this->firewall ??= new SecurityFirewallFilter();
}
private function isPublicPage(RequestInterface $request): bool
{
// Hanya page view normal. POST/PUT/PATCH/DELETE selalu diperiksa penuh
// agar submit form tidak bisa dipakai untuk melewati Shield.
if (! in_array(strtoupper($request->getMethod()), ['GET', 'HEAD'], true)) {
return false;
}
$path = $this->normalizePath($request);
$paths = config('Shield')->gateSkipPaths ?? ['/', '/health'];
return in_array($path, $paths, true);
}
/**
* @return array{uniqueKey: string, pathKey: string, unique: mixed, path: mixed}|null
*/
private function snapshotCounters(RequestInterface $request): ?array
{
$cache = $this->shieldCache();
if ($cache === null) {
return null;
}
$ip = $request->getIPAddress();
$normalizedPath = strtolower((string) preg_replace('#/{2,}#', '/', $this->normalizePath($request)));
$uniqueKey = "counters:{$ip}:unique";
$pathKey = 'counters:'.$ip.':path:'.hash('sha256', $normalizedPath);
return [
'uniqueKey' => $uniqueKey,
'pathKey' => $pathKey,
'unique' => $cache->get($uniqueKey),
'path' => $cache->get($pathKey),
];
}
/**
* @param array{uniqueKey: string, pathKey: string, unique: mixed, path: mixed}|null $snapshot
*/
private function restoreCounters(?array $snapshot): void
{
if ($snapshot === null) {
return;
}
$cache = $this->shieldCache();
if ($cache === null) {
return;
}
$ttl = (int) (config('Shield')->behaviorWindowSeconds ?? 60);
if ($snapshot['unique'] === null) {
$cache->delete($snapshot['uniqueKey']);
} else {
$cache->set($snapshot['uniqueKey'], (int) $snapshot['unique'], $ttl);
}
if ($snapshot['path'] === null) {
$cache->delete($snapshot['pathKey']);
} else {
$cache->set($snapshot['pathKey'], (int) $snapshot['path'], $ttl);
}
}
private function shieldCache(): ?CodeIgniterCacheAdapter
{
try {
$cache = service('shield.cache');
return $cache instanceof CodeIgniterCacheAdapter ? $cache : null;
} catch (\Throwable) {
return null;
}
}
private function normalizePath(RequestInterface $request): string
{
$path = $request->getPath() ?: '/';
return $path[0] === '/' ? $path : '/'.$path;
}
}

Di app/Config/Filters.php, tambahkan alias dan tempatkan gate di required:

use App\Filters\ShieldGateFilter;
public array $aliases = [
// ...
'shield.gate' => ShieldGateFilter::class,
];
public array $required = [
'before' => [
'forcehttps', // hapus kalau aplikasi tidak memakai ForceHTTPS
'shield.gate',
'pagecache', // hapus kalau aplikasi tidak memakai PageCache
],
'after' => [
'pagecache',
'performance',
'toolbar',
'shield.gate',
],
];

Pertahankan filter lain yang sudah ada di aplikasi Anda. Yang penting:

  • shield.gate berada di required['before'], setelah forcehttps (kalau dipakai) dan sebelum pagecache.
  • shield.gate juga berada di required['after'] agar 404 sebelum router tetap dihitung.
  • shield.firewall tidak lagi ada di globals['before'].

Di app/Config/Shield.php, tambahkan properti aplikasi berikut:

/**
* Path publik yang tetap diperiksa Shield, tetapi tidak menambah behavior
* counter saat diakses normal dengan GET/HEAD.
*
* Ini bukan daftar bypass. Ban, challenge, dan rule enforcement tetap berlaku.
*
* @var list<string>
*/
public array $gateSkipPaths = [
'/',
'/health',
'/robots.txt',
'/sitemap.xml',
'/favicon.ico',
'/tentang',
'/kontak',
];

gateSkipPaths adalah konfigurasi aplikasi, bukan config bawaan paket. Jangan masukkan endpoint yang menangani data sensitif atau submit form.

SecurityFirewallFilter::before() memanggil readCounters() sebelum engine memutuskan. Artinya setiap request menaikkan:

  • counters:{ip}:unique — jumlah URI berbeda.
  • counters:{ip}:path:{hash} — jumlah request ke path tersebut.

Kunjungan normal ke / berulang kali bisa menaikkan uniqueUriCount dan pathRequestCount sampai ambang challenge, padahal pengunjung hanya membuka halaman publik. Gate filter menyelesaikan ini dengan:

  1. Mengambil snapshot dua counter tersebut.
  2. Menjalankan Shield seperti biasa.
  3. Mengembalikan counter hanya jika Shield mengizinkan request.

Kalau Shield memblokir atau meminta challenge, counter tidak dikembalikan. Jadi IP yang sudah mencurigakan tetap kena challenge/block saat membuka halaman publik.

Aturannya sengaja dibatasi:

  • Hanya GET/HEAD yang di-rollback.
  • POST/PUT/PATCH/DELETE selalu diperiksa penuh.
  • gateSkipPaths tidak melewati pemeriksaan Shield; hanya menentukan path mana yang counter-nya dikembalikan saat request diizinkan.

Kalau aplikasi memakai pagecache, urutan yang aman adalah:

forcehttps → shield.gate → pagecache

PageCache tidak boleh berjalan sebelum Shield. Kalau cache lebih dulu, response yang seharusnya diblokir bisa tersaji dari cache tanpa pernah diperiksa Shield.

Periksa urutan filter:

Terminal window
php spark filter:check GET /

Yang diharapkan: forcehttps → shield.gate → pagecache (sesuaikan dengan filter yang benar-benar dipakai aplikasi Anda).

Uji perilaku dengan curl:

Skenario Hasil yang diharapkan
GET / 5x berturut-turut 200, tidak ada event baru, counter tidak menumpuk.
GET /health 200, halaman publik aman.
GET /.env Diperiksa sebelum router; pada mode enforce/challenge diblokir dengan X-Shield-Blocked. Pada observe hanya dicatat.
GET /random-path Tetap diperiksa dan 404-nya dihitung.
POST / Diperiksa penuh, tidak melewati gate.
GET / setelah /.env diblokir Tetap kena challenge/block sesuai skor IP.
  • Bukan pengganti WAF edge. required['before'] tetap berjalan setelah PHP dieksekusi. Untuk memblokir sebelum PHP, tetap gunakan Cloudflare, Nginx, ModSecurity, atau aturan web server.
  • Snapshot/rollback bukan operasi atomik. Dua request publik bersamaan dari IP yang sama bisa menghasilkan undercount sementara. Untuk traffic normal dampaknya kecil.
  • TTL counter di-reset. Rollback menulis ulang counter dengan TTL penuh behaviorWindowSeconds. Nilainya tidak berubah, hanya masa berlakunya.
  • Daftar path harus dipelihara. Tambahkan halaman publik baru ke gateSkipPaths bila perlu; jangan masukkan endpoint sensitif.
  • Terikat internal v1.2.x. Format key counter dan header X-Shield-Blocked bisa berubah di rilis berikutnya. Periksa ulang saat upgrade.

Powered by PT Ganadev Multi Solusi