Korzen
Bot Protection

PHP et Symfony

Bot Protection en PHP sans framework ou avec Symfony : le tag, une classe pour l'appel avec curl sous un seul timeout, l'ordre des headers, et les proxies de confiance.

Ce guide vaut pour PHP 8.1 et plus récent, avec l'extension curl que la plupart des builds incluent ; pour Laravel, voir Laravel. Une seule classe sert PHP sans framework comme Symfony : elle prend $_SERVER, l'IP du visiteur et le cookie _kz, donc chacun passe les siens.

Le tag

Placez le tag dans le <head> du layout qu'utilisent vos pages d'inscription, de connexion et de checkout (dans Symfony, templates/base.html.twig) :

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

L'appel

CURLOPT_TIMEOUT_MS borne l'appel entier, connexion comprise :

<?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 est l'ordre dans lequel les headers de la requête sont arrivés : un script qui copie les valeurs d'un navigateur en copie rarement l'ordre. PHP-FPM le garde dans les clés HTTP_* de $_SERVER, du dernier au premier, donc l'appel les inverse là ; php -S et symfony serve les gardent dans l'ordre. Derrière nginx, c'est l'ordre dans lequel nginx les transmet, qui est l'ordre d'arrivée (mesuré avec nginx 1.31) ; nginx écarte un nom avec _. Derrière un load balancer ou Cloudflare, c'est l'ordre de ce saut, pas celui du navigateur.

Sans framework

Dans le script qui crée le compte :

<?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'] est l'adresse de la connexion : celle du visiteur quand rien ne se trouve devant PHP. Derrière un proxy, voir « L'IP du visiteur ».

Symfony

Symfony charge src/Korzen.php de lui-même. Faites confiance à votre proxy, pour que getClientIp() soit celle du visiteur :

# 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);
    }
}

L'IP du visiteur

Sans framework, REMOTE_ADDR est l'adresse de votre proxy quand vous en avez un. Derrière un load balancer ou un nginx à vous, prenez la dernière entrée de $_SERVER['HTTP_X_FORWARDED_FOR'], celle que votre proxy a ajoutée ; derrière Cloudflare, $_SERVER['HTTP_CF_CONNECTING_IP'], si votre serveur ne reçoit du trafic que de Cloudflare. Dans Symfony, trusted_proxies s'en charge : getClientIp() lit X-Forwarded-For depuis la droite et saute les adresses auxquelles vous faites confiance. PHP-FPM ouvre une nouvelle connexion pour chaque requête qu'il sert, donc chaque appel comporte une poignée de main TLS : loin de l'Europe, choisissez un timeout qui lui laisse de la place.

Appliquer l'action

Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.

Garder le request_id

Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.

Tester sur votre machine

Lancez php -S localhost:8000 -t public (dans Symfony, symfony serve) et ouvrez-le sur localhost. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien.

  1. Définissez KORZEN_API_KEY dans l'environnement et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie _kz apparaît après quelques secondes.
  2. Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
  3. Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond 401, et l'inscription passe.

Erreurs fréquentes

  • La première entrée de X-Forwarded-For. Le visiteur l'écrit, donc un bot choisit sa propre IP.
  • CURLOPT_TIMEOUT_MS sans CURLOPT_NOSIGNAL. Là où libcurl résout les noms avec le résolveur bloquant du système, un timeout de moins d'une seconde échoue alors aussitôt, et chaque appel laisse passer le visiteur.
  • getenv() seul pour la clé dans Symfony. Le Dotenv de Symfony met .env dans $_ENV, pas dans l'environnement du processus, donc la classe lit d'abord $_ENV.
  • Appeler /v1/decide depuis le navigateur. L'API key atteindrait chaque visiteur ; l'appel a sa place sur votre serveur.
  • Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que 200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.