Korzen
Bot Protection

Laravel

Bot Protection in Laravel: de tag in de layout, de _kz-cookie onversleuteld, trusted proxies, en de call met de HTTP-client van Laravel.

Deze gids is voor Laravel 11 en nieuwer, met de HTTP-client van Laravel. Twee instellingen zijn belangrijker dan de call: Laravel versleutelt elke cookie die het leest, tenzij je anders aangeeft, en het negeert proxy's totdat je ze vertrouwt.

De tag

In de layout die je registratie-, inlog- en checkoutpagina's gebruiken:

{{-- resources/views/layouts/app.blade.php, in <head> --}}
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

De call

Laat in bootstrap/app.php de cookie van de tag onversleuteld, en vertrouw je proxy:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->encryptCookies(except: ['_kz']); // the tag's cookie is not Laravel's
    $middleware->trustProxies(at: '*');            // only if the app is reachable through your proxy alone
})

Lees de key via de config, zodat het ook na php artisan config:cache blijft werken:

// config/services.php
'korzen' => [
    'key' => env('KORZEN_API_KEY'),
],

De call. timeout en connectTimeout nemen seconden, ook breuken:

// app/Services/Korzen.php
namespace App\Services;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Throwable;

class Korzen
{
    /** The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. */
    private const HEADERS = ['accept', 'accept-encoding', 'accept-language', 'sec-ch-ua', 'sec-ch-ua-mobile',
        'sec-ch-ua-platform', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'upgrade-insecure-requests'];

    /** The answer for this request, or null on any error: then go on as on allow (fail open). */
    public static function decide(Request $request, string $path): ?array
    {
        $headers = [];
        foreach (self::HEADERS as $name) {
            if ($request->hasHeader($name)) {
                $headers[$name] = $request->header($name);
            }
        }
        // The order the headers came in, as $_SERVER keeps it: PHP-FPM lists them last first.
        $names = array_values(array_filter(array_keys($request->server->all()), fn ($k) => str_starts_with((string) $k, 'HTTP_')));
        if (PHP_SAPI === 'fpm-fcgi') {
            $names = array_reverse($names);
        }
        $order = array_map(fn ($k) => strtolower(strtr(substr($k, 5), '_', '-')), array_slice($names, 0, 64));
        try {
            $res = Http::withToken(config('services.korzen.key'))
                ->timeout(0.2)
                ->connectTimeout(0.2)
                ->post('https://korzen.dev/v1/decide', [
                    'ip' => $request->ip(),
                    'user_agent' => $request->userAgent(),
                    'headers' => (object) $headers, // {} when empty, never []
                    'header_order' => $order,
                    'token' => $request->cookie('_kz'),
                    'path' => $path,
                    'method' => 'POST',
                ]);

            return $res->ok() ? $res->json() : null;
        } catch (Throwable) {
            return null;
        }
    }
}

header_order is de volgorde waarin de headers van het verzoek binnenkwamen: een script dat de waarden van een browser kopieert, kopieert zelden hun volgorde. PHP-FPM houdt die bij in de HTTP_*-sleutels van $_SERVER, van achter naar voren, dus de call draait ze daar om; php artisan serve houdt ze op volgorde. Achter nginx is het de volgorde waarin nginx ze doorstuurt, en dat is de volgorde waarin ze binnenkwamen (gemeten met nginx 1.31); nginx laat een naam met _ vallen. Achter een load balancer of Cloudflare is het de volgorde van die hop, niet die van de browser.

Daarna, waar het account wordt aangemaakt; met Breeze in RegisteredUserController::store:

// app/Http/Controllers/Auth/RegisteredUserController.php (Breeze), in store()
$decision = Korzen::decide($request, '/register');
if (($decision['action'] ?? 'allow') === 'block') {
    throw ValidationException::withMessages(['email' => 'We could not create your account.']);
}
// "challenge": your CAPTCHA, or an email or phone check, before going on
$user = User::create([
    // ... your fields
    'korzen_request_id' => $decision['request_id'] ?? null,
]);

Het IP-adres van de bezoeker

$request->ip() is het adres van je proxy totdat je die vertrouwt. trustProxies(at: '*') vertrouwt alles wat verbinding maakt, en dat klopt als de app alleen via je load balancer of Cloudflare bereikbaar is; anders zet je de adressen van je proxy in de lijst. PHP-FPM opent voor elke request die het afhandelt een nieuwe verbinding, dus elke call bevat een TLS-handshake: zit je ver van Europa, kies dan een timeout met ruimte daarvoor.

Voer de actie uit

Lees alleen action. Bij block antwoord je met dezelfde foutmelding die de actie toont als hij om een andere reden mislukt, zodat een bot niets leert. Bij challenge gebruik je de controle die je al hebt (een CAPTCHA, een code per e-mail of telefoon); heb je er geen, ga dan door. Het account begint in monitor mode, waar action altijd allow is: voor bezoekers verandert er niets tot je block mode aanzet.

Bewaar de request_id

Elk antwoord heeft een request_id. Bewaar die bij wat de actie heeft aangemaakt, in een kolom van het account of de bestelling, of in een logregel met hun id. Zo weet je welke beslissing het was als je ons ernaar vraagt, en kun je later nagaan welk antwoord een nepaccount binnenliet.

Test het op je eigen machine

Draai php artisan serve en open http://localhost:8000. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP-adres 127.0.0.1 of ::1, en dat is prima. In Docker op je eigen machine ziet de app in plaats daarvan het adres van de bridge, zoals 172.17.0.1: de challengepagina brengt je toch terug naar localhost, want de tag zag je pagina daar.

  1. Zet KORZEN_API_KEY in .env en open de registratiepagina. In de developer tools van de browser verschijnt na een paar seconden de _kz-cookie.
  2. Registreer je. De beslissing verschijnt op de pagina Beslissingen van het dashboard, onder het pad dat je stuurde.
  3. Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met 401, en de registratie gaat door.

Veelgemaakte fouten

  • _kz ontbreekt in encryptCookies(except: …). Laravel kan de cookie van de tag dan niet ontsleutelen, en $request->cookie('_kz') is bij elke request null.
  • env('KORZEN_API_KEY') in de service. Na config:cache geeft env() buiten de configbestanden null terug.
  • Geen trusted proxies achter een load balancer. Elke bezoeker heeft dan het IP-adres van de balancer.
  • /v1/decide aanroepen vanuit de browser. De API key zou dan bij elke bezoeker terechtkomen; de call hoort op je server.
  • Geen timeout, of een fout die de registratie tegenhoudt. Elk ander antwoord dan 200, en geen antwoord op tijd, betekent: laat de bezoeker door.