Korzen
Bot Protection

Next.js

Bot Protection num projeto Next.js com App Router: a tag no root layout, a chamada numa server action, o IP do visitante na Vercel e atrás de um proxy.

Este guia é para o App Router, Next.js 15 em diante. Você põe a tag no root layout e chama o Bot Protection na server action ou no route handler que cria a conta.

A tag

No root layout, para que ela esteja em todas as páginas:

// app/layout.js
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
      </head>
      <body>{children}</body>
    </html>
  );
}

A chamada

O cliente é um arquivo só, sem dependências: ele pergunta ao /v1/decide, desiste no seu timeout e deixa o visitante passar em qualquer erro. Baixe-o para lib/ e leia-o antes do commit:

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

Depois, uma função para todas as ações protegidas:

// lib/korzen.js
import { headers } from "next/headers";
import { createClient, fromHeaders, ipFrom } from "./bot-protection.js";

export const client = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 });

// decide asks Bot Protection about the request being handled. It returns the answer,
// or null on any error: the caller then goes on as on allow (fail open).
export async function decide(path) {
  // ipFrom.vercel on Vercel; see "The visitor's IP"
  return client.decide(fromHeaders(await headers(), { path, ip: ipFrom.vercel }));
}

Depois, na server action que cria a conta:

// app/signup/actions.js
"use server";
import { decide } from "@/lib/korzen";

export async function signUp(prevState, formData) {
  const d = await decide("/signup");
  if (d?.action === "block") return { error: "We could not create your account." };
  if (d?.action === "challenge") {
    // your CAPTCHA, or an email or phone check, before going on
  }
  const user = await createAccount(formData); // your own code
  await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
  // ...
}

Num route handler, o withKorzen o envolve: responde 403 em block e passa a resposta ao handler depois dos argumentos dele:

// app/api/signup/route.js
import { ipFrom, withKorzen } from "@/lib/bot-protection";
import { client } from "@/lib/korzen";

export const POST = withKorzen(
  async (request, ctx, d) => {
    if (d?.action === "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);
    return Response.json({ ok: true });
  },
  // onChallenge answers nothing, so the handler runs its own check; without it, a
  // challenged post is refused (403)
  { client, ip: ipFrom.vercel, onChallenge: () => undefined },
);

O IP do visitante

Na Vercel, x-forwarded-for traz o IP do visitante: a Vercel o sobrescreve, então o visitante não consegue forjá-lo. Fora dela, leia o cabeçalho que a sua própria borda define: cf-connecting-ip atrás do Cloudflare, x-real-ip se o seu nginx o define. Atrás de um proxy que acrescenta ao x-forwarded-for em vez de sobrescrevê-lo, a primeira entrada é o que o visitante mandou: use a última que o seu proxy acrescentou.

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. Ou mande o visitante para a nossa página de desafio, em challenge.url: veja a página de desafio.

Sem onChallenge, o middleware não deixa passar nenhum desafio: um carregamento de página (GET) vai para a nossa página de desafio, e um envio de formulário ou uma chamada de API é recusado com 403. Proteja também a página que mostra o formulário, para que a pessoa encontre o desafio ali e a aprovação dela deixe o envio passar. Com uma verificação sua, passe onChallenge e rode-a ali. O middleware manda a pessoa de volta ao endereço que ela pediu, lido de X-Forwarded-Host e X-Forwarded-Proto quando seu proxy os define; atrás de um proxy que repassa Host: localhost e nenhum dos dois (o padrão do nginx), dê publicURL: "https://shop.example".

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.

Teste na sua máquina

Rode next dev e abra http://localhost:3000. 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. No Docker na sua máquina, o app vê em vez disso o endereço da bridge, como 172.17.0.1: a página de desafio ainda traz você de volta para localhost, porque a tag viu sua página lá.

  1. Defina KORZEN_API_KEY em .env.local 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

  • Ler a primeira entrada do x-forwarded-for atrás de um proxy que acrescenta a ele. Aí o bot escolhe o próprio IP.
  • Um formulário enviado por JavaScript logo que a página carrega, antes de a tag reportar. Chame await window.korzen?.ready() antes de enviar; resolve em poucos segundos mesmo com o Bot Protection lento.
  • A chamada no middleware. Ele roda em toda requisição, e toda chamada conta como decisão: chame só na ação que você protege.
  • 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.
  • Uma linha no seu log que começa com korzen: the visitor's IP sent to Bot Protection. O cliente a escreve uma vez, na primeira decisão cujo IP é da sua própria rede (de um load balancer, de um proxy, de um contêiner), que o Bot Protection julga como sem IP: defina ip como o endereço do visitante (o IP do visitante). No Docker na sua máquina, é o endereço da bridge, e ali não faz mal.