Korzen
Bot Protection
Dans cette documentation

Rust (Axum, Actix Web)

Bot Protection dans un serveur Rust : un client reqwest pour le processus, l'appel sous un timeout qui laisse passer le visiteur à la moindre erreur, un middleware pour Axum, le même appel dans Actix Web, et les appels de feedback et d'événements.

Il n'y a pas de SDK Rust : ce guide appelle l'API avec reqwest, le client HTTP que la plupart des serveurs Rust ont déjà. Un seul reqwest::Client pour le processus garde ses connexions ouvertes, si bien qu'un appel coûte un aller-retour et non une nouvelle négociation TLS, et son timeout borne tout l'appel, de la connexion à la lecture du corps.

Routes de paiement, de connexion et d'inscription : activez requireToken ("require_token": true dans l'appel), pour qu'un script qui ne charge jamais votre page ne puisse pas faire l'action. Protéger un checkout

Le tag

Placez le tag dans le <head> du layout qui rend vos pages d'inscription, de connexion et de checkout :

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

Dans la page, avant d'envoyer le formulaire, appelez await window.korzen?.ready() : il signale ce que le visiteur a fait depuis le dernier signalement du tag, et se résout en quelques secondes quoi qu'il arrive. Le tag

L'appel

Derrière Cloudflare ? Un Worker peut le faire sans modifier votre application. Cloudflare Workers

Les crates, dans Cargo.toml :

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

Un module qui contient le client et les appels. decide répond None à la moindre erreur, à tout statut autre que 200 et à tout timeout : l'appelant laisse alors passer le visiteur.

// 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 sur les routes qui créent un compte ou encaissent un paiement. Il interroge avant que le handler ne s'exécute, refuse block et challenge comme une inscription qui échoue, et transmet la décision au handler dans les extensions de la requête :

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

Puis, dans main, le client une fois pour le processus et le middleware sur la route. Le serveur est servi avec ConnectInfo, pour que le middleware voie l'adresse d'où vient la connexion :

// 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 est l'ordre dans lequel les en-têtes de la requête sont arrivés : hyper les lit dans cet ordre, et le HeaderMap d'Axum le garde. Derrière nginx, un load balancer ou Cloudflare, c'est l'ordre dans lequel ce relais les transmet, pas celui du navigateur.

Actix Web

Le même module et le même appel, dans le handler qui crée le compte, avec le client enregistré une fois comme 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 du visiteur

Le code ci-dessus envoie l'adresse d'où vient la connexion, avec "ip_from": "peer". C'est le visiteur seulement quand rien ne se trouve devant votre serveur. Derrière un load balancer ou nginx, envoyez plutôt l'adresse qu'il a mise dans X-Forwarded-For, avec "ip_from": "last_forwarded", et seulement quand toutes les requêtes passent par lui. Derrière Cloudflare, envoyez CF-Connecting-IP, avec "ip_from": "cloudflare", si votre serveur ne reçoit que du trafic de Cloudflare.

Appliquer l'action

Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, refusez-la comme un block. À la connexion, à l'inscription et au checkout, ne laissez jamais passer un challenge : la plupart des scripts qui envoient le User-Agent d'un navigateur reçoivent challenge, pas block. Le compte commence en mode surveillance, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode blocage.

Garder le request_id

Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.

Feedback et événements

Quand vous en apprenez plus sur un visiteur par la suite, une commande payée sans litige ou un chargeback, envoyez-le avec le request_id de cette décision : korzen.feedback(&request_id, "good_customer", &order_id). La clé, ici l'id de la commande, fait qu'un renvoi ne compte qu'une fois. Feedback

Une requête que votre serveur sert sans interroger avant, comme le chargement d'une page, peut être signalée après coup : korzen.event(&visitor, "page", "/pricing"). Les événements et le feedback sont gratuits, et aucun ne change ce que voit le visiteur : envoyez-les donc après la réponse, jamais avant.

Tester sur votre machine

Lancez cargo run et ouvrez http://localhost:3000. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien. Dans Docker sur votre machine, l'app voit à la place l'adresse du bridge, comme 172.17.0.1 : la page de défi vous ramène quand même sur localhost, puisque le tag y a vu votre page.

  1. Définissez KORZEN_API_KEY dans l'environnement et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie _kz apparaît après quelques secondes.
  2. Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
  3. Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond 401, et l'inscription passe.

Erreurs fréquentes

  • Un nouveau reqwest::Client par appel. Chacun ouvre une connexion TLS vers l'Allemagne : un ou deux allers-retours de plus.
  • Servir l'app sans into_make_service_with_connect_info. ConnectInfo échoue alors, et Axum répond 500 sur la route.
  • Appeler /v1/decide depuis le navigateur. La clé API atteindrait chaque visiteur ; l'appel a sa place sur votre serveur.
  • Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que 200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.