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 へ戻してくれます。
- 環境変数に
KORZEN_API_KEYを設定し、新規登録ページを開きます。数秒後、ブラウザの開発者ツールに_kzCookie が表示されます。 - 新規登録します。判定がダッシュボードの判定ページに、送信したパスで表示されます。
- fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは
401を返し、新規登録はそのまま完了します。
よくある間違い
- 呼び出しごとに新しい
reqwest::Clientを作る。そのたびにドイツへの TLS 接続が開かれ、往復が 1〜2 回増えます。 into_make_service_with_connect_infoなしでアプリを起動する。ConnectInfoが失敗し、Axum はそのルートで500を返します。- ブラウザから
/v1/decideを呼び出す。API キーがすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。 - タイムアウトがない、またはエラーで新規登録が止まる。
200以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。