Korzen
Bot Protection

PHP e Symfony

Bot Protection em PHP sem framework ou com Symfony: a tag, uma classe para a chamada com curl sob um só timeout, a ordem dos headers e proxies confiáveis.

Este guia é para PHP 8.1 em diante, com a extensão curl que a maioria dos builds inclui; para o Laravel, veja Laravel. Uma classe serve PHP puro e Symfony do mesmo jeito: ela recebe $_SERVER, o IP do visitante e o cookie _kz, e cada um passa os seus.

A tag

Coloque a tag no <head> do layout que as páginas de cadastro, login e checkout usam (no Symfony, templates/base.html.twig):

<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

A chamada

CURLOPT_TIMEOUT_MS limita a chamada inteira, conexão incluída:

<?php
// src/Korzen.php
namespace App;

final 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).
     * $server is $_SERVER (in Symfony, $request->server->all()); $ip the visitor's IP.
     */
    public static function decide(array $server, ?string $ip, ?string $token, string $path): ?array
    {
        $headers = [];
        foreach (self::HEADERS as $name) {
            $key = 'HTTP_' . strtoupper(strtr($name, '-', '_'));
            if (isset($server[$key])) {
                $headers[$name] = $server[$key];
            }
        }
        // The order the headers came in, as $_SERVER keeps it: PHP-FPM lists them last first.
        $names = array_values(array_filter(array_keys($server), 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));
        $ch = curl_init('https://korzen.dev/v1/decide');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode([
                'ip' => $ip,
                'user_agent' => $server['HTTP_USER_AGENT'] ?? null,
                'headers' => (object) $headers, // {} when empty, never []
                'header_order' => $order,
                'token' => $token,
                'path' => $path,
                'method' => 'POST',
            ]),
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . ($_ENV['KORZEN_API_KEY'] ?? getenv('KORZEN_API_KEY')), // Symfony's .env fills $_ENV
                'Content-Type: application/json',
                'Korzen-Deadline-Ms: 200',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT_MS => 200, // the whole call, the connection included
            CURLOPT_NOSIGNAL => true,  // or libcurl rounds a timeout under a second to none
        ]);
        $res = curl_exec($ch);
        if ($res === false || curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
            return null;
        }
        $d = json_decode($res, true);

        return is_array($d) ? $d : 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; php -S e symfony serve as guardam 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.

Sem framework

No script que cria a conta:

<?php
// public/signup.php
require __DIR__ . '/../src/Korzen.php';

use App\Korzen;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $decision = Korzen::decide($_SERVER, $_SERVER['REMOTE_ADDR'] ?? null, $_COOKIE['_kz'] ?? null, '/signup');
    if (($decision['action'] ?? 'allow') === 'block') {
        http_response_code(400);
        exit('We could not create your account.');
    }
    // "challenge": your CAPTCHA, or an email or phone check, before going on
    $user = create_account($_POST); // your own code
    save_request_id($user, $decision['request_id'] ?? null); // see "Keep the request_id"
    header('Location: /welcome', true, 303);
    exit;
}

$_SERVER['REMOTE_ADDR'] é o endereço da conexão: o do visitante quando não há nada na frente do PHP. Atrás de um proxy, veja "O IP do visitante".

Symfony

O Symfony carrega src/Korzen.php sozinho. Confie no seu proxy, para que getClientIp() seja o do visitante:

# config/packages/framework.yaml
framework:
    trusted_proxies: '127.0.0.1,10.0.0.0/8' # your proxies' addresses
    trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host']
<?php
// src/Controller/SignupController.php
namespace App\Controller;

use App\Korzen;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class SignupController extends AbstractController
{
    #[Route('/signup', methods: ['POST'])]
    public function signup(Request $request): Response
    {
        $decision = Korzen::decide($request->server->all(), $request->getClientIp(), $request->cookies->get('_kz'), '/signup');
        if (($decision['action'] ?? 'allow') === 'block') {
            return new Response('We could not create your account.', 400);
        }
        // "challenge": your CAPTCHA, or an email or phone check, before going on
        $user = $this->accounts->create($request->request->all()); // your own code
        $user->setKorzenRequestId($decision['request_id'] ?? null); // see "Keep the request_id"

        return $this->redirect('/welcome', 303);
    }
}

O IP do visitante

Sem framework, REMOTE_ADDR é o endereço do seu proxy quando você tem um. Atrás de um load balancer ou nginx seu, pegue a última entrada de $_SERVER['HTTP_X_FORWARDED_FOR'], a que o seu proxy acrescentou; atrás do Cloudflare, $_SERVER['HTTP_CF_CONNECTING_IP'], se o seu servidor só recebe tráfego do Cloudflare. No Symfony, o trusted_proxies faz isso: getClientIp() lê o X-Forwarded-For da direita para a esquerda e pula os endereços em que você confia. 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 -S localhost:8000 -t public (no Symfony, symfony serve) e abra em localhost. 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.

  1. Defina KORZEN_API_KEY no ambiente 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

  • A primeira entrada do X-Forwarded-For. Quem a define é o visitante, então o bot escolhe o próprio IP.
  • CURLOPT_TIMEOUT_MS sem CURLOPT_NOSIGNAL. Onde a libcurl resolve nomes com o resolver bloqueante do sistema, um timeout abaixo de um segundo falha na hora, e toda chamada deixa o visitante passar.
  • Só getenv() para a chave no Symfony. O Dotenv do Symfony coloca o .env em $_ENV, não no ambiente do processo, então a classe lê $_ENV primeiro.
  • 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.