Korzen
Bot Protection

Request estándar de la Web

Bot Protection en Hono, Bun, Deno, React Router y Remix, Astro, SvelteKit y Nuxt: un solo archivo del cliente, fromRequest sobre el Request de la Fetch API y la IP del visitante en cada uno.

Todos los servidores de esta página entregan a tu código el Request de la Fetch API, así que un solo cliente sirve para todos: korzen.js, un solo archivo sin dependencias, que pregunta a /v1/decide, se rinde al llegar a tu timeout y deja pasar al visitante ante cualquier error. Solo cambian dónde llega la petición y dónde está la IP del visitante. Para Next.js, consulta Next.js; para un Worker de Cloudflare, Cloudflare Workers.

El tag

Pon el tag en el <head> del layout que renderiza tus páginas de registro, inicio de sesión y checkout:

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

En la página, antes de enviar el formulario, llama a await window.korzen?.ready(): informa lo que el visitante hizo desde el último informe del tag y se resuelve en pocos segundos pase lo que pase. El tag

El cliente

Descárgalo en la carpeta del código de tu servidor y léelo antes de hacer 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

Después, un cliente para el proceso, a su lado. Los ejemplos de abajo lo importan de korzen.js; en React Router llámalo app/korzen.server.js, en SvelteKit src/lib/server/korzen.js, para que nunca llegue al bundle del 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 })) lee el User-Agent de la petición, los headers que lee Bot Protection, la cookie _kz, el path y el método, y devuelve la respuesta, o null ante cualquier error. No lee el body, así que tu código sigue leyendo el formulario después. Los Headers de la Fetch API no guardan orden, así que no se envía el orden de los headers.

Hono

Un middleware en la ruta que crea la cuenta; la misma app corre en Node, Bun y 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

Con las rutas de Bun.serve (Bun 1.2.3 y posterior), el servidor da la dirección de la conexión:

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

En la action de la ruta que crea la cuenta (el framework mode de React Router, en el que se convirtió Remix):

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

Un endpoint renderizado bajo demanda, con un adapter de servidor (como @astrojs/node), que da 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

En la form action de la página de registro; getClientAddress() es la IP que lee tu adapter:

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

Una ruta de servidor. fromHeaders(event.headers) lee los headers de la petición y deja su body a readFormData; construir un segundo Request con toWebRequest(event) dejaría el formulario sin leer:

// 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);
});

La IP del visitante

Envía la dirección que vio tu propio proxy, nunca una que el visitante pudiera haber escrito. Sin nada delante de tu servidor, es la dirección de la conexión: server.requestIP(req) en Bun, info.remoteAddr en Deno. Detrás de un load balancer o un nginx tuyo, es la última entrada de X-Forwarded-For, la que añadió tu proxy: ipFrom.lastForwarded. Detrás de Cloudflare, ipFrom.cloudflare lee CF-Connecting-IP, si tu servidor solo recibe tráfico de Cloudflare.

El clientAddress de Astro y el getClientAddress() de SvelteKit son lo que lee el adapter. Con @sveltejs/adapter-node detrás de un proxy, define ADDRESS_HEADER=X-Forwarded-For y XFF_DEPTH=1 (un proxy tuyo), donde todas las peticiones pasen por ese proxy: el adapter rechaza una petición sin el header. En Nuxt, getRequestIP(event, { xForwardedFor: true }) de h3 toma la primera entrada, que escribe el visitante: usa ipFrom.lastForwarded, como arriba.

Actúa según la action

Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, sigue adelante. La cuenta empieza en monitor mode, donde action es siempre allow: nada cambia para los visitantes hasta que actives el block mode. O envía al visitante a nuestra página de desafío: challengeRedirect(d, request.url) da la dirección y la cookie con las que responder un 303 (consulta la página de desafío).

Guarda el request_id

Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa. Cuando la cuenta resulte buena o mala, avísanos: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (consulta feedback).

Pruébalo en tu máquina

Arranca tu servidor de desarrollo y ábrelo en localhost. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o ::1, y es normal; con ipFrom.lastForwarded y sin proxy delante, no hay ninguna, y también es normal.

  1. Define KORZEN_API_KEY en el entorno del servidor y abre la página de registro. En las herramientas de desarrollo del navegador, la cookie _kz aparece tras unos segundos.
  2. Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
  3. Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde 401, y el registro pasa.

Errores comunes

  • La primera entrada de X-Forwarded-For, como la leen ipFrom.vercel o el xForwardedFor de h3, detrás de un proxy que la añade. Esa entrada la escribe el visitante, así que un bot elige su propia IP. ipFrom.vercel solo es correcto en Vercel, que sobrescribe el header.
  • El cliente importado en código que llega al navegador. La API key quedaría expuesta a cada visitante; mantenlo en un módulo solo de servidor.
  • Una página de Astro que sigue prerenderizada. Sin prerender = false (o output: "server"), el endpoint no tiene petición que juzgar, y clientAddress lanza un error.
  • No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de 200, o la falta de respuesta a tiempo, significa: deja pasar al visitante.