Korzen
Bot Protection
In dieser Dokumentation

Rust (Axum, Actix Web)

Bot Protection in einem Rust-Server: ein reqwest-Client für den Prozess, der Aufruf unter einem Timeout, der den Besucher bei jedem Fehler durchlässt, eine Middleware für Axum, derselbe Aufruf in Actix Web und die Aufrufe für Feedback und Events.

Es gibt kein Rust-SDK: Diese Anleitung ruft die API mit reqwest auf, dem HTTP-Client, den die meisten Rust-Server schon haben. Ein reqwest::Client für den Prozess hält seine Verbindungen offen, sodass ein Aufruf einen Round Trip kostet und keinen neuen TLS-Handshake, und sein timeout begrenzt den ganzen Aufruf, vom Verbinden bis zum Lesen des Bodys.

Routen für Zahlung, Login und Registrierung: Schalten Sie requireToken ein ("require_token": true im Aufruf), damit ein Skript, das Ihre Seite nie lädt, die Aktion nicht ausführen kann. Einen Checkout schützen

Der Tag

Setzen Sie den Tag in den <head> des Layouts, das Ihre Registrierungs-, Anmelde- und Checkout-Seiten rendert:

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

Rufen Sie auf der Seite vor dem Senden des Formulars await window.korzen?.ready() auf: Es meldet, was der Besucher seit der letzten Meldung des Tags getan hat, und wird in jedem Fall innerhalb weniger Sekunden aufgelöst. Der Tag

Der Aufruf

Hinter Cloudflare? Ein Worker kann das übernehmen, ohne Ihre Anwendung zu ändern. Cloudflare Workers

Die Crates, in Cargo.toml:

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

Ein Modul, das den Client und die Aufrufe hält. decide antwortet None bei jedem Fehler, jedem Status außer 200 und jedem Timeout: Der Aufrufer lässt den Besucher dann durch.

// 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

Eine Middleware auf den Routen, die ein Konto anlegen oder eine Zahlung annehmen. Sie fragt, bevor der Handler läuft, lehnt block und challenge ab wie eine fehlgeschlagene Registrierung und gibt dem Handler die Entscheidung in den Extensions der Anfrage mit:

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

Dann in main der Client einmal für den Prozess und die Middleware auf der Route. Der Server läuft mit ConnectInfo, damit die Middleware die Adresse sieht, von der die Verbindung kam:

// 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 ist die Reihenfolge, in der die Header der Anfrage ankamen: hyper liest sie in dieser Reihenfolge, und Axums HeaderMap behält sie. Hinter nginx, einem Load Balancer oder Cloudflare ist es die Reihenfolge, in der dieser Hop sie weiterreicht, nicht die des Browsers.

Actix Web

Dasselbe Modul und derselbe Aufruf im Handler, der das Konto anlegt, mit dem Client einmal als web::Data registriert:

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

Die IP des Besuchers

Der Code oben sendet die Adresse, von der die Verbindung kam, mit "ip_from": "peer". Das ist nur dann der Besucher, wenn nichts vor Ihrem Server steht. Hinter einem Load Balancer oder nginx senden Sie stattdessen die Adresse, die er in X-Forwarded-For gesetzt hat, mit "ip_from": "last_forwarded", und nur, wenn jede Anfrage über ihn kommt. Hinter Cloudflare senden Sie CF-Connecting-IP mit "ip_from": "cloudflare", wenn Ihr Server nur Traffic von Cloudflare annimmt.

Die Aktion anwenden

Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine lehnen Sie die Anfrage ab wie bei block. Bei Anmeldung, Registrierung und Checkout lassen Sie challenge nie durch: Die meisten Skripte, die den User-Agent eines Browsers senden, bekommen challenge, nicht block. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.

Die request_id aufbewahren

Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat.

Feedback und Events

Wenn Sie später mehr über einen Besucher erfahren, eine ohne Einspruch bezahlte Bestellung oder einen Chargeback, senden Sie es mit der request_id dieser Entscheidung: korzen.feedback(&request_id, "good_customer", &order_id). Der Schlüssel, hier die ID der Bestellung, lässt ein erneutes Senden nur einmal zählen. Feedback

Eine Anfrage, die Ihr Server bedient, ohne vorher zu fragen, etwa ein Seitenaufruf, lässt sich nachträglich melden: korzen.event(&visitor, "page", "/pricing"). Events und Feedback sind kostenlos, und keins von beiden ändert, was der Besucher sieht: Senden Sie sie also nach der Antwort, nie davor.

Auf Ihrem Rechner testen

Führen Sie cargo run aus und öffnen Sie http://localhost:3000. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung. Läuft die App auf Ihrem Rechner in Docker, sieht sie stattdessen die Adresse der Bridge, etwa 172.17.0.1: Die Challenge-Seite bringt Sie trotzdem zu localhost zurück, denn der Tag hat Ihre Seite dort gesehen.

  1. Setzen Sie KORZEN_API_KEY in der Umgebung und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das _kz-Cookie.
  2. Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
  3. Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet 401, und die Registrierung geht durch.

Häufige Fehler

  • Ein neuer reqwest::Client pro Aufruf. Jeder öffnet eine TLS-Verbindung nach Deutschland: ein oder zwei weitere Round Trips.
  • Die App ohne into_make_service_with_connect_info ausliefern. ConnectInfo schlägt dann fehl, und Axum antwortet auf der Route mit 500.
  • /v1/decide aus dem Browser aufrufen. Der API-Schlüssel würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.
  • Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als 200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.