Lewati ke konten

Shield Corev1.2.x

Membuat Adapter

Shield Core tidak tahu framework apa pun. Semua yang perlu Anda tulis adalah satu lapisan yang menerjemahkan request framework Anda menjadi RequestContext, lalu menerjemahkan Verdict menjadi respons.

Kalau framework Anda Laravel, jangan lakukan ini — sudah ada adapter-nya di Laravel Shield.

Sebelum mulai, tulis dulu dua kalimat dalam bahasa biasa:

  1. Bagaimana saya membaca URI, method, host, dan IP dari request framework saya?
  2. Bagaimana saya menghentikan request dan mengirim respons sendiri?

Hampir semua pekerjaan tambahan berasal dari tidak bisa menjawabnya dengan aman. Kalau framework Anda memakai pola middleware, jawabannya hampir selalu “tidak”.

Ini yang paling sering disepelekan dan paling sering jadi sumber bug. Lihat Kontrak untuk daftar lengkapnya.

Yang wajib ada minimum:

use Ganadev\Shield\Core\Events\SecurityEvent;
use Ganadev\Shield\Core\Persistence\EventRepositoryInterface;
final class MyEventRepository implements EventRepositoryInterface
{
public function __construct(
private readonly \PDO $pdo,
private readonly ClockInterface $clock,
) {}
public function record(SecurityEvent $event): void
{
$this->pdo->prepare(
'INSERT INTO security_events
(ip, host, method, raw_uri, normalized_uri, rule_id, category,
severity, score_delta, decision, intended_decision, user_agent,
referer, request_id, rule_version, created_at)
VALUES
(:ip, :host, :method, :raw_uri, :normalized_uri, :rule_id, :category,
:severity, :score_delta, :decision, :intended_decision, :user_agent,
:referer, :request_id, :rule_version, :created_at)'
)->execute([
':ip' => $event->ipAddress,
':host' => $event->host,
// ... field lainnya
':created_at' => $event->createdAt->format('Y-m-d H:i:s'),
]);
}
public function pruneOlderThan(\DateTimeImmutable $cutoff): int
{
$stmt = $this->pdo->prepare('DELETE FROM security_events WHERE created_at < :cutoff');
$stmt->execute([':cutoff' => $cutoff->format('Y-m-d H:i:s')]);
return $stmt->rowCount();
}
}

CacheAdapterInterface punya lima method. increment() yang paling mudah salah — harus atomik, karena dua request paralel dari satu IP harus terhitung berlipat.

public function increment(string $key, int $ttlSeconds): int
{
// Redis: INCR + EXPIRE
// Memcached: increment() + touch()
// PDO : INSERT ... ON CONFLICT DO UPDATE SET value = value + 1
// File : tidak aman untuk paralel — jangan dipakai di produksi
}

Kalau framework Anda tidak punya cache atomik, jangan dipaksa. Simpan counter di database dan biayanya satu INSERT per request yang mencurigakan saja.

ChallengeDriverInterface cuma tiga method:

use Ganadev\Shield\Core\Challenge\ChallengePayload;
use Ganadev\Shield\Core\Challenge\ChallengeResult;
use Ganadev\Shield\Core\Context\RequestContext;
final class TurnstileDriver implements ChallengeDriverInterface
{
public function name(): string
{
return 'turnstile';
}
public function render(RequestContext $context): ChallengePayload
{
return new ChallengePayload(
driver: $this->name(),
siteKey: $this->siteKey,
// `action` dan `data` diteruskan apa adanya ke SDK
);
}
public function verify(string $token, RequestContext $context): ChallengeResult
{
$valid = $this->sdk->verify($token, $context->ip);
return $valid
? ChallengeResult::success($this->name())
: ChallengeResult::failure($this->name(), 'token tidak valid');
}
}

ChallengePayload::action bawaannya shield_challenge. Jangan ubah nama itu kalau driver Anda butuh action untuk identifikasi, karena verifikasi akan mencocokkannya.

Ini tempat license- déciderannya. Urutannya harus persis seperti ini:

