Korzen
Bot Protection

Laravel

Bot Protection en Laravel: el tag en el layout, la cookie _kz sin cifrar, proxies de confianza y la llamada con el cliente HTTP de Laravel.

Esta guía es para Laravel 11 en adelante, con su cliente HTTP. Dos ajustes importan más que la llamada: Laravel cifra cada cookie que lee salvo que le digas lo contrario, e ignora los proxies hasta que confíes en ellos.

El tag

En el layout que usan tus páginas de registro, inicio de sesión y 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 llamada

En bootstrap/app.php, deja sin cifrar la cookie del tag y confía en tu 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
})

Lee la clave a través de la config, para que siga funcionando tras php artisan config:cache:

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

La llamada. timeout y connectTimeout reciben segundos, también fracciones:

// 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 es el orden en que llegaron los headers de la petición: un script que copia los valores de un navegador rara vez copia su orden. PHP-FPM lo guarda en las claves HTTP_* de $_SERVER, del último al primero, así que la llamada las invierte ahí; php artisan serve las guarda en orden. Detrás de nginx es el orden en que nginx los reenvía, que es el orden en que llegaron (medido con nginx 1.31); nginx descarta un nombre con _. Detrás de un balanceador de carga o Cloudflare, es el orden de ese salto, no el del navegador.

Luego, donde se crea la cuenta; con Breeze, en 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,
]);

La IP del visitante

$request->ip() es la dirección de tu proxy hasta que confíes en él. trustProxies(at: '*') confía en quien se conecte, lo correcto cuando la app solo es accesible a través de tu load balancer o de Cloudflare; si no, lista las direcciones de tu proxy. PHP-FPM abre una conexión nueva por cada petición que atiende, así que cada llamada incluye un handshake TLS: lejos de Europa, elige un timeout con margen para él.

Actúa según la action

Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, sigue adelante. La cuenta empieza en monitor mode, donde action es siempre allow: nada cambia para los visitantes hasta que actives el block mode.

Guarda el request_id

Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa.

Pruébalo en tu máquina

Ejecuta php artisan serve y abre http://localhost:8000. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o ::1, y es normal. En Docker en tu máquina, la app ve en su lugar la dirección del bridge, como 172.17.0.1: la página de desafío te devuelve igual a localhost, porque el tag vio tu página allí.

  1. Define KORZEN_API_KEY en .env y abre la página de registro. En las herramientas de desarrollo del navegador, la cookie _kz aparece tras unos segundos.
  2. Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
  3. Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde 401, y el registro pasa.

Errores comunes

  • No incluir _kz en encryptCookies(except: …). Laravel no logra descifrar la cookie del tag y $request->cookie('_kz') es null en cada petición.
  • env('KORZEN_API_KEY') en el servicio. Tras config:cache, env() fuera de los archivos de config devuelve null.
  • No declarar proxies de confianza detrás de un load balancer. Todos los visitantes tienen la IP del balancer.
  • Llamar a /v1/decide desde el navegador. La API key quedaría expuesta a cada visitante; la llamada se hace desde tu servidor.
  • No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de 200, o la falta de respuesta a tiempo, significa: deja pasar al visitante.