Korzen
Bot Protection

Laravel

Bot Protection in Laravel: il tag nel layout, il cookie _kz lasciato non cifrato, i trusted proxy, e la chiamata con l'HTTP client di Laravel.

Questa guida è per Laravel 11 e successivi, con il suo HTTP client. Due impostazioni contano più della chiamata: Laravel cifra ogni cookie che legge, a meno che tu non dica altrimenti, e ignora i proxy finché non li dichiari fidati.

Il tag

Nel layout usato dalle pagine di registrazione, login e checkout:

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

La chiamata

In bootstrap/app.php, lascia non cifrato il cookie del tag e dichiara fidato il tuo 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
})

Leggi la chiave tramite la config, così funziona anche dopo php artisan config:cache:

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

La chiamata. timeout e connectTimeout accettano secondi, anche frazioni:

// 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 è l'ordine in cui sono arrivati gli header della richiesta: uno script che copia i valori di un browser ne copia di rado l'ordine. PHP-FPM lo tiene nelle chiavi HTTP_* di $_SERVER, dall'ultimo al primo, quindi la chiamata le inverte lì; php artisan serve le tiene in ordine. Dietro nginx è l'ordine in cui nginx li inoltra, cioè quello di arrivo (misurato con nginx 1.31); nginx scarta un nome con _. Dietro un load balancer o Cloudflare, è l'ordine di quel passaggio, non quello del browser.

Poi, dove viene creato l'account; con 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,
]);

L'IP del visitatore

$request->ip() è l'indirizzo del tuo proxy finché non lo dichiari fidato. trustProxies(at: '*') si fida di qualsiasi cosa si connetta, il che va bene quando l'app è raggiungibile solo attraverso il tuo load balancer o Cloudflare; altrimenti elenca gli indirizzi del tuo proxy. PHP-FPM apre una nuova connessione per ogni richiesta che serve, quindi ogni chiamata porta con sé un handshake TLS: se sei lontano dall'Europa, scegli un timeout che ne tenga conto.

Applica l'azione

Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode.

Conserva il request_id

Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso.

Provalo sulla tua macchina

Avvia php artisan serve e apri http://localhost:8000. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o ::1, e va bene così. In Docker sulla tua macchina, l'app vede invece l'indirizzo del bridge, come 172.17.0.1: la pagina di challenge ti riporta comunque su localhost, perché il tag ha visto lì la tua pagina.

  1. Imposta KORZEN_API_KEY in .env e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie _kz compare dopo qualche secondo.
  2. Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
  3. Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde 401, e la registrazione va a buon fine.

Errori comuni

  • _kz assente da encryptCookies(except: …). Laravel allora non riesce a decifrare il cookie del tag e $request->cookie('_kz') è null a ogni richiesta.
  • env('KORZEN_API_KEY') nel service. Dopo config:cache, env() fuori dai file di config restituisce null.
  • Nessun trusted proxy dietro un load balancer. Ogni visitatore ha allora l'IP del balancer.
  • Chiamare /v1/decide dal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server.
  • Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da 200, o nessuna risposta in tempo, significa: lascia passare il visitatore.