Korzen
Bot Protection

PHP と Symfony

フレームワークなしの PHP または Symfony に Bot Protection を導入します。タグ、1 つのタイムアウトで curl を使う呼び出し用クラス、ヘッダーの順序、信頼するプロキシを説明します。

このガイドは、ほとんどのビルドに含まれる 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 になりますが、問題ありません。

  1. 環境変数に KORZEN_API_KEY を設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに _kz Cookie が表示されます。
  2. 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
  3. 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 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。