Korzen
Bot Protection

Next.js

Next.js の App Router プロジェクトに Bot Protection を導入します。ルートレイアウトのタグ、server action での呼び出し、Vercel とプロキシの背後での訪問者の IP を説明します。

このガイドは App Router を使う Next.js 15 以降が対象です。タグはルートレイアウトに追加し、Bot Protection はアカウントを作成する server action または route handler から呼び出します。

タグ

すべてのページに含まれるよう、ルートレイアウトに置きます。

// app/layout.js
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
      </head>
      <body>{children}</body>
    </html>
  );
}

呼び出し

クライアントは依存関係のない 1 ファイルです。/v1/decide に問い合わせ、設定した timeout で打ち切り、エラーのときは必ず訪問者を通します。lib/ にダウンロードし、commit する前に中身を読んでください。

curl -o lib/bot-protection.js https://korzen.dev/bot-protection/sdk/korzen.js
curl -o lib/bot-protection.d.ts https://korzen.dev/bot-protection/sdk/korzen.d.ts # types, for TypeScript

次に、保護するすべてのアクションで使う関数を 1 つ用意します。

// lib/korzen.js
import { headers } from "next/headers";
import { createClient, fromHeaders, ipFrom } from "./bot-protection.js";

export const client = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 });

// decide asks Bot Protection about the request being handled. It returns the answer,
// or null on any error: the caller then goes on as on allow (fail open).
export async function decide(path) {
  // ipFrom.vercel on Vercel; see "The visitor's IP"
  return client.decide(fromHeaders(await headers(), { path, ip: ipFrom.vercel }));
}

次に、アカウントを作成する server action で呼び出します。

// app/signup/actions.js
"use server";
import { decide } from "@/lib/korzen";

export async function signUp(prevState, formData) {
  const d = await decide("/signup");
  if (d?.action === "block") return { error: "We could not create your account." };
  if (d?.action === "challenge") {
    // your CAPTCHA, or an email or phone check, before going on
  }
  const user = await createAccount(formData); // your own code
  await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
  // ...
}

route handler では withKorzen でハンドラーを包みます。block のときは 403 を返し、それ以外はハンドラー自身の引数の後に応答を渡します。

// app/api/signup/route.js
import { ipFrom, withKorzen } from "@/lib/bot-protection";
import { client } from "@/lib/korzen";

export const POST = withKorzen(
  async (request, ctx, d) => {
    if (d?.action === "challenge") {
      // your CAPTCHA, or an email or phone check, before going on
    }
    const user = await createAccount(await request.formData()); // your own code
    await saveRequestID(user.id, d?.request_id);
    return Response.json({ ok: true });
  },
  // onChallenge answers nothing, so the handler runs its own check; without it, a
  // challenged post is refused (403)
  { client, ip: ipFrom.vercel, onChallenge: () => undefined },
);

訪問者の IP

Vercel では x-forwarded-for に訪問者の IP が入っています。Vercel がこの値を上書きするため、訪問者が自分で書き込むことはできません。それ以外の環境では、自前のエッジが設定するヘッダーを読んでください。Cloudflare の背後なら cf-connecting-ip、nginx が設定しているなら x-real-ip です。x-forwarded-for を上書きせずに追記するプロキシの背後では、最初の値は訪問者が送ったものなので、プロキシが追加した最後の値を使ってください。

アクションを適用する

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

onChallenge を指定しない場合、middleware は challenge を通しません。ページ読み込み(GET)はチャレンジページへ送られ、フォームの送信や API の呼び出しは 403 で拒否されます。フォームを表示するページも保護してください。人はそこでチャレンジを受け、その通過によってフォームの送信が通ります。独自の確認手段がある場合は、onChallenge を指定してそこで実行してください。戻り先は訪問者が開いたアドレスで、プロキシが付けていれば X-Forwarded-Host と X-Forwarded-Proto から読みます。Host: localhost を転送し、どちらも付けないプロキシ(nginx の既定)の後ろでは publicURL: "https://shop.example" を指定してください。

request_id を保存する

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

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

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

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

よくある間違い

  • 追記型のプロキシの背後で x-forwarded-for の最初の値を読む。bot が自分の IP を好きに選べてしまいます。
  • ページの読み込み直後、タグが報告する前に JavaScript でフォームを送信する。送信前に await window.korzen?.ready() を呼んでください。Bot Protection が遅延していても数秒以内に完了します。
  • middleware での呼び出し。すべてのリクエストで実行され、そのすべてが判定としてカウントされます。呼び出すのは保護したい操作の中だけにしてください。
  • ブラウザーから /v1/decide を呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。
  • タイムアウトがない、またはエラーで新規登録が止まる。200 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。
  • ログに korzen: the visitor's IP sent to Bot Protection で始まる行が出る。クライアントは、IP があなた自身のネットワークのもの(ロードバランサー、プロキシ、コンテナのもの)である最初の判定で一度だけこれを書きます。Bot Protection はその判定を IP なしとして扱います。ip に訪問者のアドレスを設定してください(訪問者の IP)。自分のマシンの Docker ではブリッジのアドレスで、そこでは問題ありません。