Korzen
Bot Protection

Web 標準の Request

Hono、Bun、Deno、React Router と Remix、Astro、SvelteKit、Nuxt に Bot Protection を導入します。1 つのクライアントファイル、Fetch API の Request に対する fromRequest、それぞれでの訪問者の IP を説明します。

このページのサーバーはどれも、Fetch API の Request をあなたのコードに渡します。そのため 1 つのクライアントですべてに対応できます。korzen.js は依存関係のない 1 つのファイルで、/v1/decide に問い合わせ、タイムアウトに達したら諦め、どんなエラーでも訪問者を通します。違うのは、リクエストがどこに届くかと、訪問者の IP がどこにあるかだけです。Next.js は Next.js を、Cloudflare Worker は Cloudflare Workers を参照してください。

タグ

新規登録、ログイン、チェックアウトの各ページを描画するレイアウトの <head> にタグを置きます。

<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

ページでフォームを送信する前に await window.korzen?.ready() を呼び出してください。タグが最後に報告してから訪問者が行ったことを報告し、何が起きても数秒以内に完了します。タグ

クライアント

サーバーコードのフォルダーにダウンロードし、コミットする前に中身を読んでください。

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

次に、その隣にプロセスで 1 つのクライアントを置きます。以下の例は korzen.js から import します。React Router では app/korzen.server.js、SvelteKit では src/lib/server/korzen.js という名前にして、ブラウザー向けのバンドルに決して入らないようにしてください。

// korzen.js: one client for the process
import { createClient } from "./bot-protection.js";

export const korzen = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 });
export { fromHeaders, fromRequest, ipFrom } from "./bot-protection.js";

korzen.decide(fromRequest(request, { ip })) は、リクエストの User-Agent、Bot Protection が読むヘッダー、_kz Cookie、パス、メソッドを読み、応答を返します。エラーのときは null を返します。ボディは読まないので、その後もあなたのコードでフォームを読めます。Fetch API の Headers は順序を保たないため、ヘッダーの順序は送られません。

Hono

アカウントを作成するルートに middleware を付けます。同じアプリが Node、Bun、Deno で動きます。

// app.js
import { Hono } from "hono";
import { serve } from "@hono/node-server"; // on Bun or Deno, serve app.fetch with their own server
import { fromRequest, ipFrom, korzen } from "./korzen.js";

const app = new Hono();

// Asks before the handler, and keeps the answer as c.get("korzen") (null when there is none).
async function guard(c, next) {
  const d = await korzen.decide(fromRequest(c.req.raw, { ip: ipFrom.lastForwarded })); // see "The visitor's IP"
  if (d?.action === "block") return c.text("We could not create your account.", 400);
  // "challenge": your CAPTCHA, or an email or phone check, before going on
  c.set("korzen", d);
  await next();
}

app.post("/signup", guard, async (c) => {
  const user = await createAccount(await c.req.parseBody()); // your own code
  await saveRequestID(user.id, c.get("korzen")?.request_id); // see "Keep the request_id"
  return c.redirect("/welcome", 303);
});

serve({ fetch: app.fetch, port: 3000 });

Bun

Bun.serve のルート(Bun 1.2.3 以降)では、サーバーが接続元のアドレスを渡します。

// server.js: bun run server.js
import { fromRequest, korzen } from "./korzen.js";

Bun.serve({
  port: 3000,
  routes: {
    "/signup": {
      POST: async (req, server) => {
        const d = await korzen.decide(fromRequest(req, { ip: server.requestIP(req)?.address })); // see "The visitor's IP"
        if (d?.action === "block") return new Response("We could not create your account.", { status: 400 });
        // "challenge": your CAPTCHA, or an email or phone check, before going on
        const user = await createAccount(await req.formData()); // your own code
        await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
        return Response.redirect(new URL("/welcome", req.url), 303);
      },
    },
  },
});

Deno

// server.js: deno run --allow-net --allow-env server.js
import { fromRequest, korzen } from "./korzen.js";

Deno.serve({ port: 3000 }, async (req, info) => {
  if (req.method !== "POST" || new URL(req.url).pathname !== "/signup") return new Response("Not found", { status: 404 });
  const d = await korzen.decide(fromRequest(req, { ip: info.remoteAddr.hostname })); // see "The visitor's IP"
  if (d?.action === "block") return new Response("We could not create your account.", { status: 400 });
  // "challenge": your CAPTCHA, or an email or phone check, before going on
  const user = await createAccount(await req.formData()); // your own code
  await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
  return Response.redirect(new URL("/welcome", req.url), 303);
});

React Router と Remix

アカウントを作成するルートの action で呼び出します(React Router の framework mode。Remix はこれになりました)。

// app/routes/signup.jsx
import { data, redirect } from "react-router";
import { fromRequest, ipFrom, korzen } from "../korzen.server.js";

