Korzen
Bot Protection

FastAPI

Bot Protection in FastAPI: il tag, un modulo per la chiamata con httpx sotto un solo timeout, l'ordine degli header dallo scope ASGI, e uvicorn dietro un proxy.

Questa guida è per FastAPI su Python 3.11 o successivi, servito da uvicorn, e funziona allo stesso modo in qualsiasi app Starlette. La chiamata usa httpx con un AsyncClient per processo, che tiene aperta la connessione; asyncio.timeout limita l'intera chiamata, connessione compresa, perché il timeout di httpx vale per ogni passaggio separatamente.

Il tag

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

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

La chiamata

# 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 è l'ordine in cui sono arrivati gli header della richiesta: uno script che copia i valori di un browser ne copia di rado l'ordine. A differenza di WSGI, ASGI lo tiene, in request.scope["headers"]. Dietro nginx, un load balancer o Cloudflare, è l'ordine in cui quel passaggio li inoltra, non quello del browser.

Nella route che crea l'account (Form richiede 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 del visitatore

request.client.host è l'indirizzo della connessione, a meno che uvicorn non si fidi del proxy davanti. uvicorn legge X-Forwarded-For dagli indirizzi che --forwarded-allow-ips indica (127.0.0.1 di default) e imposta il client sull'ultima voce che non hanno aggiunto loro: avvialo con l'indirizzo del tuo load balancer, come uvicorn main:app --forwarded-allow-ips 10.0.0.5, o con la rete in cui si trova. Dietro Cloudflare, leggi invece request.headers.get("cf-connecting-ip"), se il tuo server accetta traffico solo da Cloudflare.

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.

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.

Provalo sulla tua macchina

Avvia uvicorn main:app --reload 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ì. In Docker sulla tua macchina, l'app vede invece l'indirizzo del bridge, come 172.17.0.1: la pagina di challenge ti riporta comunque su localhost, perché il tag ha visto lì la tua pagina.

  1. Imposta KORZEN_API_KEY nell'ambiente 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

  • --forwarded-allow-ips '*'. Si fida di ogni hop, quindi il client diventa la prima voce di X-Forwarded-For, che scrive il visitatore.
  • Un nuovo httpx.AsyncClient() per ogni chiamata. Ognuno apre la propria connessione, con un handshake TLS dentro il tuo timeout.
  • Solo timeout= su httpx. Limita ogni passaggio separatamente (connessione, scrittura, lettura), quindi la chiamata può durare più volte tanto; asyncio.timeout la limita tutta.
  • Chiamare /v1/decide dal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server.
  • Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da 200, o nessuna risposta in tempo, significa: lascia passare il visitatore.