Korzen
Bot Protection

Fastify e NestJS

Bot Protection em Fastify ou NestJS: a tag, o arquivo do cliente, um hook preHandler no Fastify, o middleware do Express no NestJS e o IP do visitante.

Este guia é para Fastify 5 e para NestJS 12 como o nest new o cria (um projeto ES module sobre o Express), no Node 22 em diante, com o arquivo de cliente que o guia do Express usa: korzen.js, um arquivo só, sem dependências, que pergunta ao /v1/decide, desiste no seu timeout e deixa o visitante passar em qualquer erro.

A tag

Coloque a tag no <head> do layout que renderiza as páginas de cadastro, login e checkout:

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

Na página, antes de enviar o formulário, chame await window.korzen?.ready(): ela informa o que o visitante fez desde o último relato da tag e resolve em poucos segundos, aconteça o que acontecer. A tag

A chamada

Baixe o cliente para o seu projeto e leia-o antes do 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

O fetch do Node fecha uma conexão parada há 4 segundos; um Agent do undici com um keepAliveTimeout maior, passado como dispatcher, a mantém aberta (veja no Express por que o undici 7).

Fastify

Um hook preHandler na rota que cria a conta. Ele guarda a resposta em request.korzen, ou null quando não há resposta. fromNodeRequest(request.raw) lê a requisição do próprio Node, então a chamada leva a ordem em que os headers chegaram:

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

O NestJS roda sobre o Express, então o protect, o middleware de Express do cliente, protege a rota em main.ts. Um projeto novo só compila TypeScript para dist/: adicione korzen.js aos assets em nest-cli.json, para o build copiá-lo ao lado de 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);

Depois, no controller, a resposta está na requisição:

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

Sem onChallenge, o protect não deixa passar nenhum desafio: um carregamento de página (GET) vai para a nossa página de desafio e volta, e um envio de formulário é recusado com 403. Proteger /signup protege também a página do formulário, para que a pessoa encontre o desafio ali e a aprovação dela deixe o envio passar.

O IP do visitante

O Fastify só lê o X-Forwarded-For dos proxies que o trustProxy nomeia, por endereço ou rede: "loopback,uniquelocal" confia num proxy nesta máquina ou numa rede privada (10.x, 172.16–31.x, 192.168.x, fc00::/7), e o request.ip passa a ser a entrada que esse proxy acrescentou. O Fastify 5 não aceita um número de saltos: um número não confia em nada. true confia em toda entrada, e a primeira delas quem escreve é o visitante. No NestJS sobre o Express, trust proxy é o número de proxies na frente, como no guia do Express. Atrás do Cloudflare, leia CF-Connecting-IP (ipFrom.cloudflare), se o seu servidor só recebe tráfego do Cloudflare.

Siga a action

Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode.

Guarde o request_id

Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa. Quando a conta se mostrar boa ou ruim, avise: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (veja feedback).

Teste na sua máquina

Rode o servidor com node --env-file=.env app.js (no NestJS, npm run start:dev) e abra em localhost. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou ::1, e não tem problema.

  1. Defina KORZEN_API_KEY em .env e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie _kz aparece depois de alguns segundos.
  2. Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
  3. Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde 401, e o cadastro passa.

Erros comuns

  • trustProxy: 1 no Fastify 5. Não confia em nenhum proxy, e o request.ip é o endereço do seu load balancer para todo visitante.
  • trustProxy: true, ou trust proxy como true no NestJS. Confia em todo salto, e o IP vira o que o visitante escreveu no X-Forwarded-For.
  • No NestJS, korzen.js fora dos assets do nest-cli.json: o build fica sem dist/korzen.js, e o servidor não sobe.
  • Chamar /v1/decide do navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor.
  • Sem timeout, ou um erro que trava o cadastro. Se a resposta não for 200, ou não chegar a tempo, deixe o visitante passar.