Korzen
Bot Protection

Request padrão da Web

Bot Protection em Hono, Bun, Deno, React Router e Remix, Astro, SvelteKit e Nuxt: um arquivo de cliente, fromRequest sobre o Request da Fetch API e o IP do visitante em cada um.

Todo servidor desta página entrega ao seu código o Request da Fetch API, então um cliente serve a todos: 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. Só mudam onde a requisição chega e onde está o IP do visitante. Para o Next.js, veja Next.js; para um Cloudflare Worker, Cloudflare Workers.

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

O cliente

Baixe-o para a pasta do código do seu servidor e leia-o antes do commit:

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

Depois, um cliente para o processo, ao lado dele. Os exemplos abaixo o importam de korzen.js; no React Router chame-o de app/korzen.server.js, no SvelteKit de src/lib/server/korzen.js, para que ele nunca chegue ao bundle do navegador:

// korzen.js: one client for the process
import { createClient } from "./bot-protection.js";

export const korzen = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 });
export { fromHeaders, fromRequest, ipFrom } from "./bot-protection.js";

korzen.decide(fromRequest(request, { ip })) lê o User-Agent da requisição, os headers que o Bot Protection lê, o cookie _kz, o path e o método, e devolve a resposta, ou null em qualquer erro. Ele não lê o corpo, então o seu código ainda lê o formulário depois. Os Headers da Fetch API não guardam ordem, então nenhuma ordem de headers é enviada.

Hono

Um middleware na rota que cria a conta; o mesmo app roda no Node, no Bun e no Deno:

// app.js
import { Hono } from "hono";
import { serve } from "@hono/node-server"; // on Bun or Deno, serve app.fetch with their own server
import { fromRequest, ipFrom, korzen } from "./korzen.js";

const app = new Hono();

// Asks before the handler, and keeps the answer as c.get("korzen") (null when there is none).
async function guard(c, next) {
  const d = await korzen.decide(fromRequest(c.req.raw, { ip: ipFrom.lastForwarded })); // see "The visitor's IP"
  if (d?.action === "block") return c.text("We could not create your account.", 400);
  // "challenge": your CAPTCHA, or an email or phone check, before going on
  c.set("korzen", d);
  await next();
}

app.post("/signup", guard, async (c) => {
  const user = await createAccount(await c.req.parseBody()); // your own code
  await saveRequestID(user.id, c.get("korzen")?.request_id); // see "Keep the request_id"
  return c.redirect("/welcome", 303);
});

serve({ fetch: app.fetch, port: 3000 });

Bun

Com as rotas do Bun.serve (Bun 1.2.3 em diante), o servidor dá o endereço da conexão:

// server.js: bun run server.js
import { fromRequest, korzen } from "./korzen.js";

Bun.serve({
  port: 3000,
  routes: {
    "/signup": {
      POST: async (req, server) => {
        const d = await korzen.decide(fromRequest(req, { ip: server.requestIP(req)?.address })); // see "The visitor's IP"
        if (d?.action === "block") return new Response("We could not create your account.", { status: 400 });
        // "challenge": your CAPTCHA, or an email or phone check, before going on
        const user = await createAccount(await req.formData()); // your own code
        await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
        return Response.redirect(new URL("/welcome", req.url), 303);
      },
    },
  },
});

Deno

// server.js: deno run --allow-net --allow-env server.js
import { fromRequest, korzen } from "./korzen.js";

Deno.serve({ port: 3000 }, async (req, info) => {
  if (req.method !== "POST" || new URL(req.url).pathname !== "/signup") return new Response("Not found", { status: 404 });
  const d = await korzen.decide(fromRequest(req, { ip: info.remoteAddr.hostname })); // see "The visitor's IP"
  if (d?.action === "block") return new Response("We could not create your account.", { status: 400 });
  // "challenge": your CAPTCHA, or an email or phone check, before going on
  const user = await createAccount(await req.formData()); // your own code
  await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
  return Response.redirect(new URL("/welcome", req.url), 303);
});

React Router e Remix

Na action da rota que cria a conta (o framework mode do React Router, em que o Remix se transformou):

// app/routes/signup.jsx
import { data, redirect } from "react-router";
import { fromRequest, ipFrom, korzen } from "../korzen.server.js";

