Korzen
Bot Protection

Cloudflare Workers

Bot Protection dans un Cloudflare Worker, devant votre origine : la décision à l'edge, CF-Connecting-IP, et la requête transmise à votre application.

Quand votre site est derrière Cloudflare, un Worker sur les routes que vous protégez peut interroger Bot Protection avant que la requête n'atteigne votre origine : aucun changement dans votre application. Il tourne à l'edge de Cloudflare, près de korzen.dev, qui est aussi derrière Cloudflare. Sur un site statique, une Cloudflare Pages Function fait de même pour une action, comme sur notre propre blog.

Le tag

Le tag va quand même sur vos pages, dans le <head>, pour que le cookie _kz soit là quand le visiteur envoie le formulaire :

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

Le Worker

Le client tient en un seul fichier sans dépendance : il interroge /v1/decide, abandonne à votre timeout et laisse passer le visiteur à la moindre erreur. Téléchargez-le à côté de votre Worker, et lisez-le avant de le committer :

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

createWorker est le Worker tout entier : il interroge sur chaque requête de ses routes avec votre timeout, et prend ses réglages dans l'environnement du 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 : votre clé secrète, en secret du Worker. Sans elle, chaque requête passe sans être jugée, et le log du Worker le dit une fois.
  • KORZEN_URL : https://korzen.dev.
  • KORZEN_TIMEOUT_MS : combien de temps une décision peut prendre, de 10 à 1000 ms (200 sans réglage). Au-delà, la requête passe.
  • KORZEN_REQUIRE_TOKEN : les chemins dont les actions demandent le token du tag, séparés par des virgules ; un chemin qui finit par * est un préfixe.

Sur block, le Worker répond 403. Sur challenge, en block mode, un chargement de page va vers la page de challenge et revient avec un token, qu'il garde dans le cookie _kz ; un envoi de formulaire ou un appel d'API soumis au challenge reçoit 403. Tout le reste continue vers le cache de Cloudflare et votre origine, avec les headers Korzen-Request-Id et Korzen-Action que votre application peut journaliser. Toute erreur ou tout timeout laisse passer la requête : si Bot Protection est lent ou indisponible, votre site fonctionne comme sans le Worker.

Les actions qui demandent un token

Une action sur un chemin de KORZEN_REQUIRE_TOKEN (son POST, PUT, PATCH ou DELETE : un chargement de page ne dépense pas de token) ne passe qu'avec un token que le tag a gagné dans ce navigateur, une fois par session. Un script qui n'exécute jamais JavaScript est refusé à chaque tentative, quelle que soit son adresse. Mettez le tag sur la page qui mène à l'action, et cette page aussi sur les routes du Worker, pour qu'une personne rencontre le challenge dès son chargement.

Les consultations servies par le cache

Le Worker s'exécute avant le cache de Cloudflare : une requête à laquelle le cache répond n'atteint jamais votre serveur, mais elle atteint le Worker. Placez le Worker sur les routes consultées en boucle (un stock, un plan de salle) et chaque consultation est jugée ; celles qui passent sont toujours servies par le cache. Chaque requête sur les routes du Worker est une décision du volume de votre plan : ne routez que ce que vous voulez juger.

Ce que Cloudflare ajoute

Avec chaque décision, le Worker envoie ce que Cloudflare a mesuré du visiteur (request.cf) : sur tous les plans le réseau (ASN), le pays, la version HTTP et le temps d'aller-retour TCP entre le visiteur et Cloudflare (aucun en HTTP/3) ; avec Enterprise Bot Management, aussi le fingerprint JA4 et les detection ids. Bot Protection confronte cet aller-retour à celui que le tag a mesuré sur la page : un enregistrement fait sur un autre réseau annonce un aller-retour que la connexion du visiteur ne peut pas avoir. Aucun ne bloque un visiteur à lui seul.

Votre propre handler

createWorker couvre le cas habituel. Pour un Worker qui en fait plus, enveloppez votre propre fetch avec withKorzen ; il envoie de même ce que Cloudflare a mesuré.

withKorzen enveloppe le fetch du Worker : il lit ce que Cloudflare sait du visiteur et interroge avec votre timeout. Sur block, il répond lui-même 403. Sur challenge, en block mode, un chargement de page part vers la page de challenge et revient avec un token, qu'il garde comme cookie _kz : le tag poursuit cette session, et ses décisions suivantes sont allow. Tout le reste continue vers votre origine, avec l'id et l'action de la décision dans des en-têtes que votre application peut journaliser :

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

L'envoi d'un formulaire en challenge ne peut pas aller à la page et revenir : withKorzen le refuse avec 403. Avec une vérification à vous sur l'origine, passez onChallenge: () => undefined, et l'envoi arrive à votre origine avec Korzen-Action: challenge. Mettez aussi la page qui affiche le formulaire sur les routes du Worker, et le visiteur passe le challenge à son chargement.

L'IP du visiteur

CF-Connecting-IP est posé par Cloudflare et ne peut pas être écrit par le visiteur, donc un Worker a toujours la bonne adresse. Votre origine doit quand même n'accepter que le trafic de Cloudflare, sinon un bot peut contourner le Worker en l'appelant directement.

Les headers et leur ordre

withKorzen envoie les headers dont une version de navigateur fixe les valeurs (Accept, Accept-Encoding, les client hints, Sec-Fetch-*) et Accept-Language : un script qui copie le User-Agent d'un navigateur les copie rarement tous. Il n'envoie pas leur ordre (header_order) : dans un Worker, request.headers liste les noms triés, pas dans l'ordre où ils sont arrivés, donc le champ est omis et la décision lit le reste. Sur votre propre serveur Node, fromNodeRequest envoie l'ordre à partir de rawHeaders.

Le tester

npx wrangler dev exécute le Worker sur votre machine ; définissez la clé dans .dev.vars comme KORZEN_API_KEY=…. Puis npx wrangler deploy, et suivez les décisions sur la page Décisions du tableau de bord.

Erreurs fréquentes

  • Un Worker sur chaque route : chaque requête sur ses routes est une décision. Ne routez que ce que vous voulez juger : les consultations que vous surveillez, inscription, connexion, checkout.
  • Un chemin de KORZEN_REQUIRE_TOKEN dont les pages n'ont pas le tag : aucun visiteur n'y a de token, donc chaque action y est refusée.
  • Lire le corps de la requête pour décider, puis transmettre un corps déjà consommé : le corps d'une Request ne se lit qu'une fois.
  • Une origine ouverte à tout Internet : le Worker ne protège que les requêtes qui passent par Cloudflare.