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.
- Defina
KORZEN_API_KEYno ambiente e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie_kzaparece depois de alguns segundos. - Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
- 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_MSsemCURLOPT_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.envem$_ENV, não no ambiente do processo, então a classe lê$_ENVprimeiro. - Chamar
/v1/decidedo 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.