Korzen
Bot Protection

Request standard del Web

Bot Protection in Hono, Bun, Deno, React Router e Remix, Astro, SvelteKit e Nuxt: un solo file del client, fromRequest sulla Request della Fetch API, e l'IP del visitatore su ciascuno.

Ogni server di questa pagina passa al tuo codice la Request della Fetch API, quindi un solo client li serve tutti: korzen.js, un solo file senza dipendenze, che chiede a /v1/decide, rinuncia allo scadere del tuo timeout e lascia passare il visitatore a ogni errore. Cambiano solo il punto in cui arriva la richiesta e dove si trova l'IP del visitatore. Per Next.js, vedi Next.js; per un Cloudflare Worker, Cloudflare Workers.

Il tag

Metti il tag nel <head> del layout che renderizza le pagine di registrazione, login e checkout:

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

Nella pagina, prima di inviare il form, chiama await window.korzen?.ready(): riporta ciò che il visitatore ha fatto dall'ultimo rapporto del tag e si risolve entro pochi secondi qualunque cosa accada. Il tag

Il client

Scaricalo nella cartella del codice del tuo server e leggilo prima del 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

Poi un solo client per il processo, accanto. Gli esempi qui sotto lo importano da korzen.js; in React Router chiamalo app/korzen.server.js, in SvelteKit src/lib/server/korzen.js, così non arriva mai nel bundle del browser:

// 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 })) legge lo User-Agent della richiesta, gli header che Bot Protection legge, il cookie _kz, il path e il metodo, e restituisce la risposta, o null a ogni errore. Non legge il body, quindi il tuo codice legge comunque il form dopo. Gli Headers della Fetch API non tengono l'ordine, quindi l'ordine degli header non viene inviato.

Hono

Un middleware sulla route che crea l'account; la stessa app gira su Node, Bun e 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 le route di Bun.serve (Bun 1.2.3 e successivi), il server dà l'indirizzo della connessione:

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

Nell'action della route che crea l'account (il framework mode di React Router, in cui è confluito 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 renderizzato su richiesta, con un adapter server (come @astrojs/node), che fornisce 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

Nella form action della pagina di registrazione; getClientAddress() è l'IP che legge il tuo 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 server route. fromHeaders(event.headers) legge gli header della richiesta e lascia il body a readFormData; costruire una seconda Request con toWebRequest(event) lascerebbe il form non letto:

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

L'IP del visitatore

Invia l'indirizzo che ha visto il tuo proxy, mai uno che il visitatore possa aver scritto. Senza niente davanti al tuo server, è l'indirizzo della connessione: server.requestIP(req) in Bun, info.remoteAddr in Deno. Dietro un tuo load balancer o nginx, è l'ultima voce di X-Forwarded-For, quella che il tuo proxy ha aggiunto: ipFrom.lastForwarded. Dietro Cloudflare, ipFrom.cloudflare legge CF-Connecting-IP, se il tuo server accetta traffico solo da Cloudflare.

clientAddress di Astro e getClientAddress() di SvelteKit sono ciò che legge l'adapter. Con @sveltejs/adapter-node dietro un proxy, imposta ADDRESS_HEADER=X-Forwarded-For e XFF_DEPTH=1 (un tuo proxy), dove ogni richiesta passa da quel proxy: l'adapter rifiuta una richiesta senza l'header. In Nuxt, getRequestIP(event, { xForwardedFor: true }) di h3 prende la prima voce, che scrive il visitatore: usa ipFrom.lastForwarded, come sopra.

Applica l'azione

Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode. Oppure manda il visitatore alla nostra pagina di challenge: challengeRedirect(d, request.url) dà l'indirizzo e il cookie con cui rispondere con un 303 (vedi la pagina di challenge).

Conserva il request_id

Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso. Quando l'account si rivela buono o cattivo, comunicalo: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (vedi feedback).

Provalo sulla tua macchina

Avvia il tuo server di sviluppo e aprilo su localhost. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o ::1, e va bene così; con ipFrom.lastForwarded e nessun proxy davanti, non c'è IP, e va bene lo stesso.

  1. Imposta KORZEN_API_KEY nell'ambiente del server e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie _kz compare dopo qualche secondo.
  2. Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
  3. Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde 401, e la registrazione va a buon fine.

Errori comuni

  • La prima voce di X-Forwarded-For, come la leggono ipFrom.vercel o xForwardedFor di h3, dietro un proxy che vi aggiunge voci. Quella voce la scrive il visitatore, quindi un bot si sceglie il proprio IP. ipFrom.vercel è corretto solo su Vercel, che sovrascrive l'header.
  • Il client importato in codice che arriva al browser. L'API key arriverebbe a ogni visitatore; tienilo in un modulo solo server.
  • Una pagina Astro lasciata prerenderizzata. Senza prerender = false (o output: "server"), l'endpoint non ha una richiesta da giudicare, e clientAddress lancia un errore.
  • Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da 200, o nessuna risposta in tempo, significa: lascia passare il visitatore.