Korzen
Bot Protection

Fastify と NestJS

Fastify または NestJS に Bot Protection を導入します。タグ、クライアントファイル、Fastify の preHandler フック、NestJS での Express の middleware、訪問者の IP を説明します。

このガイドは、Node 22 以降で動く Fastify 5 と、nest new が作る形の NestJS 12(Express 上の ES module プロジェクト)を対象にしています。使うのは Express ガイドと同じクライアントファイル korzen.js です。依存関係のない 1 つのファイルで、/v1/decide に問い合わせ、タイムアウトに達したら諦め、どんなエラーでも訪問者を通します。

タグ

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

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

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

呼び出し

クライアントをプロジェクトにダウンロードし、コミットする前に中身を読んでください。

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

Node の fetch はアイドル状態が 4 秒続くと接続を閉じます。keepAliveTimeout を長くした undici の Agent を dispatcher として渡すと、接続が維持されます(undici 7 を使う理由は Express を参照)。

Fastify

アカウントを作成するルートに preHandler フックを付けます。応答は request.korzen に入り、応答がなければ null になります。fromNodeRequest(request.raw) は Node 自体のリクエストを読むので、呼び出しにはヘッダーが届いた順序も含まれます。

// app.js
import Fastify from "fastify";
import formbody from "@fastify/formbody";
import { Agent } from "undici"; // optional (npm install undici@7): keeps the connection open between calls
import { createClient, fromNodeRequest } from "./src/korzen.js";

const korzen = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200, dispatcher: new Agent({ keepAliveTimeout: 300_000 }) });
const app = Fastify({ trustProxy: "loopback,uniquelocal" }); // your proxy on a private network: see "The visitor's IP"
app.decorateRequest("korzen", null);
await app.register(formbody);

// Asks before the handler, and keeps the answer on request.korzen (null when there is none).
async function guard(request, reply) {
  request.korzen = await korzen.decide(fromNodeRequest(request.raw, { ip: request.ip }));
  if (request.korzen?.action === "block") return reply.code(400).send("We could not create your account.");
  // "challenge": your CAPTCHA, or an email or phone check, before going on
}

app.post("/signup", { preHandler: guard }, async (request, reply) => {
  const user = await createAccount(request.body); // your own code
  await saveRequestID(user.id, request.korzen?.request_id); // see "Keep the request_id"
  return reply.redirect("/welcome", 303);
});

await app.listen({ port: 3000 });

NestJS

NestJS は Express 上で動くので、クライアントの Express 用 middleware である protect が main.ts でルートを保護します。新しいプロジェクトは TypeScript だけを dist/ にビルドします。nest-cli.json の assets に korzen.js を加え、ビルドがそれを main.js の隣にコピーするようにしてください。

{
  "sourceRoot": "src",
  "compilerOptions": { "assets": ["korzen.js"] }
}
// src/main.ts
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { Agent } from "undici"; // optional (npm install undici@7): keeps the connection open between calls
import { AppModule } from "./app.module.js";
import { createClient, protect } from "./korzen.js";

const korzen = createClient({ apiKey: process.env.KORZEN_API_KEY!, url: "https://korzen.dev", timeoutMs: 200, dispatcher: new Agent({ keepAliveTimeout: 300_000 }) });

const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set("trust proxy", 1); // one proxy of yours in front: see "The visitor's IP"
// Asks before every route under /signup; the answer is on req.korzen, or null when there is none.
app.use("/signup", protect({
  client: korzen,
  ip: (_get, req) => req.ip, // with "trust proxy" set
  onBlock: (_req, res) => res.status(400).send("We could not create your account."),
  // onChallenge: (req, res, next, d) => your CAPTCHA, or an email or phone check, then next()
}));
await app.listen(process.env.PORT ?? 3000);

あとはコントローラーで、応答がリクエストに入っています。

// src/signup.controller.ts
import { Body, Controller, Get, Post, Redirect, Req } from "@nestjs/common";
import type { Request } from "express";
import type { Decision } from "./korzen.js";

@Controller("signup")
export class SignupController {
  @Get() // the form's page: a challenge meets the person here
  form() {
    return renderSignupForm(); // your own code
  }

  @Post()
  @Redirect("/welcome", 303)
  async create(@Req() req: Request & { korzen?: Decision | null }, @Body() body: Record<string, string>) {
    const user = await createAccount(body); // your own code
    await saveRequestID(user.id, req.korzen?.request_id); // see "Keep the request_id"
  }
}

onChallenge を指定しない場合、protect は challenge を通しません。ページ読み込み(GET)はチャレンジページへ送られて戻り、フォームの送信は 403 で拒否されます。/signup を保護するとフォームのページも保護されるので、人はそこでチャレンジを受け、その通過によってフォームの送信が通ります。

訪問者の IP

Fastify は、trustProxy がアドレスまたはネットワークで指定したプロキシからの X-Forwarded-For だけを読みます。"loopback,uniquelocal" は、同じマシン上またはプライベートネットワーク(10.x、172.16–31.x、192.168.x、fc00::/7)上のプロキシを信頼し、request.ip はそのプロキシが追加した値になります。Fastify 5 はホップ数を受け付けません。数値を指定すると何も信頼しません。true はすべての値を信頼し、その最初の値は訪問者が書いたものです。Express 上の NestJS では、Express ガイドと同じく、trust proxy は前段のプロキシの数です。Cloudflare の背後にあり、サーバーが Cloudflare からのトラフィックしか受け付けない場合は、CF-Connecting-IP(ipFrom.cloudflare)を読んでください。

アクションを適用する

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

request_id を保存する

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

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

node --env-file=.env app.js(NestJS では npm run start:dev)でサーバーを起動し、localhost で開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。

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

よくある間違い

  • Fastify 5 で trustProxy: 1 を指定する。どのプロキシも信頼しないため、すべての訪問者の request.ip がロードバランサーのアドレスになります。
  • trustProxy: true、または NestJS で trust proxy を true にする。すべてのホップを信頼するため、IP が訪問者が X-Forwarded-For に書いた値になります。
  • NestJS で、nest-cli.json の assets に korzen.js がない。ビルドに dist/korzen.js が含まれず、サーバーが起動しません。
  • ブラウザーから /v1/decide を呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。
  • タイムアウトがない、またはエラーで新規登録が止まる。200 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。