Diese Anleitung gilt für PHP 8.1 und neuer, mit der curl-Erweiterung, die die meisten Builds enthalten; für Laravel siehe Laravel. Eine Klasse dient einfachem PHP und Symfony gleichermaßen: Sie nimmt $_SERVER, die IP des Besuchers und das _kz-Cookie, sodass beide jeweils ihre eigenen übergeben.
Der Tag
Setzen Sie den Tag in den <head> des Layouts, das Ihre Registrierungs-, Anmelde- und Checkout-Seiten verwenden (in Symfony templates/base.html.twig):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
Der Aufruf
CURLOPT_TIMEOUT_MS begrenzt den ganzen Aufruf, die Verbindung eingeschlossen:
<?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 ist die Reihenfolge, in der die Header der Anfrage ankamen: Ein Skript, das die Werte eines Browsers kopiert, kopiert selten ihre Reihenfolge. PHP-FPM hält sie in den HTTP_*-Schlüsseln von $_SERVER, von hinten nach vorn, also kehrt der Aufruf sie dort um; php -S und symfony serve halten sie in Reihenfolge. Hinter nginx ist es die Reihenfolge, in der nginx sie weitergibt, also die, in der sie ankamen (gemessen mit nginx 1.31); nginx verwirft einen Namen mit _. Hinter einem Load Balancer oder Cloudflare ist es die Reihenfolge dieses Hops, nicht die des Browsers.
Ohne Framework
Im Skript, das das Konto anlegt:
<?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'] ist die Adresse der Verbindung: die des Besuchers, wenn nichts vor PHP steht. Hinter einem Proxy siehe „Die IP des Besuchers“.
Symfony
Symfony lädt src/Korzen.php von selbst. Vertrauen Sie Ihrem Proxy, damit getClientIp() die IP des Besuchers ist:
# 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); } }
Die IP des Besuchers
Ohne Framework ist REMOTE_ADDR die Adresse Ihres Proxys, wenn Sie einen haben. Hinter einem Load Balancer oder nginx von Ihnen nehmen Sie den letzten Eintrag von $_SERVER['HTTP_X_FORWARDED_FOR'], den Ihr Proxy angehängt hat; hinter Cloudflare $_SERVER['HTTP_CF_CONNECTING_IP'], wenn Ihr Server nur Traffic von Cloudflare annimmt. In Symfony erledigt das trusted_proxies: getClientIp() liest X-Forwarded-For von rechts und überspringt die Adressen, denen Sie vertrauen. PHP-FPM öffnet für jede bediente Anfrage eine neue Verbindung, sodass jeder Aufruf einen TLS-Handshake mitbringt: Weit weg von Europa wählen Sie einen Timeout mit Spielraum dafür.
Die Aktion anwenden
Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.
Die request_id aufbewahren
Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat.
Auf Ihrem Rechner testen
Starten Sie php -S localhost:8000 -t public (in Symfony symfony serve) und öffnen Sie es auf localhost. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung.
- Setzen Sie
KORZEN_API_KEYin der Umgebung und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das_kz-Cookie. - Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
- Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet
401, und die Registrierung geht durch.
Häufige Fehler
- Der erste Eintrag von
X-Forwarded-For. Der Besucher schreibt ihn, also wählt ein Bot seine eigene IP. CURLOPT_TIMEOUT_MSohneCURLOPT_NOSIGNAL. Wo libcurl Namen mit dem blockierenden Resolver des Systems auflöst, scheitert ein Timeout unter einer Sekunde dann sofort, und jeder Aufruf lässt den Besucher durch.getenv()allein für den Schlüssel in Symfony. Das Dotenv von Symfony legt.envin$_ENVab, nicht in der Umgebung des Prozesses, also liest die Klasse zuerst$_ENV./v1/decideaus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.- Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als
200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.