Korzen
Bot Protection

PHP e Symfony

Bot Protection in PHP senza framework o con Symfony: il tag, una classe per la chiamata con curl sotto un solo timeout, l'ordine degli header, e i trusted proxy.

Questa guida è per PHP 8.1 e successivi, con l'estensione curl che la maggior parte delle build include; per Laravel, vedi Laravel. Una sola classe serve sia PHP senza framework sia Symfony: riceve $_SERVER, l'IP del visitatore e il cookie _kz, così ciascuno passa i propri.

Il tag

Metti il tag nel <head> del layout che usano le pagine di registrazione, login e checkout (in Symfony, templates/base.html.twig):

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

La chiamata

CURLOPT_TIMEOUT_MS limita l'intera chiamata, connessione compresa:

<?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 è l'ordine in cui sono arrivati gli header della richiesta: uno script che copia i valori di un browser ne copia di rado l'ordine. PHP-FPM lo tiene nelle chiavi HTTP_* di $_SERVER, dall'ultimo al primo, quindi la chiamata le inverte lì; php -S e symfony serve le tengono in ordine. Dietro nginx è l'ordine in cui nginx li inoltra, cioè quello di arrivo (misurato con nginx 1.31); nginx scarta un nome con _. Dietro un load balancer o Cloudflare, è l'ordine di quel passaggio, non quello del browser.

Senza framework

Nello script che crea l'account:

<?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'] è l'indirizzo della connessione: quello del visitatore quando niente sta davanti a PHP. Dietro un proxy, vedi "L'IP del visitatore".

Symfony

Symfony carica src/Korzen.php da solo. Dichiara fidato il tuo proxy, così getClientIp() è quello del visitatore:

# 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 del visitatore

Senza framework, REMOTE_ADDR è l'indirizzo del tuo proxy quando ne hai uno. Dietro un tuo load balancer o nginx, prendi l'ultima voce di $_SERVER['HTTP_X_FORWARDED_FOR'], quella che il tuo proxy ha aggiunto; dietro Cloudflare, $_SERVER['HTTP_CF_CONNECTING_IP'], se il tuo server accetta traffico solo da Cloudflare. In Symfony ci pensa trusted_proxies: getClientIp() legge X-Forwarded-For da destra e salta gli indirizzi di cui ti fidi. PHP-FPM apre una nuova connessione per ogni richiesta che serve, quindi ogni chiamata porta con sé un handshake TLS: se sei lontano dall'Europa, scegli un timeout che ne tenga conto.

Applica l'azione

Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode.

Conserva il request_id

Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso.

Provalo sulla tua macchina

Avvia php -S localhost:8000 -t public (in Symfony, symfony serve) e aprilo su localhost. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o ::1, e va bene così.

  1. Imposta KORZEN_API_KEY nell'ambiente e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie _kz compare dopo qualche secondo.
  2. Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
  3. Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde 401, e la registrazione va a buon fine.

Errori comuni

  • La prima voce di X-Forwarded-For. La scrive il visitatore, quindi un bot si sceglie il proprio IP.
  • CURLOPT_TIMEOUT_MS senza CURLOPT_NOSIGNAL. Dove libcurl risolve i nomi con il resolver bloccante del sistema, un timeout sotto il secondo fallisce allora subito, e ogni chiamata lascia passare il visitatore.
  • Solo getenv() per la chiave in Symfony. Il Dotenv di Symfony mette .env in $_ENV, non nell'ambiente del processo, quindi la classe legge prima $_ENV.
  • Chiamare /v1/decide dal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server.
  • Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da 200, o nessuna risposta in tempo, significa: lascia passare il visitatore.