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ì.
- Imposta
KORZEN_API_KEYnell'ambiente e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie_kzcompare dopo qualche secondo. - Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
- 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_MSsenzaCURLOPT_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.envin$_ENV, non nell'ambiente del processo, quindi la classe legge prima$_ENV. - Chiamare
/v1/decidedal 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.