Korzen
Bot Protection

Cloudflare Workers

Bot Protection in een Cloudflare Worker, vóór je origin: de beslissing aan de edge, CF-Connecting-IP, en de request door naar je app.

Staat je site achter Cloudflare, dan kan een Worker op de routes die je beschermt Bot Protection raadplegen voordat de request je origin bereikt: zonder enige wijziging in je applicatie. Hij draait aan de edge van Cloudflare, dicht bij korzen.dev, dat ook achter Cloudflare staat. Op een statische site doet een Cloudflare Pages Function hetzelfde voor één actie, zoals op onze eigen blog.

De tag

De tag hoort nog steeds op je pagina's, in de <head>, zodat de _kz-cookie er is als de bezoeker het formulier verstuurt:

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

De Worker

De client is één bestand zonder dependencies: hij vraagt /v1/decide, geeft op bij je timeout en laat de bezoeker door bij elke fout. Download hem naast je Worker en lees hem voordat je hem commit:

curl -o src/bot-protection.js https://korzen.dev/bot-protection/sdk/korzen.js

createWorker is de hele Worker: hij vraagt naar elke request op zijn routes met jouw timeout, en haalt zijn instellingen uit de omgeving van de Worker:

// src/index.js: a Worker on the routes Bot Protection judges, in front of the cache and your origin.
// wrangler.toml:
//   routes = [
//     { pattern = "tickets.example/api/resale/*", zone_name = "tickets.example" },
//     { pattern = "tickets.example/orders/*", zone_name = "tickets.example" },
//   ]
//   [vars]
//   KORZEN_URL = "https://korzen.dev"
//   KORZEN_TIMEOUT_MS = "200"
//   KORZEN_REQUIRE_TOKEN = "/orders/*"
// The key: npx wrangler secret put KORZEN_API_KEY
import { createWorker } from "./bot-protection.js";

export default createWorker();
  • KORZEN_API_KEY: je geheime sleutel, als secret van de Worker. Zonder sleutel gaat elke request onbeoordeeld door, en de log van de Worker meldt het één keer.
  • KORZEN_URL: https://korzen.dev.
  • KORZEN_TIMEOUT_MS: hoe lang een beslissing mag duren, 10 tot 1000 ms (200 als het niet is ingesteld). Daarna gaat de request door.
  • KORZEN_REQUIRE_TOKEN: de paden waarvan de acties het token van de tag nodig hebben, gescheiden door komma's; een pad dat eindigt op * is een prefix.

Bij block antwoordt de Worker met 403. Bij challenge, in block mode, gaat het laden van een pagina naar de challengepagina en komt terug met een token, dat hij bewaart als cookie _kz; een formulierpost of API-call met een challenge krijgt 403. Al het andere gaat door naar de cache van Cloudflare en je origin, met de headers Korzen-Request-Id en Korzen-Action die je applicatie kan loggen. Elke fout of timeout laat de request door: is Bot Protection traag of onbereikbaar, dan werkt je site zoals zonder de Worker.

Acties die een token nodig hebben

Een actie op een pad in KORZEN_REQUIRE_TOKEN (de POST, PUT, PATCH of DELETE: het laden van een pagina verbruikt geen token) gaat alleen door met een token dat de tag in die browser verdiende, één keer per sessie. Een script dat nooit JavaScript uitvoert, wordt bij elke poging geweigerd, vanaf welk adres het ook komt. Zet de tag op de pagina die naar de actie leidt, en die pagina ook op de routes van de Worker, zodat een mens een challenge al bij het laden ziet.

Polling uit de cache

De Worker draait vóór de cache van Cloudflare: een request die de cache beantwoordt, bereikt je server nooit, maar wel de Worker. Zet de Worker op de routes die gepolld worden (een voorraad, een stoelenplan) en elke poll wordt beoordeeld; de toegestane komen nog steeds uit de cache. Elke request op de routes van de Worker is een beslissing binnen het volume van je plan, dus route alleen wat je beoordeeld wilt hebben.

Wat Cloudflare toevoegt

