Korzen
Bot Protection
このドキュメントの目次

Rust (Axum, Actix Web)

Rust サーバーでの Bot Protection:プロセスに 1 つの reqwest クライアント、どんなエラーでも訪問者を通す 1 つのタイムアウト内の呼び出し、Axum 用のミドルウェア、Actix Web での同じ呼び出し、そしてフィードバックとイベントの呼び出し。

Rust の SDK はありません。このガイドは、ほとんどの Rust サーバーがすでに持っている HTTP クライアント reqwest で API を呼び出します。プロセスに 1 つの reqwest::Client が接続を開いたままにするので、呼び出しは新しい TLS ハンドシェイクではなく 1 往復で済み、その timeout が接続からボディの読み取りまで呼び出し全体を制限します。

決済、ログイン、新規登録のルートでは requireToken をオンにしてください(呼び出しに "require_token": true)。ページを一度も読み込まないスクリプトは、そのアクションを実行できなくなります。チェックアウトの保護

タグ

新規登録、ログイン、チェックアウトの各ページを描画するレイアウトの <head> にタグを置きます。

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

ページでフォームを送信する前に await window.korzen?.ready() を呼び出してください。タグが最後に報告してから訪問者が行ったことを報告し、何が起きても数秒以内に完了します。タグ

呼び出し

Cloudflare の背後にある場合は、Worker でアプリケーションを変更せずに同じことができます。Cloudflare Workers

Cargo.toml のクレート:

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

クライアントと呼び出しをまとめたモジュールです。decide はどんなエラーでも、200 以外のステータスでも、タイムアウトでも None を返します。呼び出し側はそのとき訪問者を通します。

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

アカウントを作成するルートや支払いを受けるルートのミドルウェアです。ハンドラーが実行される前に問い合わせ、block と challenge は失敗したサインアップと同じように拒否し、判定をリクエストの extensions に入れてハンドラーに渡します:

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

次に main で、クライアントをプロセスに 1 回、ミドルウェアをルートに設定します。サーバーは ConnectInfo 付きで起動するので、ミドルウェアは接続元のアドレスを見られます:

// 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 はリクエストのヘッダーが届いた順序です。hyper はその順に読み、Axum の HeaderMap はそれを保ちます。nginx、ロードバランサー、Cloudflare の背後では、ブラウザの順序ではなく、その中継が転送した順序になります。

Actix Web

同じモジュールと同じ呼び出しを、アカウントを作成するハンドラーで使います。クライアントは web::Data として 1 回登録します:

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

訪問者の IP

上のコードは、接続元のアドレスを "ip_from": "peer" で送ります。それが訪問者になるのは、サーバーの前に何もないときだけです。ロードバランサーや nginx の背後では、代わりにそれが X-Forwarded-For に入れたアドレスを "ip_from": "last_forwarded" で送ります。ただし、すべてのリクエストがそれを通る場合に限ります。Cloudflare の背後では、サーバーが Cloudflare からのトラフィックしか受けないなら、CF-Connecting-IP を "ip_from": "cloudflare" で送ります。

アクションを適用する

読むのは action だけです。block の場合は、その操作がほかの理由で失敗したときと同じエラーを返し、ボットに何も悟らせないようにします。challenge の場合は、すでにある確認手段(CAPTCHA、メールや電話の確認コード)を使います。何もなければ block と同じように拒否します。サインイン、サインアップ、チェックアウトでは challenge を決して通さないでください。ブラウザーの User-Agent を送るスクリプトの多くは、block ではなく challenge になります。アカウントはモニターモードで始まり、その間 action は常に allow です。ブロックモードをオンにするまで、訪問者にとっては何も変わりません。

request_id を保存する

すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。

フィードバックとイベント

あとで訪問者についてさらにわかったとき(異議なく支払われた注文やチャージバックなど)は、その判定の request_id とともに送ります:korzen.feedback(&request_id, "good_customer", &order_id)。キー(ここでは注文の ID)があれば、再送しても 1 回だけ数えられます。フィードバック

サーバーが事前に問い合わせずに処理したリクエスト(ページの読み込みなど)は、あとから報告できます:korzen.event(&visitor, "page", "/pricing")。イベントとフィードバックは無料で、どちらも訪問者に見えるものを変えません。レスポンスの前ではなく、後に送ってください。

手元のマシンでテストする

cargo run を実行し、http://localhost:3000 を開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。自分のマシンの Docker でアプリを動かすと、代わりにブリッジのアドレス(172.17.0.1 など)になりますが、タグがそこであなたのページを見ているので、チャレンジページは localhost へ戻してくれます。

  1. 環境変数に KORZEN_API_KEY を設定し、新規登録ページを開きます。数秒後、ブラウザの開発者ツールに _kz Cookie が表示されます。
  2. 新規登録します。判定がダッシュボードの判定ページに、送信したパスで表示されます。
  3. fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは 401 を返し、新規登録はそのまま完了します。

よくある間違い

  • 呼び出しごとに新しい reqwest::Client を作る。そのたびにドイツへの TLS 接続が開かれ、往復が 1〜2 回増えます。
  • into_make_service_with_connect_info なしでアプリを起動する。ConnectInfo が失敗し、Axum はそのルートで 500 を返します。
  • ブラウザから /v1/decide を呼び出す。API キーがすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。
  • タイムアウトがない、またはエラーで新規登録が止まる。200 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。