Korzen
Bot Protection

Laravel

Bot Protection in Laravel: der Tag im Layout, das _kz-Cookie unverschlüsselt gelassen, vertrauenswürdige Proxys und der Aufruf mit Laravels HTTP-Client.

Diese Anleitung gilt für Laravel 11 und neuer, mit seinem HTTP-Client. Zwei Einstellungen sind wichtiger als der Aufruf: Laravel verschlüsselt jedes Cookie, das es liest, sofern nicht anders angegeben, und es ignoriert Proxys, bis Sie ihnen vertrauen.

Der Tag

Im Layout, das Ihre Registrierungs-, Anmelde- und Checkout-Seiten verwenden:

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

Der Aufruf

In bootstrap/app.php lassen Sie das Cookie des Tags unverschlüsselt und vertrauen Ihrem 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
})

Lesen Sie den Schlüssel über die Config, damit es auch nach php artisan config:cache funktioniert:

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

Der Aufruf. timeout und connectTimeout nehmen Sekunden, auch Bruchteile:

// 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 ist die Reihenfolge, in der die Header der Anfrage ankamen: Ein Skript, das die Werte eines Browsers kopiert, kopiert selten ihre Reihenfolge. PHP-FPM hält sie in den HTTP_*-Schlüsseln von $_SERVER, von hinten nach vorn, also kehrt der Aufruf sie dort um; php artisan serve hält sie in Reihenfolge. Hinter nginx ist es die Reihenfolge, in der nginx sie weitergibt, also die, in der sie ankamen (gemessen mit nginx 1.31); nginx verwirft einen Namen mit _. Hinter einem Load Balancer oder Cloudflare ist es die Reihenfolge dieses Hops, nicht die des Browsers.

Dann dort, wo das Konto angelegt wird; mit 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,
]);

Die IP des Besuchers

$request->ip() ist die Adresse Ihres Proxys, bis Sie ihm vertrauen. trustProxies(at: '*') vertraut allem, was sich verbindet, was richtig ist, wenn die App nur über Ihren Load Balancer oder Cloudflare erreichbar ist; sonst tragen Sie die Adressen Ihres Proxys ein. PHP-FPM öffnet für jede bediente Anfrage eine neue Verbindung, sodass jeder Aufruf einen TLS-Handshake mitbringt: Weit weg von Europa wählen Sie einen Timeout mit Spielraum dafür.

Die Aktion anwenden

Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.

Die request_id aufbewahren

Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat.

Auf Ihrem Rechner testen

Führen Sie php artisan serve aus und öffnen Sie http://localhost:8000. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung. Läuft die App auf Ihrem Rechner in Docker, sieht sie stattdessen die Adresse der Bridge, etwa 172.17.0.1: Die Challenge-Seite bringt Sie trotzdem zu localhost zurück, denn der Tag hat Ihre Seite dort gesehen.

  1. Setzen Sie KORZEN_API_KEY in .env und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das _kz-Cookie.
  2. Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
  3. Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet 401, und die Registrierung geht durch.

Häufige Fehler

  • _kz fehlt in encryptCookies(except: …). Laravel kann das Cookie des Tags dann nicht entschlüsseln, und $request->cookie('_kz') ist bei jeder Anfrage null.
  • env('KORZEN_API_KEY') im Service. Nach config:cache gibt env() außerhalb der Config-Dateien null zurück.
  • Keine vertrauenswürdigen Proxys hinter einem Load Balancer. Jeder Besucher hat dann die IP des Balancers.
  • /v1/decide aus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.
  • Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als 200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.