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á.
- Defina
KORZEN_API_KEYno ambiente (ou o seu.env, se você usa um) e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie_kzaparece depois de alguns segundos. - Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
- 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. ProxyFixcom umx_formaior 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/decidedo 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.