Korzen
Bot Protection

Web-standard Request

Bot Protection in Hono, Bun, Deno, React Router en Remix, Astro, SvelteKit en Nuxt: één clientbestand, fromRequest op het Request van de Fetch API, en het IP-adres van de bezoeker op elk platform.

Elke server op deze pagina geeft je code het Request van de Fetch API, dus één client dient ze allemaal: korzen.js, één bestand zonder dependencies, dat /v1/decide vraagt, opgeeft bij je timeout en de bezoeker bij elke fout doorlaat. Alleen waar het request binnenkomt, en waar het IP-adres van de bezoeker staat, verschillen. Voor Next.js, zie Next.js; voor een Cloudflare Worker, Cloudflare Workers.

De tag

Zet de tag in de <head> van de layout die je registratie-, inlog- en checkoutpagina's rendert:

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

Roep op de pagina, voordat het formulier wordt verstuurd, await window.korzen?.ready() aan: het meldt wat de bezoeker deed sinds de tag het laatst meldde, en is hoe dan ook binnen een paar seconden klaar. De tag

De client

Download hem in de map van je servercode, en lees hem voordat je hem 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

Daarna één client voor het proces, ernaast. De voorbeelden hieronder importeren hem uit korzen.js; noem hem in React Router app/korzen.server.js en in SvelteKit src/lib/server/korzen.js, zodat hij nooit in de bundle van de browser belandt:

// 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 })) leest de User-Agent van het request, de headers die Bot Protection leest, de _kz-cookie, het pad en de methode, en geeft het antwoord terug, of null bij elke fout. Hij leest geen body, dus je code leest het formulier daarna nog steeds. De Headers van de Fetch API houden geen volgorde bij, dus er wordt geen headervolgorde meegestuurd.

Hono

Een middleware op de route die het account aanmaakt; dezelfde app draait op Node, Bun en 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

Met de routes van Bun.serve (Bun 1.2.3 en nieuwer) geeft de server het adres van de verbinding:

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

In de action van de route die het account aanmaakt (de framework mode van React Router, waarin Remix is opgegaan):

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

Een endpoint dat op aanvraag wordt gerenderd, met een serveradapter (zoals @astrojs/node), die clientAddress geeft:

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

In de form action van de registratiepagina; getClientAddress() is het IP-adres dat je adapter leest:

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

Een serverroute. fromHeaders(event.headers) leest de headers van het request en laat de body over aan readFormData; een tweede Request bouwen met toWebRequest(event) zou het formulier ongelezen laten:

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

Het IP-adres van de bezoeker

Stuur het adres dat je eigen proxy zag, nooit een adres dat de bezoeker zelf had kunnen schrijven. Staat er niets voor je server, dan is dat het adres van de verbinding: server.requestIP(req) in Bun, info.remoteAddr in Deno. Achter één load balancer of nginx van jezelf is het de laatste entry van X-Forwarded-For, die je proxy heeft toegevoegd: ipFrom.lastForwarded. Achter Cloudflare leest ipFrom.cloudflare CF-Connecting-IP, als je server alleen verkeer van Cloudflare accepteert.

De clientAddress van Astro en de getClientAddress() van SvelteKit zijn wat de adapter leest. Met @sveltejs/adapter-node achter een proxy zet je ADDRESS_HEADER=X-Forwarded-For en XFF_DEPTH=1 (één proxy van jezelf), waar elk request via die proxy binnenkomt: de adapter weigert een request zonder die header. In Nuxt neemt getRequestIP(event, { xForwardedFor: true }) van h3 de eerste entry, en die schrijft de bezoeker: gebruik ipFrom.lastForwarded, zoals hierboven.

Voer de actie uit

Lees alleen action. Bij block antwoord je met dezelfde foutmelding die de actie toont als hij om een andere reden mislukt, zodat een bot niets leert. Bij challenge gebruik je de controle die je al hebt (een CAPTCHA, een code per e-mail of telefoon); heb je er geen, ga dan door. Het account begint in monitor mode, waar action altijd allow is: voor bezoekers verandert er niets tot je block mode aanzet. Of stuur de bezoeker naar onze challengepagina: challengeRedirect(d, request.url) geeft het adres en de cookie om een 303 mee te beantwoorden (zie de challengepagina).

Bewaar de request_id

Elk antwoord heeft een request_id. Bewaar die bij wat de actie heeft aangemaakt, in een kolom van het account of de bestelling, of in een logregel met hun id. Zo weet je welke beslissing het was als je ons ernaar vraagt, en kun je later nagaan welk antwoord een nepaccount binnenliet. Blijkt het account goed of slecht, meld het dan terug: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (zie feedback).

Test het op je eigen machine

Start je dev-server en open hem op localhost. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP-adres 127.0.0.1 of ::1, en dat is prima; met ipFrom.lastForwarded en geen proxy ervoor is er geen, en ook dat is prima.

  1. Zet KORZEN_API_KEY in de omgeving van de server en open de registratiepagina. In de developer tools van de browser verschijnt na een paar seconden de _kz-cookie.
  2. Registreer je. De beslissing verschijnt op de pagina Beslissingen van het dashboard, onder het pad dat je stuurde.
  3. Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met 401, en de registratie gaat door.

Veelgemaakte fouten

  • De eerste entry van X-Forwarded-For, zoals ipFrom.vercel of xForwardedFor van h3 die leest, achter een proxy die eraan toevoegt. Die entry schrijft de bezoeker zelf, dus een bot kiest zijn eigen IP-adres. ipFrom.vercel klopt alleen op Vercel, dat de header overschrijft.
  • De client geïmporteerd in code die de browser bereikt. De API key zou dan bij elke bezoeker terechtkomen; houd hem in een module die alleen op de server draait.
  • Een Astro-pagina die geprerenderd blijft. Zonder prerender = false (of output: "server") heeft het endpoint geen request om te beoordelen, en geeft clientAddress een fout.
  • Geen timeout, of een fout die de registratie tegenhoudt. Elk ander antwoord dan 200, en geen antwoord op tijd, betekent: laat de bezoeker door.