Não há SDK para Rust: este guia chama a API com o reqwest, o cliente HTTP que a maioria dos servidores Rust já tem. Um reqwest::Client para o processo mantém as conexões abertas, para que uma chamada custe uma ida e volta e não um novo handshake TLS, e o timeout dele limita a chamada inteira, da conexão à leitura do corpo.
Rotas de pagamento, login e cadastro: ligue o requireToken ("require_token": true na chamada), para que um script que nunca carrega a sua página não consiga fazer a ação. Proteger um checkout
A tag
Coloque a tag no <head> do layout que renderiza as páginas de cadastro, login e checkout:
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
Na página, antes de enviar o formulário, chame await window.korzen?.ready(): ela informa o que o visitante fez desde o último relato da tag e resolve em poucos segundos, aconteça o que acontecer. A tag
A chamada
Atrás do Cloudflare? Um Worker pode fazer isso sem mudar a sua aplicação. Cloudflare Workers
Os crates, no Cargo.toml:
# Cargo.toml
[dependencies]
axum = "0.8"
reqwest = { version = "0.13", features = ["json"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
Um módulo com o cliente e as chamadas. decide responde None em qualquer erro, em qualquer status que não seja 200 e em qualquer timeout: quem chama deixa então o visitante passar.
// src/korzen.rs: one client for the process, shared by every request. use std::time::Duration; use serde_json::{Map, Value, json}; /// The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. pub(crate) const FORWARDED: [&str; 11] = [ "accept", "accept-encoding", "accept-language", "host", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests", ]; /// What the call says about the visitor. #[derive(Debug, Default)] pub(crate) struct Visitor { pub(crate) ip: String, pub(crate) user_agent: String, /// The `_kz` cookie the tag set. pub(crate) token: String, /// The [`FORWARDED`] headers the request has. pub(crate) headers: Vec<(String, String)>, /// The request's header names in the order they came, at most 64; empty when the /// server does not keep it. pub(crate) header_order: Vec<String>, } #[derive(Debug, Clone)] pub(crate) struct Korzen { http: reqwest::Client, url: String, key: String, } impl Korzen { /// The client: `timeout` bounds the whole call, from connecting to reading the body. pub(crate) fn new(url: &str, key: &str, timeout: Duration) -> reqwest::Result<Self> { let http = reqwest::Client::builder().timeout(timeout).build()?; let url = url.trim_end_matches('/').to_owned(); Ok(Self { http, url, key: key.to_owned(), }) } /// The answer for `visitor` before the action at `path`, or `None` on any error or /// timeout: then go on as on allow (fail open). pub(crate) async fn decide(&self, visitor: &Visitor, path: &str) -> Option<Value> { let mut body = fields(visitor, path); body.insert("ip_from".to_owned(), json!("peer")); // see "The visitor's IP" body.insert("method".to_owned(), json!("POST")); // Sign-up, login and payment: a script that never loaded your page holds no token. body.insert("require_token".to_owned(), json!(true)); let res = self.post("/v1/decide", &Value::Object(body), None).await?; res.json().await.ok() } /// Tells Bot Protection what you learned later about the session of a decision: /// `good_customer`, `verified_human`, `chargeback` or `fraud`. `key` makes a resend count /// once: an order's id, say. False when it did not get there. pub(crate) async fn feedback(&self, request_id: &str, outcome: &str, key: &str) -> bool { let body = json!({"feedback": [{"request_id": request_id, "outcome": outcome}]}); self.post("/v1/feedback", &body, Some(key)).await.is_some() } /// Reports a request your server served without asking first: `"page"` for a page a /// person loads, `"api"` for a call a script makes. False when it did not get there. pub(crate) async fn event(&self, visitor: &Visitor, kind: &str, path: &str) -> bool { let mut event = fields(visitor, path); event.insert("kind".to_owned(), json!(kind)); let body = json!({"events": [event]}); self.post("/v1/events", &body, None).await.is_some() } /// The answer to a POST of `body` to `route`, or `None` unless it is a 200. async fn post( &self, route: &str, body: &Value, key: Option<&str>, ) -> Option<reqwest::Response> { let mut req = self .http .post(format!("{}{route}", self.url)) .bearer_auth(&self.key) .json(body); if let Some(key) = key { req = req.header("Idempotency-Key", key); } let res = req.send().await.ok()?; (res.status() == reqwest::StatusCode::OK).then_some(res) } } /// What the decide call and an event both say about the request. fn fields(visitor: &Visitor, path: &str) -> Map<String, Value> { let headers: Map<String, Value> = visitor .headers .iter() .map(|(name, value)| (name.clone(), json!(value))) .collect(); let mut f = Map::new(); f.insert("ip".to_owned(), json!(visitor.ip)); f.insert("user_agent".to_owned(), json!(visitor.user_agent)); f.insert("headers".to_owned(), Value::Object(headers)); f.insert("header_order".to_owned(), json!(visitor.header_order)); f.insert("token".to_owned(), json!(visitor.token)); f.insert("path".to_owned(), json!(path)); f }
Axum
Um middleware nas rotas que criam uma conta ou recebem um pagamento. Ele pergunta antes de o handler rodar, recusa block e challenge como um cadastro que falha e entrega a decisão ao handler nas extensões da requisição:
// src/protect.rs (Axum): the middleware on the routes that create an account. use std::net::SocketAddr; use axum::extract::{ConnectInfo, Request, State}; use axum::http::{HeaderMap, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use serde_json::Value; use super::korzen::{FORWARDED, Korzen, Visitor}; /// Asks Bot Protection before the handler. On block or challenge it answers as a failed /// sign-up does; otherwise the handler runs, with the decision in the request's extensions. pub(crate) async fn protect( State(korzen): State<Korzen>, ConnectInfo(peer): ConnectInfo<SocketAddr>, mut req: Request, next: Next, ) -> Response { let visitor = visitor(peer, req.headers()); let decision = korzen.decide(&visitor, req.uri().path()).await; let action = decision .as_ref() .and_then(|d| d.get("action")) .and_then(Value::as_str); if matches!(action, Some("block" | "challenge")) { // "challenge" is refused too: with a CAPTCHA or an email or phone check of yours, // run it here instead. return (StatusCode::BAD_REQUEST, "We could not create your account.").into_response(); } if let Some(d) = decision { req.extensions_mut().insert(d); // keep its request_id with the account } next.run(req).await } /// The visitor of a request from `peer`, the TCP peer. pub(crate) fn visitor(peer: SocketAddr, h: &HeaderMap) -> Visitor { let get = |name: &str| { h.get(name) .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_owned() }; let token = h .get_all("cookie") .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|c| c.split(';')) .find_map(|c| c.trim().strip_prefix("_kz=")) .unwrap_or_default() .to_owned(); Visitor { ip: peer.ip().to_string(), user_agent: get("user-agent"), token, headers: FORWARDED .iter() .filter(|n| h.contains_key(**n)) .map(|n| ((*n).to_owned(), get(n))) .collect(), header_order: h.keys().take(64).map(|k| k.as_str().to_owned()).collect(), } }
Depois, no main, o cliente uma vez para o processo e o middleware na rota. O servidor é servido com ConnectInfo, para que o middleware veja o endereço de onde a conexão veio:
// src/main.rs (Axum) mod korzen; mod protect; use std::net::SocketAddr; use std::time::Duration; use axum::middleware::from_fn_with_state; use axum::response::Redirect; use axum::routing::post; use axum::{Extension, Router}; use serde_json::Value; #[tokio::main] async fn main() { let key = std::env::var("KORZEN_API_KEY").unwrap_or_default(); // The timeout for your server's region: https://korzen.dev/bot-protection/docs/server-call#latency let korzen = korzen::Korzen::new("https://korzen.dev", &key, Duration::from_millis(200)) .expect("an HTTP client"); let app = Router::new().route( "/signup", post(signup).route_layer(from_fn_with_state(korzen.clone(), protect::protect)), ); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .expect("port 3000"); // ConnectInfo gives the middleware the TCP peer's address. axum::serve( listener, app.into_make_service_with_connect_info::<SocketAddr>(), ) .await .expect("the server"); } /// The decision is there unless the call failed. async fn signup(decision: Option<Extension<Value>>) -> Redirect { let request_id = decision .as_ref() .and_then(|Extension(d)| d.get("request_id")?.as_str()); // Create the account here, and keep request_id with it. println!("sign-up, decision {request_id:?}"); Redirect::to("/welcome") }
header_order é a ordem em que os headers da requisição chegaram: o hyper os lê nessa ordem, e o HeaderMap do Axum a mantém. Atrás de nginx, de um load balancer ou da Cloudflare, é a ordem em que esse salto os repassa, não a do navegador.
Actix Web
O mesmo módulo e a mesma chamada, no handler que cria a conta, com o cliente registrado uma vez como web::Data:
// Actix Web: the same call, in the handler that creates the account. Actix keeps the // headers in a hash map, so their order is lost: header_order stays empty. use actix_web::{HttpRequest, HttpResponse, web}; use crate::korzen::{FORWARDED, Korzen, Visitor}; async fn signup(req: HttpRequest, korzen: web::Data<Korzen>) -> HttpResponse { let h = req.headers(); let get = |name: &str| { h.get(name) .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_owned() }; let visitor = Visitor { ip: req .peer_addr() .map(|a| a.ip().to_string()) .unwrap_or_default(), user_agent: get("user-agent"), token: req .cookie("_kz") .map(|c| c.value().to_owned()) .unwrap_or_default(), headers: FORWARDED .iter() .filter(|n| h.contains_key(**n)) .map(|n| ((*n).to_owned(), get(n))) .collect(), header_order: Vec::new(), }; let decision = korzen.decide(&visitor, "/signup").await; let action = decision .as_ref() .and_then(|d| d.get("action")) .and_then(|a| a.as_str()); if matches!(action, Some("block" | "challenge")) { return HttpResponse::BadRequest().body("We could not create your account."); } // Create the account here, and keep decision's request_id with it. HttpResponse::SeeOther() .insert_header(("Location", "/welcome")) .finish() }
O IP do visitante
O código acima envia o endereço de onde a conexão veio, com "ip_from": "peer". Esse é o visitante só quando nada fica na frente do seu servidor. Atrás de um load balancer ou de nginx, envie em vez disso o endereço que ele colocou em X-Forwarded-For, com "ip_from": "last_forwarded", e só quando toda requisição passa por ele. Atrás da Cloudflare, envie CF-Connecting-IP, com "ip_from": "cloudflare", se o seu servidor só recebe tráfego da Cloudflare.
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 e-mail ou telefone); sem nenhuma, recuse como um block. No login, no cadastro e no checkout, nunca deixe passar um challenge: a maioria dos scripts que enviam o User-Agent de um navegador recebe challenge, não block. A conta começa no modo monitor, em que a action é sempre allow: nada muda para os visitantes até você ligar o modo bloqueio.
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.
Feedback e eventos
Quando você souber mais sobre um visitante depois, um pedido pago sem contestação ou um chargeback, envie isso com o request_id daquela decisão: korzen.feedback(&request_id, "good_customer", &order_id). A chave, aqui o id do pedido, faz um reenvio contar uma vez só. Feedback
Uma requisição que o seu servidor atende sem perguntar antes, como o carregamento de uma página, pode ser informada depois: korzen.event(&visitor, "page", "/pricing"). Eventos e feedback são gratuitos, e nenhum dos dois muda o que o visitante vê; então envie-os depois da resposta, nunca antes.
Teste na sua máquina
Rode cargo run e abra http://localhost:3000. 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 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
- Um
reqwest::Clientnovo por chamada. Cada um abre uma conexão TLS até a Alemanha: uma ou duas idas e voltas a mais. - Servir o app sem
into_make_service_with_connect_info. OConnectInfoentão falha, e o Axum responde500na rota. - Chamar
/v1/decidedo navegador. A chave de API 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.