Korzen
Bot Protection

Laravel

Laravel に Bot Protection を導入します。レイアウトのタグ、暗号化しない _kz Cookie、信頼するプロキシ、Laravel の HTTP クライアントによる呼び出しを説明します。

このガイドは Laravel 11 以降と、その HTTP クライアントを対象にしています。呼び出しそのものより重要な設定が 2 つあります。Laravel は指定しない限り読み取るすべての Cookie を暗号化されたものとして扱うこと、そして信頼するまでプロキシを無視することです。

タグ

新規登録、ログイン、チェックアウトの各ページが使うレイアウトに置きます。

{{-- resources/views/layouts/app.blade.php, in <head> --}}
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

呼び出し

bootstrap/app.php で、タグの Cookie を暗号化の対象外にし、プロキシを信頼します。

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->encryptCookies(except: ['_kz']); // the tag's cookie is not Laravel's
    $middleware->trustProxies(at: '*');            // only if the app is reachable through your proxy alone
})

php artisan config:cache の後も動くよう、キーは config から読み取ります。

// config/services.php
'korzen' => [
    'key' => env('KORZEN_API_KEY'),
],

呼び出しです。timeout と connectTimeout は秒単位で、小数も指定できます。

// app/Services/Korzen.php
namespace App\Services;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Throwable;

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). */
    public static function decide(Request $request, string $path): ?array
    {
        $headers = [];
        foreach (self::HEADERS as $name) {
            if ($request->hasHeader($name)) {
                $headers[$name] = $request->header($name);
            }
        }
        // The order the headers came in, as $_SERVER keeps it: PHP-FPM lists them last first.
        $names = array_values(array_filter(array_keys($request->server->all()), 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));
        try {
            $res = Http::withToken(config('services.korzen.key'))
                ->timeout(0.2)
                ->connectTimeout(0.2)
                ->post('https://korzen.dev/v1/decide', [
                    'ip' => $request->ip(),
                    'user_agent' => $request->userAgent(),
                    'headers' => (object) $headers, // {} when empty, never []
                    'header_order' => $order,
                    'token' => $request->cookie('_kz'),
                    'path' => $path,
                    'method' => 'POST',
                ]);

            return $res->ok() ? $res->json() : null;
        } catch (Throwable) {
            return null;
        }
    }
}

header_order はリクエストのヘッダーが届いた順序です。ブラウザの値をコピーするスクリプトでも、その順序まで真似ることはまれです。PHP-FPM はこの順序を $_SERVER の HTTP_* キーに逆順で保つため、そこでは呼び出しが並びを反転します。php artisan serve は届いた順に保ちます。nginx の後ろでは nginx が転送する順序、つまり届いた順序になります(nginx 1.31 で計測)。nginx は _ を含む名前を捨てます。ロードバランサーや Cloudflare の後ろでは、ブラウザの順序ではなく、その中継の順序です。

次に、アカウントを作成する箇所で呼び出します。Breeze なら RegisteredUserController::store の中です。

// app/Http/Controllers/Auth/RegisteredUserController.php (Breeze), in store()
$decision = Korzen::decide($request, '/register');
if (($decision['action'] ?? 'allow') === 'block') {
    throw ValidationException::withMessages(['email' => 'We could not create your account.']);
}
// "challenge": your CAPTCHA, or an email or phone check, before going on
$user = User::create([
    // ... your fields
    'korzen_request_id' => $decision['request_id'] ?? null,
]);

訪問者の IP

プロキシを信頼するまで、$request->ip() はプロキシのアドレスになります。trustProxies(at: '*') は接続してくるものをすべて信頼します。アプリにロードバランサーや Cloudflare 経由でしか到達できないならこれで正しいのですが、そうでなければプロキシのアドレスを列挙してください。PHP-FPM は処理するリクエストごとに新しい接続を開くため、呼び出しのたびに TLS ハンドシェイクが発生します。ヨーロッパから遠い場合は、その分の余裕を持たせたタイムアウトを選んでください。

アクションを適用する

読むのは action だけです。block の場合は、その操作がほかの理由で失敗したときと同じエラーを返し、bot に何も悟らせないようにします。challenge の場合は、すでにある確認手段(CAPTCHA、メールや電話の確認コード)を使います。何もなければそのまま続けます。アカウントは monitor mode で始まり、その間 action は常に allow です。block mode をオンにするまで、訪問者にとっては何も変わりません。

request_id を保存する

すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。

手元のマシンでテストする

php artisan serve を実行し、http://localhost:8000 を開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。自分のマシンの Docker でアプリを動かすと、代わりにブリッジのアドレス(172.17.0.1 など)になりますが、タグがそこであなたのページを見ているので、チャレンジページは localhost へ戻してくれます。

  1. .env に KORZEN_API_KEY を設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに _kz Cookie が表示されます。
  2. 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
  3. fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは 401 を返し、新規登録はそのまま完了します。

よくある間違い

  • encryptCookies(except: …) に _kz が入っていない。Laravel がタグの Cookie の復号に失敗し、すべてのリクエストで $request->cookie('_kz') が null になります。
  • サービスの中で env('KORZEN_API_KEY') を使う。config:cache の後は、config ファイル以外での env() は null を返します。
  • ロードバランサーの背後なのに信頼するプロキシを設定していない。すべての訪問者がロードバランサーの IP になります。
  • ブラウザーから /v1/decide を呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。
  • タイムアウトがない、またはエラーで新規登録が止まる。200 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。