Korzen
Bot Protection

Fastify und NestJS

Bot Protection in Fastify oder NestJS: der Tag, die Client-Datei, ein preHandler-Hook in Fastify, die Express-Middleware in NestJS und die IP des Besuchers.

Diese Anleitung gilt für Fastify 5 und für NestJS 12, wie nest new es anlegt (ein ES-Modul-Projekt auf Express), auf Node 22 oder neuer, mit der Client-Datei, die auch die Express-Anleitung verwendet: korzen.js, eine einzige Datei ohne Abhängigkeiten, die /v1/decide fragt, bei Ihrem Timeout aufgibt und den Besucher bei jedem Fehler durchlässt.

Der Tag

Setzen Sie den Tag in den <head> des Layouts, das Ihre Registrierungs-, Anmelde- und Checkout-Seiten rendert:

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

Rufen Sie auf der Seite vor dem Senden des Formulars await window.korzen?.ready() auf: Es meldet, was der Besucher seit der letzten Meldung des Tags getan hat, und wird in jedem Fall innerhalb weniger Sekunden aufgelöst. Der Tag

Der Aufruf

Laden Sie den Client in Ihr Projekt und lesen Sie ihn, bevor Sie ihn committen:

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

Das fetch von Node schließt eine Verbindung nach 4 Sekunden Leerlauf; ein undici-Agent mit längerem keepAliveTimeout, als dispatcher übergeben, hält sie offen (warum undici 7, steht in Express).

Fastify

Ein preHandler-Hook auf der Route, die das Konto anlegt. Er legt die Antwort in request.korzen ab, oder null, wenn es keine gibt. fromNodeRequest(request.raw) liest die eigene Anfrage von Node, sodass der Aufruf die Reihenfolge mitschickt, in der die Header ankamen:

// 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 läuft auf Express, also schützt protect, die Express-Middleware des Clients, die Route in main.ts. Ein neues Projekt baut nur TypeScript nach dist/: Tragen Sie korzen.js in die Assets von nest-cli.json ein, damit der Build die Datei neben main.js kopiert:

{
  "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);

Dann liegt die Antwort im Controller auf der Anfrage:

// 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"
  }
}

Ohne onChallenge lässt protect keine Challenge durch: Ein Seitenaufruf (GET) geht zu unserer Challenge-Seite und kommt zurück, ein abgeschicktes Formular wird mit 403 abgelehnt. Wer /signup schützt, schützt auch die Seite des Formulars: Dort trifft eine Person auf die Challenge, und ihr Pass lässt das Formular durch.

Die IP des Besuchers

Fastify liest X-Forwarded-For nur von den Proxys, die trustProxy nennt, nach Adresse oder Netz: "loopback,uniquelocal" vertraut einem Proxy auf diesem Rechner oder in einem privaten Netz (10.x, 172.16–31.x, 192.168.x, fc00::/7), und request.ip ist dann der Eintrag, den dieser Proxy angehängt hat. Fastify 5 nimmt keine Anzahl von Hops: Eine Zahl vertraut niemandem. true vertraut jedem Eintrag, und den ersten schreibt der Besucher. In NestJS auf Express ist trust proxy die Anzahl der Proxys davor, wie in der Express-Anleitung. Hinter Cloudflare lesen Sie CF-Connecting-IP (ipFrom.cloudflare), wenn Ihr Server nur Traffic von Cloudflare annimmt.

Die Aktion anwenden

Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.

Die request_id aufbewahren

Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat. Wenn sich das Konto als gut oder schlecht herausstellt, melden Sie es zurück: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (siehe Feedback).

Auf Ihrem Rechner testen

Starten Sie Ihren Server mit node --env-file=.env app.js (in NestJS mit npm run start:dev) und öffnen Sie ihn auf localhost. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung.

  1. Setzen Sie KORZEN_API_KEY in .env und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das _kz-Cookie.
  2. Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
  3. Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet 401, und die Registrierung geht durch.

Häufige Fehler

  • trustProxy: 1 in Fastify 5. Es vertraut keinem Proxy, sodass request.ip für jeden Besucher die Adresse Ihres Load Balancers ist.
  • trustProxy: true, oder trust proxy auf true in NestJS. Jeder Hop gilt als vertrauenswürdig, sodass die IP zu dem wird, was der Besucher in X-Forwarded-For geschrieben hat.
  • In NestJS fehlt korzen.js in den Assets von nest-cli.json: Der Build hat keine dist/korzen.js, und der Server startet nicht.
  • /v1/decide aus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.
  • Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als 200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.