No hay SDK para Rust: esta guía llama a la API con reqwest, el cliente HTTP que ya tienen la mayoría de los servidores Rust. Un reqwest::Client para el proceso mantiene sus conexiones abiertas, así que una llamada cuesta un viaje de ida y vuelta y no un nuevo handshake TLS, y su timeout acota la llamada entera, desde la conexión hasta leer el cuerpo.
Rutas de pago, inicio de sesión y registro: activa requireToken ("require_token": true en la llamada), para que un script que nunca carga tu página no pueda hacer la acción. Proteger un checkout
El tag
Pon el tag en el <head> del layout que renderiza tus páginas de registro, inicio de sesión y checkout:
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
En la página, antes de enviar el formulario, llama a await window.korzen?.ready(): informa lo que el visitante hizo desde el último informe del tag y se resuelve en pocos segundos pase lo que pase. El tag
La llamada
¿Detrás de Cloudflare? Un Worker puede hacerlo sin cambiar tu aplicación. Cloudflare Workers
Los crates, en Cargo.toml:
# Cargo.toml
[dependencies]
axum = "0.8"
reqwest = { version = "0.13", features = ["json"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
Un módulo con el cliente y las llamadas. decide responde None ante cualquier error, cualquier estado que no sea 200 y cualquier timeout: quien llama deja entonces pasar al visitante.
// 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
Un middleware en las rutas que crean una cuenta o cobran un pago. Pregunta antes de que corra el handler, rechaza block y challenge como lo hace un registro fallido y le pasa al handler la decisión en las extensiones de la solicitud:
// 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(), } }
Después, en main, el cliente una vez para el proceso y el middleware en la ruta. El servidor se sirve con ConnectInfo, para que el middleware vea la dirección de la que vino la conexión:
// 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 es el orden en que llegaron los headers de la solicitud: hyper los lee en ese orden y el HeaderMap de Axum lo conserva. Detrás de nginx, un load balancer o Cloudflare, es el orden en que ese salto los reenvía, no el del navegador.
Actix Web
El mismo módulo y la misma llamada, en el handler que crea la cuenta, con el cliente registrado una 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() }
La IP del visitante
El código de arriba envía la dirección de la que vino la conexión, con "ip_from": "peer". Ese es el visitante solo cuando nada está delante de tu servidor. Detrás de un load balancer o de nginx, envía en su lugar la dirección que puso en X-Forwarded-For, con "ip_from": "last_forwarded", y solo cuando todas las solicitudes pasan por él. Detrás de Cloudflare, envía CF-Connecting-IP, con "ip_from": "cloudflare", si tu servidor solo recibe tráfico de Cloudflare.
Actúa según la action
Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, recházala como un block. En el inicio de sesión, el registro y el checkout, nunca dejes pasar un challenge: la mayoría de los scripts que envían el User-Agent de un navegador reciben challenge, no block. La cuenta empieza en modo monitor, donde action es siempre allow: nada cambia para los visitantes hasta que actives el modo bloqueo.
Guarda el request_id
Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa.
Feedback y eventos
Cuando sepas más de un visitante más tarde, un pedido pagado sin disputa o un chargeback, envíalo con el request_id de esa decisión: korzen.feedback(&request_id, "good_customer", &order_id). La clave, aquí el id del pedido, hace que un reenvío cuente una sola vez. Feedback
Una solicitud que tu servidor atiende sin preguntar antes, como la carga de una página, se puede informar después: korzen.event(&visitor, "page", "/pricing"). Los eventos y el feedback son gratis, y ninguno cambia lo que ve el visitante, así que envíalos después de la respuesta, nunca antes.
Pruébalo en tu máquina
Ejecuta cargo run y abre http://localhost:3000. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o ::1, y es normal. En Docker en tu máquina, la app ve en su lugar la dirección del bridge, como 172.17.0.1: la página de desafío te devuelve igual a localhost, porque el tag vio tu página allí.
- Define
KORZEN_API_KEYen el entorno y abre la página de registro. En las herramientas de desarrollo del navegador, la cookie_kzaparece tras unos segundos. - Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
- Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde
401, y el registro pasa.
Errores comunes
- Un
reqwest::Clientnuevo por llamada. Cada uno abre una conexión TLS hasta Alemania: uno o dos viajes de ida y vuelta más. - Servir la app sin
into_make_service_with_connect_info.ConnectInfofalla entonces, y Axum responde500en la ruta. - Llamar a
/v1/decidedesde el navegador. La clave de API quedaría expuesta a cada visitante; la llamada se hace desde tu servidor. - No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de
200, o la falta de respuesta a tiempo, significa: deja pasar al visitante.