このガイドは、ほとんどのビルドに含まれる curl 拡張を使う PHP 8.1 以降を対象にしています。Laravel は Laravel を参照してください。1 つのクラスがフレームワークなしの PHP と Symfony の両方に対応します。$_SERVER、訪問者の IP、_kz Cookie を引数に取るので、それぞれが自分の値を渡します。
タグ
新規登録、ログイン、チェックアウトの各ページが使うレイアウト(Symfony なら templates/base.html.twig)の <head> にタグを置きます。
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
呼び出し
CURLOPT_TIMEOUT_MS が、接続を含む呼び出し全体を制限します。
<?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 はリクエストのヘッダーが届いた順序です。ブラウザの値をコピーするスクリプトでも、その順序まで真似ることはまれです。PHP-FPM はこの順序を $_SERVER の HTTP_* キーに逆順で保つため、そこでは呼び出しが並びを反転します。php -S と symfony serve は届いた順に保ちます。nginx の後ろでは nginx が転送する順序、つまり届いた順序になります(nginx 1.31 で計測)。nginx は _ を含む名前を捨てます。ロードバランサーや Cloudflare の後ろでは、ブラウザの順序ではなく、その中継の順序です。
フレームワークなしで
アカウントを作成するスクリプトで呼び出します。
<?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'] は接続元のアドレスで、PHP の前段に何もなければ訪問者のアドレスです。プロキシの背後では「訪問者の IP」を参照してください。
Symfony
Symfony は src/Korzen.php を自動で読み込みます。getClientIp() が訪問者のアドレスになるよう、プロキシを信頼してください。
# 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); } }
訪問者の IP
フレームワークなしでは、プロキシがあれば REMOTE_ADDR はプロキシのアドレスです。自前のロードバランサーや nginx が 1 つある場合は、$_SERVER['HTTP_X_FORWARDED_FOR'] の最後の値、つまりプロキシが追加した値を使ってください。Cloudflare の背後にあり、サーバーが Cloudflare からのトラフィックしか受け付けない場合は $_SERVER['HTTP_CF_CONNECTING_IP'] です。Symfony では trusted_proxies がこれを行います。getClientIp() は X-Forwarded-For を右から読み、信頼するアドレスを飛ばします。PHP-FPM は処理するリクエストごとに新しい接続を開くため、呼び出しのたびに TLS ハンドシェイクが発生します。ヨーロッパから遠い場合は、その分の余裕を持たせたタイムアウトを選んでください。
アクションを適用する
読むのは action だけです。block の場合は、その操作がほかの理由で失敗したときと同じエラーを返し、bot に何も悟らせないようにします。challenge の場合は、すでにある確認手段(CAPTCHA、メールや電話の確認コード)を使います。何もなければそのまま続けます。アカウントは monitor mode で始まり、その間 action は常に allow です。block mode をオンにするまで、訪問者にとっては何も変わりません。
request_id を保存する
すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。
手元のマシンでテストする
php -S localhost:8000 -t public(Symfony では symfony serve)を実行し、localhost で開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。
- 環境変数に
KORZEN_API_KEYを設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに_kzCookie が表示されます。 - 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
- fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは
401を返し、新規登録はそのまま完了します。
よくある間違い
X-Forwarded-Forの最初の値を使う。訪問者が書き込める値なので、bot が自分の IP を好きに選べてしまいます。CURLOPT_NOSIGNALなしでCURLOPT_TIMEOUT_MSを使う。libcurl がシステムのブロッキングリゾルバーで名前を解決する環境では、1 秒未満のタイムアウトが即座に失敗し、すべての呼び出しが訪問者を通してしまいます。- Symfony でキーを
getenv()だけで読む。Symfony の Dotenv は.envをプロセスの環境変数ではなく$_ENVに入れるので、クラスは先に$_ENVを読みます。 - ブラウザーから
/v1/decideを呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。 - タイムアウトがない、またはエラーで新規登録が止まる。
200以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。