Korzen
Bot Protection

PHP y Symfony

Bot Protection en PHP sin framework o con Symfony: el tag, una clase para la llamada con curl bajo un solo timeout, el orden de los headers y los proxies de confianza.

Esta guía es para PHP 8.1 y posterior, con la extensión curl que incluye la mayoría de las builds; para Laravel, consulta Laravel. Una sola clase sirve igual para PHP sin framework y para Symfony: recibe $_SERVER, la IP del visitante y la cookie _kz, así que cada uno pasa los suyos.

El tag

Pon el tag en el <head> del layout que usan tus páginas de registro, inicio de sesión y checkout (en Symfony, templates/base.html.twig):

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

La llamada

CURLOPT_TIMEOUT_MS acota la llamada entera, conexión incluida:

<?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 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 -S y symfony serve las guardan 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.

Sin framework

En el script que crea la cuenta:

<?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'] es la dirección de la conexión: la del visitante cuando no hay nada delante de PHP. Detrás de un proxy, consulta "La IP del visitante".

Symfony

Symfony carga src/Korzen.php por sí solo. Confía en tu proxy, para que getClientIp() sea la del 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);
    }
}

La IP del visitante

Sin framework, REMOTE_ADDR es la dirección de tu proxy cuando tienes uno. Detrás de un load balancer o un nginx tuyo, toma la última entrada de $_SERVER['HTTP_X_FORWARDED_FOR'], la que añadió tu proxy; detrás de Cloudflare, $_SERVER['HTTP_CF_CONNECTING_IP'], si tu servidor solo recibe tráfico de Cloudflare. En Symfony, lo hace trusted_proxies: getClientIp() lee X-Forwarded-For desde la derecha y salta las direcciones en las que confías. 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 -S localhost:8000 -t public (en Symfony, symfony serve) y ábrelo en localhost. 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.

  1. Define KORZEN_API_KEY en el entorno 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

  • La primera entrada de X-Forwarded-For. La escribe el visitante, así que un bot elige su propia IP.
  • CURLOPT_TIMEOUT_MS sin CURLOPT_NOSIGNAL. Donde libcurl resuelve nombres con el resolver bloqueante del sistema, un timeout de menos de un segundo falla entonces al instante, y cada llamada deja pasar al visitante.
  • Solo getenv() para la clave en Symfony. El Dotenv de Symfony pone .env en $_ENV, no en el entorno del proceso, así que la clase lee primero $_ENV.
  • 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.