Korzen
Bot Protection

Django e Flask

Bot Protection em Django ou Flask: a tag no template base, um módulo para a chamada com requests e o IP do visitante atrás de um proxy.

Um módulo serve os dois: ele recebe o IP, os cabeçalhos da requisição e o cookie _kz, e cada framework passa os seus. Usa requests com uma Session por processo, que mantém a conexão aberta; o timeout vale para a conexão e para cada leitura.

A tag

Coloque a tag no <head> do template base (base.html), que as páginas de cadastro, login e checkout estendem:

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

A chamada

# korzen.py
import os

import requests

korzen = requests.Session()  # 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"]


def header_order(environ):
    """The order the request's headers came in, as gunicorn keeps it in the WSGI environ."""
    names = [k for k in environ if k.startswith("HTTP_") or k in ("CONTENT_TYPE", "CONTENT_LENGTH")]
    return [k.removeprefix("HTTP_").lower().replace("_", "-") for k in names[:64]]


def decide(ip, headers, environ, token, path):
    """The answer for this request, or None on any error: then go on as on allow (fail open)."""
    try:
        r = korzen.post(
            "https://korzen.dev/v1/decide",
            timeout=0.2,
            headers={"Authorization": f"Bearer {os.environ['KORZEN_API_KEY']}"},
            json={
                "ip": ip,
                "user_agent": headers.get("User-Agent"),
                "headers": {h: headers[h] for h in FORWARDED if h in headers},
                "header_order": header_order(environ),
                "token": token,
                "path": path,
                "method": "POST",
            },
        )
        return r.json() if r.status_code == 200 else None
    except Exception:
        return None

header_order é a ordem em que os headers da requisição chegaram: um script que copia os valores de um navegador raramente copia a ordem deles. O gunicorn guarda essa ordem nas chaves HTTP_* do environ do WSGI (request.META no Django, request.environ no Flask), e descarta um nome com _. Atrás do nginx, de um load balancer ou do Cloudflare, é a ordem em que esse salto os repassa, não a do navegador.

No Django, na view que cria a conta:

# views.py
from django.shortcuts import redirect, render

from . import korzen


def client_ip(request):
    # Behind Cloudflare: return request.headers.get("CF-Connecting-IP").
    # Behind one proxy of yours that appends to X-Forwarded-For: its last entry.
    forwarded = request.headers.get("X-Forwarded-For")
    if forwarded:
        return forwarded.split(",")[-1].strip()
    return request.META.get("REMOTE_ADDR")


def signup(request):
    if request.method == "POST":
        d = korzen.decide(client_ip(request), request.headers, request.META, request.COOKIES.get("_kz"), "/signup")
        if d and d["action"] == "block":
            return render(request, "signup.html", {"error": "We could not create your account."}, status=400)
        # "challenge": your CAPTCHA, or an email or phone check, before going on
        user = create_account(request.POST)  # your own code
        save_request_id(user, d and d["request_id"])  # see "Keep the request_id"
        return redirect("welcome")
    return render(request, "signup.html")

No Flask:

# app.py
from flask import Flask, redirect, render_template, request
from werkzeug.middleware.proxy_fix import ProxyFix

import korzen

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)  # one proxy of yours in front


@app.post("/signup")
def signup():
    d = korzen.decide(request.remote_addr, request.headers, request.environ, request.cookies.get("_kz"), "/signup")
    if d and d["action"] == "block":
        return render_template("signup.html", error="We could not create your account."), 400
    # "challenge": your CAPTCHA, or an email or phone check, before going on
    user = create_account(request.form)  # your own code
    save_request_id(user, d and d["request_id"])  # see "Keep the request_id"
    return redirect("/welcome")

O IP do visitante

O Django não tem configuração de proxy: REMOTE_ADDR é o endereço da conexão, o do seu proxy se houver um. O client_ip acima pega a última entrada do X-Forwarded-For, a que o seu proxy acrescentou; com dois proxies seus na frente, pegue a anterior. Atrás do Cloudflare, leia CF-Connecting-IP. No Flask, ProxyFix(x_for=1) faz o mesmo para um proxy e define request.remote_addr.

Siga a action

Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode.

Guarde o request_id

Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa.

Teste na sua máquina

Rode python manage.py runserver ou flask run e abra em localhost. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou ::1, e não tem problema. No Docker na sua máquina, o app vê em vez disso o endereço da bridge, como 172.17.0.1: a página de desafio ainda traz você de volta para localhost, porque a tag viu sua página lá.

  1. Defina KORZEN_API_KEY no ambiente (ou o seu .env, se você usa um) e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie _kz aparece depois de alguns segundos.
  2. Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
  3. Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde 401, e o cadastro passa.

Erros comuns

  • A primeira entrada do X-Forwarded-For. Quem a define é o visitante, então o bot escolhe o próprio IP.
  • ProxyFix com um x_for maior que o número de proxies que você tem. O Flask passa a confiar numa entrada forjada pelo visitante.
  • Um formulário enviado por JavaScript logo que a página carrega, antes de a tag reportar: chame await window.korzen?.ready() antes de enviar.
  • Chamar /v1/decide do navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor.
  • Sem timeout, ou um erro que trava o cadastro. Se a resposta não for 200, ou não chegar a tempo, deixe o visitante passar.