Met elke beslissing stuurt de Worker mee wat Cloudflare van de bezoeker heeft gemeten (request.cf): op elk plan het netwerk (ASN), het land, de HTTP-versie en de TCP-round-trip tussen de bezoeker en Cloudflare (geen via HTTP/3); met Enterprise Bot Management ook de JA4-fingerprint en de detection-id's. Bot Protection legt die round-trip naast die welke de tag op de pagina mat: een opname van een ander netwerk claimt een round-trip die de eigen verbinding van de bezoeker niet kan hebben. Geen van deze blokkeert een bezoeker op zichzelf.

Een eigen handler

createWorker dekt het gewone geval. Voor een Worker die meer doet, wikkel je je eigen fetch in withKorzen; die stuurt net zo mee wat Cloudflare heeft gemeten.

withKorzen omhult de fetch van de Worker: hij leest wat Cloudflare van de bezoeker weet en vraagt met je timeout. Bij block antwoordt hij zelf 403. Bij challenge gaat in block mode het laden van een pagina naar de challengepagina en komt terug met een token, dat hij bewaart als de cookie _kz: de tag zet die sessie voort, en de volgende beslissingen ervan zijn allow. Al het andere gaat door naar je origin, met het id en de actie van de beslissing in headers die je applicatie kan loggen:

// src/index.js: a Worker on the routes you protect, in front of your origin.
// wrangler.toml:  routes = [{ pattern = "shop.example/signup*", zone_name = "shop.example" }]
// The key:        npx wrangler secret put KORZEN_API_KEY
import { createClient, ipFrom, withKorzen } from "./bot-protection.js";

let protect; // made once per isolate, with the key from env

export default {
  fetch(request, env, ctx) {
    protect ??= withKorzen(
      async (request, env, ctx, d) => {
        // on to your origin, with the decision's id and action
        const forward = new Request(request);
        if (d?.request_id) forward.headers.set("Korzen-Request-Id", d.request_id);
        if (d?.action) forward.headers.set("Korzen-Action", d.action);
        return fetch(forward);
      },
      {
        client: createClient({ apiKey: env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 }),
        ip: ipFrom.cloudflare, // CF-Connecting-IP: Cloudflare sets it
      },
    );
    return protect(request, env, ctx);
  },
};

Een formulier dat een challenge krijgt kan niet naar de pagina en terug: withKorzen weigert het met 403. Met een eigen controle op je origin geef je onChallenge: () => undefined mee, en dan komt het bij je origin aan met Korzen-Action: challenge. Zet ook de pagina met het formulier op de routes van de Worker, dan krijgt de bezoeker de challenge als die laadt.

Het IP-adres van de bezoeker

CF-Connecting-IP wordt door Cloudflare gezet en kan niet door de bezoeker worden ingevuld, dus een Worker heeft altijd het juiste adres. Je origin moet nog steeds alleen verkeer van Cloudflare accepteren, anders kan een bot de Worker overslaan door de origin direct aan te roepen.

De headers en hun volgorde

withKorzen stuurt de headers waarvan een browserversie de waarden vastlegt (Accept, Accept-Encoding, de client hints, Sec-Fetch-*) en Accept-Language: een script dat de User-Agent van een browser kopieert, kopieert ze zelden allemaal. Hun volgorde (header_order) stuurt het niet: in een Worker geeft request.headers de namen gesorteerd, niet zoals ze binnenkwamen, dus het veld blijft weg en de beslissing leest de rest. Op je eigen Node-server stuurt fromNodeRequest de volgorde uit rawHeaders.

Test het

npx wrangler dev draait de Worker op je eigen machine; zet de key in .dev.vars als KORZEN_API_KEY=…. Daarna npx wrangler deploy, en volg de beslissingen op de pagina Beslissingen van het dashboard.

Veelgemaakte fouten

  • Een Worker op elke route: elke request op zijn routes is een beslissing. Route alleen wat je beoordeeld wilt hebben: de polling die je in de gaten houdt, registratie, inlog, checkout.
  • Een pad in KORZEN_REQUIRE_TOKEN waarvan de pagina's geen tag hebben: daar heeft geen bezoeker een token, dus wordt elke actie geweigerd.
  • De body van de request lezen om te beslissen, en daarna een al gebruikte body doorsturen: de body van een Request kan maar één keer worden gelezen.
  • Een origin die openstaat voor het hele internet: de Worker bewaakt alleen de requests die via Cloudflare gaan.