Korzen
Bot Protection

FastAPI

Bot Protection dans FastAPI : le tag, un module pour l'appel avec httpx sous un seul timeout, l'ordre des headers depuis le scope ASGI, et uvicorn derrière un proxy.

Ce guide vaut pour FastAPI sur Python 3.11 ou plus récent, servi par uvicorn, et fonctionne de même dans toute application Starlette. L'appel utilise httpx avec un seul AsyncClient par processus, qui garde la connexion ouverte ; asyncio.timeout borne l'appel entier, connexion comprise, puisque le timeout propre à httpx s'applique à chaque étape séparément.

Le tag

Placez le tag dans le <head> du template qu'étendent vos pages d'inscription, de connexion et de checkout :

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

L'appel

# korzen.py
import asyncio
import os

import httpx

korzen = httpx.AsyncClient()  # one per process: it keeps the connection open

# The headers Bot Protection reads: those one browser build sends alike, and Accept-Language.
FORWARDED = ["accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
             "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"]


async def decide(request, path):
    """The answer for this request, or None on any error: then go on as on allow (fail open)."""
    try:
        async with asyncio.timeout(0.2):  # the whole call, the connection included
            r = await korzen.post(
                "https://korzen.dev/v1/decide",
                headers={"Authorization": f"Bearer {os.environ['KORZEN_API_KEY']}", "Korzen-Deadline-Ms": "200"},
                json={
                    "ip": request.client.host if request.client else None,  # see "The visitor's IP"
                    "user_agent": request.headers.get("user-agent"),
                    "headers": {h: request.headers[h] for h in FORWARDED if h in request.headers},
                    # The order the request's headers came in, as the ASGI server keeps it.
                    "header_order": [name.decode("latin-1").lower() for name, _ in request.scope["headers"][:64]],
                    "token": request.cookies.get("_kz"),
                    "path": path,
                    "method": "POST",
                },
            )
        return r.json() if r.status_code == 200 else None
    except Exception:
        return None

header_order est l'ordre dans lequel les headers de la requête sont arrivés : un script qui copie les valeurs d'un navigateur en copie rarement l'ordre. Contrairement à WSGI, ASGI le garde, dans request.scope["headers"]. Derrière nginx, un load balancer ou Cloudflare, c'est l'ordre dans lequel ce saut les transmet, pas celui du navigateur.

Dans la route qui crée le compte (Form a besoin de python-multipart) :

# main.py
from typing import Annotated

from fastapi import FastAPI, Form, Request
from fastapi.responses import PlainTextResponse, RedirectResponse

import korzen

app = FastAPI()


@app.post("/signup")
async def signup(request: Request, email: Annotated[str, Form()]):
    d = await korzen.decide(request, "/signup")
    if d and d["action"] == "block":
        return PlainTextResponse("We could not create your account.", status_code=400)
    # "challenge": your CAPTCHA, or an email or phone check, before going on
    user = await create_account(email)  # your own code
    await save_request_id(user, d and d["request_id"])  # see "Keep the request_id"
    return RedirectResponse("/welcome", status_code=303)

L'IP du visiteur

request.client.host est l'adresse de la connexion, sauf si uvicorn fait confiance au proxy devant. uvicorn lit X-Forwarded-For depuis les adresses que nomme --forwarded-allow-ips (127.0.0.1 par défaut) et prend comme client la dernière entrée qu'elles n'ont pas ajoutée : lancez-le avec l'adresse de votre load balancer, comme uvicorn main:app --forwarded-allow-ips 10.0.0.5, ou le réseau où il se trouve. Derrière Cloudflare, lisez plutôt request.headers.get("cf-connecting-ip"), si votre serveur ne reçoit du trafic que de Cloudflare.

Appliquer l'action

Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.

Garder le request_id

Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.

Tester sur votre machine

Lancez uvicorn main:app --reload et ouvrez-le sur localhost. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien. Dans Docker sur votre machine, l'app voit à la place l'adresse du bridge, comme 172.17.0.1 : la page de challenge vous ramène quand même sur localhost, puisque le tag y a vu votre page.

  1. Définissez KORZEN_API_KEY dans l'environnement et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie _kz apparaît après quelques secondes.
  2. Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
  3. Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond 401, et l'inscription passe.

Erreurs fréquentes

  • --forwarded-allow-ips '*'. Il fait confiance à chaque saut, donc le client devient la première entrée de X-Forwarded-For, que le visiteur écrit.
  • Un nouveau httpx.AsyncClient() pour chaque appel. Chacun ouvre sa propre connexion, avec une poignée de main TLS dans votre timeout.
  • timeout= sur httpx seul. Il borne chaque étape séparément (connexion, écriture, lecture), donc l'appel peut durer plusieurs fois plus longtemps ; asyncio.timeout le borne en entier.
  • Appeler /v1/decide depuis le navigateur. L'API key atteindrait chaque visiteur ; l'appel a sa place sur votre serveur.
  • Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que 200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.