export async function action({ request }) {
  const d = await korzen.decide(fromRequest(request, { ip: ipFrom.lastForwarded })); // see "The visitor's IP"
  if (d?.action === "block") return data({ error: "We could not create your account." }, { status: 400 });
  // "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); // see "Keep the request_id"
  return redirect("/welcome");
}

Astro

Um endpoint renderizado sob demanda, com um adapter de servidor (como @astrojs/node), que fornece o clientAddress:

// src/pages/api/signup.js
import { fromRequest, korzen } from "../../korzen.js";

export const prerender = false;

export async function POST({ request, clientAddress, redirect }) {
  const d = await korzen.decide(fromRequest(request, { ip: clientAddress, path: "/signup" })); // see "The visitor's IP"
  if (d?.action === "block") return new Response("We could not create your account.", { status: 400 });
  // "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); // see "Keep the request_id"
  return redirect("/welcome", 303);
}

SvelteKit

Na form action da página de cadastro; getClientAddress() é o IP que o seu adapter lê:

// src/routes/signup/+page.server.js
import { fail, redirect } from "@sveltejs/kit";
import { fromRequest, korzen } from "$lib/server/korzen.js";

export const actions = {
  default: async ({ request, getClientAddress }) => {
    const d = await korzen.decide(fromRequest(request, { ip: getClientAddress() })); // see "The visitor's IP"
    if (d?.action === "block") return fail(400, { error: "We could not create your account." });
    // "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); // see "Keep the request_id"
    redirect(303, "/welcome");
  },
};

Nuxt

Uma server route. fromHeaders(event.headers) lê os headers da requisição e deixa o corpo para o readFormData; montar um segundo Request com toWebRequest(event) deixaria o formulário sem ler:

// server/api/signup.post.js
import { fromHeaders, ipFrom, korzen } from "../utils/korzen.js";

export default defineEventHandler(async (event) => {
  const d = await korzen.decide(fromHeaders(event.headers, { ip: ipFrom.lastForwarded, path: "/api/signup" })); // see "The visitor's IP"
  if (d?.action === "block") {
    setResponseStatus(event, 400);
    return "We could not create your account.";
  }
  // "challenge": your CAPTCHA, or an email or phone check, before going on
  const user = await createAccount(await readFormData(event)); // your own code
  await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
  return sendRedirect(event, "/welcome", 303);
});

O IP do visitante

Envie o endereço que o seu próprio proxy viu, nunca um que o visitante pudesse ter escrito. Sem nada na frente do seu servidor, é o endereço da conexão: server.requestIP(req) no Bun, info.remoteAddr no Deno. Atrás de um load balancer ou nginx seu, é a última entrada do X-Forwarded-For, a que o seu proxy acrescentou: ipFrom.lastForwarded. Atrás do Cloudflare, ipFrom.cloudflare lê o CF-Connecting-IP, se o seu servidor só recebe tráfego do Cloudflare.

O clientAddress do Astro e o getClientAddress() do SvelteKit são o que o adapter lê. Com @sveltejs/adapter-node atrás de um proxy, defina ADDRESS_HEADER=X-Forwarded-For e XFF_DEPTH=1 (um proxy seu), onde toda requisição passa por esse proxy: o adapter recusa uma requisição sem o header. No Nuxt, o getRequestIP(event, { xForwardedFor: true }) do h3 pega a primeira entrada, que quem escreve é o visitante: use ipFrom.lastForwarded, como acima.

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: challengeRedirect(d, request.url) dá o endereço e o cookie para responder com um 303 (veja a página de desafio).

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 seu servidor de desenvolvimento 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; com ipFrom.lastForwarded e nenhum proxy na frente, não há IP, e também não tem problema.

  1. Defina KORZEN_API_KEY no ambiente do servidor 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

  • A primeira entrada do X-Forwarded-For, como ipFrom.vercel ou o xForwardedFor do h3 a leem, atrás de um proxy que acrescenta ao header. Quem define essa entrada é o visitante, então o bot escolhe o próprio IP. ipFrom.vercel só está certo na Vercel, que sobrescreve o header.
  • O cliente importado num código que chega ao navegador. A API key ficaria exposta para todo visitante; mantenha-o num módulo só do servidor.
  • Uma página do Astro deixada pré-renderizada. Sem prerender = false (ou output: "server"), o endpoint não tem requisição para julgar, e o clientAddress lança um erro.
  • 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.