public function handle(Request $request, Closure $next): Response
{
// 0. Jangan proses request yang bukan HTML biasa.
// Hook atau API bisa rusak karena ikut kena challenge.
if (! $this->shouldInspect($request)) {
return $next($request);
}
// 1. Bangun context dari request framework Anda.
$context = RequestContext::create(
rawUri: $request->getRequestUri(),
method: $request->getMethod(),
host: $request->getHost(),
ip: $this->clientIp($request),
headersSubset: $this->headers($request),
body: $this->body($request),
);
// 2. Ambil counter perilaku dari cache.
$counters = $this->counters->for($this->clientIp($request), $context);
// 3. Tanya engine.
$result = $this->engine->inspect($context, $counters, [
'request_id' => $this->requestId(),
'trusted_cookie' => $this->cookie($request),
]);
// 4. Keputusan Anda — baca shouldBlock(), bukan decision.
if ($result->shouldBlock()) {
return $this->blocked($result);
}
if ($result->shouldChallenge()) {
return $this->challenge($result);
}
// 5. Lanjut ke aplikasi.
return $next($request);
}

Jangan pernah membaca $_SERVER di dalam Core

Section titled “Jangan pernah membaca $_SERVER di dalam Core”

Kalau adapter Anda mulai mengambil data dari superglobal, Anda memindahkan logika yang seharusnya bisa diuji. Semua data masuk lewat RequestContext.

Jangan membuat cache ban sendiri dengan TTL berbeda

Section titled “Jangan membuat cache ban sendiri dengan TTL berbeda”

Ban aktif dibaca dari BanRepositoryInterface, yang punya cache 30 detik. Kalau adapter Anda menambahkan cache kedua dengan TTL berbeda, Anda akan melihat ban yang “hilang” atau “masih ada” tidak sesuai dengan database.

Cache 30 detik itu disengaja: cukup untuk menutup celah request paralel, cukup pendek untuk membuat Intervention manual terasa langsung.

Jangan pakai verdict->decision secara langsung

Section titled “Jangan pakai verdict->decision secara langsung”

Kalau Anda menulis if ($result->verdict->decision === Decision::TempBan), Anda akan memblokir bahkan saat mode observe aktif — persis hal yang membuat mode observe tidak berguna sebagai alat pengujian.

Gunakan shouldBlock(), shouldChallenge(), atau allowed().

Kalau aplikasi Anda di belakang proxy, adaptasi IP-nya harus benar sebelum meneruskan ke RequestContext. Ini bukan detail kecil: salah konfigurasi berarti seluruh proteksi berbasis IP jadi tidak berguna, karena penyerang cukup memalsukan satu header.

Aturan generally berlaku:

  • Proksi tepercaya hanya yang Anda sebut secara eksplisit (CIDR).
  • Header yang dipakai harus X-Forwarded-For atau X-Real-IP, sesuai yang benar-benar emitted proxy Anda.
  • Kalau aplikasi bisa dijangkau langsung tanpa lewat proxy, jangan percaya header forwarded sama sekali.

Shield Core punya test suite sendiri, tapi yang paling berguna untuk Anda adalah dua hal:ammers ClockInterface dan time yang bisa dikendalikan.

final class FrozenClock implements ClockInterface
{
public function __construct(private \DateTimeImmutable $now) {}
public function now(): \DateTimeImmutable
{
return $this->now;
}
public function advance(string $modifier): void
{
$this->now = $this->now->modify($modifier);
}
}

Dengan begitu Anda bisa menguji pelusan offense dan kedaluwarsa ban tanpa tidur 60 menit.

  • Request biasa → diteruskan.
  • GET /.env → diblokir.
  • /%2e%65nv → diblokir, sama seperti /.env.
  • Path di allowlist.paths → diteruskan.
  • Path di allowlist.paths yang juga cocok rule critical → tetap diblokir.
  • Mode observe → keputusan TEMP_BAN menjadi OBSERVE.
  • BanRepository melempar exception → request tetap diteruskan dengan infrastructureDegraded = true.
  • Bot sah terverifikasi DNS → tidak kena challenge.
  • User-Agent bot palsu → kena challenge kalau bot_mode = challenge.
  • Cookie tepercaya valid → tidak kena challenge.
  • Body request tidak pernah muncul di log kejadian.
Periksa Kenapa
composer analyse bersih di package Anda PHPStan level 8 menangkap kesalahan tipe pada kontrak
Tidak ada pembacaan superglobal di luar adapter Menjaga Core tetap murni
pruneOlderThan dipanggil dari perintah terjadwal Mencegah tabel membengkak
Benchmark jalur allow < 2 ms Shield jalan di setiap request
Mode default observe di package Anda Production belum siap memblokir
Semua 11 skenario di atas lulus Regresi
  • Konfigurasi — semua kunci yang bisa Anda ubah.
  • Aturan — cara menambah signature sendiri.

Powered by PT Ganadev Multi Solusi