Korzen
Bot Protection
In questa documentazione

Rust (Axum, Actix Web)

Bot Protection in un server Rust: un client reqwest per il processo, la chiamata sotto un timeout che lascia passare il visitatore a ogni errore, un middleware per Axum, la stessa chiamata in Actix Web e le chiamate di feedback e di eventi.

Non c'è un SDK per Rust: questa guida chiama l'API con reqwest, il client HTTP che la maggior parte dei server Rust ha già. Un solo reqwest::Client per il processo tiene aperte le connessioni, così una chiamata costa un round trip e non un nuovo handshake TLS, e il suo timeout limita l'intera chiamata, dalla connessione alla lettura del body.

Rotte di pagamento, accesso e registrazione: attiva requireToken ("require_token": true nella chiamata), così uno script che non carica mai la tua pagina non può compiere l'azione. Proteggere un checkout

Il tag

Metti il tag nel <head> del layout che renderizza le pagine di registrazione, accesso e checkout:

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

Nella pagina, prima di inviare il form, chiama await window.korzen?.ready(): riporta ciò che il visitatore ha fatto dall'ultimo rapporto del tag e si risolve entro pochi secondi qualunque cosa accada. Il tag

La chiamata

Dietro Cloudflare? Un Worker può farlo senza modificare la tua applicazione. Cloudflare Workers

I crate, in Cargo.toml:

# Cargo.toml
[dependencies]
axum = "0.8"
reqwest = { version = "0.13", features = ["json"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Un modulo con il client e le chiamate. decide risponde None a ogni errore, a ogni stato diverso da 200 e a ogni timeout: chi chiama lascia allora passare il visitatore.

// 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 sulle rotte che creano un account o incassano un pagamento. Chiede prima che il handler venga eseguito, rifiuta block e challenge come una registrazione non riuscita e passa al handler la decisione nelle estensioni della richiesta:

// 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(),
    }
}

Poi, in main, il client una volta per il processo e il middleware sulla rotta. Il server viene servito con ConnectInfo, così il middleware vede l'indirizzo da cui è arrivata la connessione:

// 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 è l'ordine in cui sono arrivati gli header della richiesta: hyper li legge in quell'ordine e lo HeaderMap di Axum lo conserva. Dietro nginx, un load balancer o Cloudflare, è l'ordine in cui quel passaggio li inoltra, non quello del browser.

Actix Web

Lo stesso modulo e la stessa chiamata, nel handler che crea l'account, con il client registrato una volta come 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()
}

L'IP del visitatore

Il codice sopra invia l'indirizzo da cui è arrivata la connessione, con "ip_from": "peer". È il visitatore solo quando davanti al tuo server non c'è nulla. Dietro un load balancer o nginx, invia invece l'indirizzo che ha messo in X-Forwarded-For, con "ip_from": "last_forwarded", e solo quando ogni richiesta passa da lì. Dietro Cloudflare, invia CF-Connecting-IP, con "ip_from": "cloudflare", se il tuo server riceve 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, rifiutala come un block. Su accesso, registrazione e checkout non lasciare mai passare un challenge: la maggior parte degli script che inviano lo User-Agent di un browser riceve challenge, non block. L'account parte in modalità monitor, dove action è sempre allow: per i visitatori non cambia niente finché non attivi la modalità blocco.

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.

Feedback ed eventi

Quando in seguito scopri di più su un visitatore, un ordine pagato senza contestazioni o un chargeback, invialo con il request_id di quella decisione: korzen.feedback(&request_id, "good_customer", &order_id). La chiave, qui l'id dell'ordine, fa contare un nuovo invio una volta sola. Feedback

Una richiesta che il tuo server serve senza chiedere prima, come il caricamento di una pagina, si può segnalare dopo: korzen.event(&visitor, "page", "/pricing"). Eventi e feedback sono gratuiti, e nessuno dei due cambia ciò che vede il visitatore: inviali quindi dopo la risposta, mai prima.

Provalo sulla tua macchina

Avvia cargo run e apri http://localhost:3000. 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 sfida 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

  • Un nuovo reqwest::Client per ogni chiamata. Ognuno apre una connessione TLS verso la Germania: uno o due round trip in più.
  • Servire l'app senza into_make_service_with_connect_info. ConnectInfo allora fallisce, e Axum risponde 500 sulla rotta.
  • Chiamare /v1/decide dal browser. La chiave API 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.