Korzen
Bot Protection

Fastify en NestJS

Bot Protection in Fastify of NestJS: de tag, het clientbestand, een preHandler-hook in Fastify, de Express-middleware in NestJS, en het IP-adres van de bezoeker.

Deze gids is voor Fastify 5, en NestJS 12 zoals nest new het aanmaakt (een ES-moduleproject op Express), op Node 22 of nieuwer, met het clientbestand dat de Express-gids gebruikt: korzen.js, één bestand zonder dependencies, dat /v1/decide vraagt, opgeeft bij je timeout en de bezoeker bij elke fout doorlaat.

De tag

Zet de tag in de <head> van de layout die je registratie-, inlog- en checkoutpagina's rendert:

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

Roep op de pagina, voordat het formulier wordt verstuurd, await window.korzen?.ready() aan: het meldt wat de bezoeker deed sinds de tag het laatst meldde, en is hoe dan ook binnen een paar seconden klaar. De tag

De call

Download de client in je project, en lees hem voordat je hem commit:

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

De fetch van Node sluit een verbinding na 4 seconden zonder verkeer; een undici-Agent met een langere keepAliveTimeout, meegegeven als dispatcher, houdt hem open (zie Express voor waarom undici 7).

Fastify

Een preHandler-hook op de route die het account aanmaakt. Hij bewaart het antwoord op request.korzen, of null als er geen is. fromNodeRequest(request.raw) leest het eigen request van Node, dus de call bevat de volgorde waarin de headers binnenkwamen:

// 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 draait op Express, dus protect, de Express-middleware van de client, beschermt de route in main.ts. Een nieuw project bouwt alleen TypeScript naar dist/: zet korzen.js bij de assets in nest-cli.json, zodat de build het naast main.js kopieert:

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

Daarna staat het antwoord in de controller op het request:

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

Zonder onChallenge laat protect geen challenge door: het laden van een pagina (GET) gaat naar onze challengepagina en komt terug, en een verstuurd formulier wordt geweigerd met 403. Wie /signup beschermt, beschermt ook de pagina van het formulier: daar krijgt een persoon de challenge, en zijn pass laat het formulier door.

Het IP-adres van de bezoeker

Fastify leest X-Forwarded-For alleen van de proxy's die trustProxy noemt, op adres of netwerk: "loopback,uniquelocal" vertrouwt een proxy op deze machine of op een privénetwerk (10.x, 172.16–31.x, 192.168.x, fc00::/7), en request.ip is dan de entry die die proxy heeft toegevoegd. Fastify 5 accepteert geen aantal hops: een getal vertrouwt niets. true vertrouwt elke entry, en de eerste daarvan schrijft de bezoeker. In NestJS op Express is trust proxy het aantal proxy's ervoor, zoals in de Express-gids. Achter Cloudflare lees je CF-Connecting-IP (ipFrom.cloudflare), als je server alleen verkeer van Cloudflare accepteert.

Voer de actie uit

Lees alleen action. Bij block antwoord je met dezelfde foutmelding die de actie toont als hij om een andere reden mislukt, zodat een bot niets leert. Bij challenge gebruik je de controle die je al hebt (een CAPTCHA, een code per e-mail of telefoon); heb je er geen, ga dan door. Het account begint in monitor mode, waar action altijd allow is: voor bezoekers verandert er niets tot je block mode aanzet.

Bewaar de request_id

Elk antwoord heeft een request_id. Bewaar die bij wat de actie heeft aangemaakt, in een kolom van het account of de bestelling, of in een logregel met hun id. Zo weet je welke beslissing het was als je ons ernaar vraagt, en kun je later nagaan welk antwoord een nepaccount binnenliet. Blijkt het account goed of slecht, meld het dan terug: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (zie feedback).

Test het op je eigen machine

Start je server met node --env-file=.env app.js (in NestJS npm run start:dev) en open hem op localhost. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP-adres 127.0.0.1 of ::1, en dat is prima.

  1. Zet KORZEN_API_KEY in .env en open de registratiepagina. In de developer tools van de browser verschijnt na een paar seconden de _kz-cookie.
  2. Registreer je. De beslissing verschijnt op de pagina Beslissingen van het dashboard, onder het pad dat je stuurde.
  3. Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met 401, en de registratie gaat door.

Veelgemaakte fouten

  • trustProxy: 1 in Fastify 5. Dat vertrouwt geen enkele proxy, dus request.ip is voor elke bezoeker het adres van je load balancer.
  • trustProxy: true, of trust proxy op true in NestJS. Elke hop wordt vertrouwd, dus het IP-adres wordt wat de bezoeker in X-Forwarded-For schreef.
  • In NestJS ontbreekt korzen.js in de assets van nest-cli.json: de build heeft geen dist/korzen.js, en de server start niet.
  • /v1/decide aanroepen vanuit de browser. De API key zou dan bij elke bezoeker terechtkomen; de call hoort op je server.
  • Geen timeout, of een fout die de registratie tegenhoudt. Elk ander antwoord dan 200, en geen antwoord op tijd, betekent: laat de bezoeker door.