Korzen
Bot Protection

Laravel

Bot Protection no Laravel: a tag no layout, o cookie _kz sem criptografia, proxies confiáveis e a chamada com o cliente HTTP do Laravel.

Este guia é para Laravel 11 em diante, com o cliente HTTP dele. Duas configurações importam mais que a chamada: o Laravel criptografa todo cookie que lê, a menos que você diga o contrário, e ignora proxies até você confiar neles.

A tag

No layout que as páginas de cadastro, login e checkout usam:

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

A chamada

Em bootstrap/app.php, deixe o cookie da tag sem criptografia e confie no seu 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
})

Leia a chave pela config, para continuar funcionando depois de php artisan config:cache:

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

A chamada. timeout e connectTimeout recebem segundos, inclusive frações:

// 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 é a ordem em que os headers da requisição chegaram: um script que copia os valores de um navegador raramente copia a ordem deles. O PHP-FPM guarda essa ordem nas chaves HTTP_* de $_SERVER, do último para o primeiro, então a chamada as inverte nele; o php artisan serve as guarda na ordem. Atrás do nginx é a ordem em que o nginx os repassa, que é a ordem em que chegaram (medido com o nginx 1.31); o nginx descarta um nome com _. Atrás de um load balancer ou do Cloudflare, é a ordem desse salto, não a do navegador.

Depois, onde a conta é criada; com Breeze, em 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,
]);

O IP do visitante

$request->ip() é o endereço do seu proxy até você confiar nele. trustProxies(at: '*') confia em quem se conectar, o que está certo quando o app só é alcançável pelo seu load balancer ou pelo Cloudflare; senão, liste os endereços do seu proxy. O PHP-FPM abre uma conexão nova para cada requisição que atende, então cada chamada inclui um handshake TLS: longe da Europa, escolha um timeout com folga para ele.

Siga a action

Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode.

Guarde o request_id

Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa.

Teste na sua máquina

Rode php artisan serve e abra http://localhost:8000. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou ::1, e não tem problema. No Docker na sua máquina, o app vê em vez disso o endereço da bridge, como 172.17.0.1: a página de desafio ainda traz você de volta para localhost, porque a tag viu sua página lá.

  1. Defina KORZEN_API_KEY em .env e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie _kz aparece depois de alguns segundos.
  2. Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
  3. Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde 401, e o cadastro passa.

Erros comuns

  • _kz fora do encryptCookies(except: …). O Laravel não consegue descriptografar o cookie da tag e $request->cookie('_kz') vem null em toda requisição.
  • env('KORZEN_API_KEY') no service. Depois de config:cache, env() fora dos arquivos de config devolve null.
  • Nenhum proxy confiável atrás de um load balancer. Todo visitante fica com o IP do balancer.
  • Chamar /v1/decide do navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor.
  • Sem timeout, ou um erro que trava o cadastro. Se a resposta não for 200, ou não chegar a tempo, deixe o visitante passar.