export async function action({ request }) {
  const d = await korzen.decide(fromRequest(request, { ip: ipFrom.lastForwarded })); // see "The visitor's IP"
  if (d?.action === "block") return data({ error: "We could not create your account." }, { status: 400 });
  // "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); // see "Keep the request_id"
  return redirect("/welcome");
}

Astro

オンデマンドでレンダリングされるエンドポイントで、clientAddress を提供するサーバーアダプター(@astrojs/node など)とともに使います。

// src/pages/api/signup.js
import { fromRequest, korzen } from "../../korzen.js";

export const prerender = false;

export async function POST({ request, clientAddress, redirect }) {
  const d = await korzen.decide(fromRequest(request, { ip: clientAddress, path: "/signup" })); // see "The visitor's IP"
  if (d?.action === "block") return new Response("We could not create your account.", { status: 400 });
  // "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); // see "Keep the request_id"
  return redirect("/welcome", 303);
}

SvelteKit

新規登録ページのフォーム action で呼び出します。getClientAddress() はアダプターが読む IP です。

// src/routes/signup/+page.server.js
import { fail, redirect } from "@sveltejs/kit";
import { fromRequest, korzen } from "$lib/server/korzen.js";

export const actions = {
  default: async ({ request, getClientAddress }) => {
    const d = await korzen.decide(fromRequest(request, { ip: getClientAddress() })); // see "The visitor's IP"
    if (d?.action === "block") return fail(400, { error: "We could not create your account." });
    // "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); // see "Keep the request_id"
    redirect(303, "/welcome");
  },
};

Nuxt

サーバールートで呼び出します。fromHeaders(event.headers) はリクエストのヘッダーを読み、ボディは readFormData に残します。toWebRequest(event) で 2 つ目の Request を作ると、フォームが読めなくなります。

// server/api/signup.post.js
import { fromHeaders, ipFrom, korzen } from "../utils/korzen.js";

export default defineEventHandler(async (event) => {
  const d = await korzen.decide(fromHeaders(event.headers, { ip: ipFrom.lastForwarded, path: "/api/signup" })); // see "The visitor's IP"
  if (d?.action === "block") {
    setResponseStatus(event, 400);
    return "We could not create your account.";
  }
  // "challenge": your CAPTCHA, or an email or phone check, before going on
  const user = await createAccount(await readFormData(event)); // your own code
  await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
  return sendRedirect(event, "/welcome", 303);
});

訪問者の IP

送るのは自前のプロキシが見たアドレスで、訪問者が書き込めたアドレスは決して送らないでください。サーバーの前段に何もなければ、それは接続元のアドレスです。Bun では server.requestIP(req)、Deno では info.remoteAddr です。自前のロードバランサーや nginx が 1 つある場合は、X-Forwarded-For の最後の値、つまりプロキシが追加した値で、ipFrom.lastForwarded がこれを読みます。Cloudflare の背後にあり、サーバーが Cloudflare からのトラフィックしか受け付けない場合は、ipFrom.cloudflare が CF-Connecting-IP を読みます。

Astro の clientAddress と SvelteKit の getClientAddress() は、アダプターが読む値です。プロキシの背後で @sveltejs/adapter-node を使う場合は、すべてのリクエストがそのプロキシを通る環境で、ADDRESS_HEADER=X-Forwarded-For と XFF_DEPTH=1(自前のプロキシが 1 つ)を設定してください。アダプターはこのヘッダーのないリクエストを拒否します。Nuxt では、h3 の getRequestIP(event, { xForwardedFor: true }) は訪問者が書く最初の値を取ります。上の例のように ipFrom.lastForwarded を使ってください。

アクションを適用する

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

request_id を保存する

すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。アカウントが良いか悪いかが分かったら、それを送り返してください:await korzen.feedback([{ request_id, outcome: "chargeback" }])(フィードバックを参照)。

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

開発サーバーを起動し、localhost で開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。ipFrom.lastForwarded を使い、前段にプロキシがない場合は IP なしになりますが、これも問題ありません。

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

よくある間違い

  • 値を追加していくプロキシの背後で、ipFrom.vercel や h3 の xForwardedFor のように X-Forwarded-For の最初の値を読む。その値は訪問者が書くので、bot が自分の IP を好きに選べてしまいます。ipFrom.vercel が正しいのは、このヘッダーを上書きする Vercel の上だけです。
  • ブラウザーに届くコードにクライアントを import する。API key がすべての訪問者に渡ってしまいます。サーバー専用のモジュールに置いてください。
  • Astro のページをプリレンダリングのままにする。prerender = false(または output: "server")がないと、エンドポイントには判定するリクエストがなく、clientAddress は例外を投げます。
  • タイムアウトがない、またはエラーで新規登録が止まる。200 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。