このガイドは Spring Boot 3.2 以降(Tomcat 上の Spring MVC、Java 17 以降)を対象にしています。呼び出しには、JDK 自体の HttpClient の上で Spring の RestClient をプロセスで 1 つ使うため、接続は維持されます。読み取りタイムアウトが、リクエストの送信から応答までの呼び出し全体を制限します。
タグ
新規登録、ログイン、チェックアウトの各ページが使うレイアウト(Thymeleaf なら共有するフラグメント)の <head> にタグを置きます。
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
呼び出し
キーは環境変数から読み、プロキシの forwarded ヘッダーを Tomcat に読ませます。
# src/main/resources/application.properties korzen.api-key=${KORZEN_API_KEY} server.forward-headers-strategy=native
// src/main/java/com/example/Korzen.java package com.example; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.net.http.HttpClient; import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.util.WebUtils; @Component public class Korzen { /** The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. */ private static final List<String> FORWARDED = List.of("accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"); private static final Duration TIMEOUT = Duration.ofMillis(200); private final RestClient client; // one per process: it keeps the connection open public Korzen(@Value("${korzen.api-key}") String apiKey) { var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder().connectTimeout(TIMEOUT).build()); factory.setReadTimeout(TIMEOUT); // the whole call, from the request to its answer client = RestClient.builder() .baseUrl("https://korzen.dev") .requestFactory(factory) .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Korzen-Deadline-Ms", "200") .build(); } /** The answer for this request, or null on any error: then go on as on allow (fail open). */ public Map<String, Object> decide(HttpServletRequest request, String path) { var headers = new HashMap<String, String>(); for (var name : FORWARDED) { var value = request.getHeader(name); if (value != null) { headers.put(name, value); } } Cookie token = WebUtils.getCookie(request, "_kz"); var body = new HashMap<String, Object>(); // a HashMap, since Map.of takes no null body.put("ip", request.getRemoteAddr()); // see "The visitor's IP" body.put("user_agent", request.getHeader("User-Agent")); body.put("headers", headers); body.put("token", token == null ? null : token.getValue()); body.put("path", path); body.put("method", "POST"); try { return client.post().uri("/v1/decide").contentType(MediaType.APPLICATION_JSON).body(body) .retrieve().body(new ParameterizedTypeReference<Map<String, Object>>() {}); } catch (RuntimeException e) { return null; // a timeout, a network error or any status but 200 } } }
サーブレットコンテナはリクエストのヘッダーの順序を保たないため、順序は送られません。次に、アカウントを作成するコントローラーで呼び出します。
// src/main/java/com/example/SignupController.java package com.example; import jakarta.servlet.http.HttpServletRequest; import java.net.URI; import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController public class SignupController { private final Korzen korzen; public SignupController(Korzen korzen) { this.korzen = korzen; } @PostMapping("/signup") public ResponseEntity<String> signup(HttpServletRequest request, @RequestParam String email) { Map<String, Object> d = korzen.decide(request, "/signup"); if (d != null && "block".equals(d.get("action"))) { return ResponseEntity.badRequest().body("We could not create your account."); } // "challenge": your CAPTCHA, or an email or phone check, before going on var user = accounts.create(email); // your own code user.setKorzenRequestId(d == null ? null : (String) d.get("request_id")); // see "Keep the request_id" return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/welcome")).build(); } }
訪問者の IP
request.getRemoteAddr() は接続元のアドレスで、プロキシがあればプロキシのアドレスになります。server.forward-headers-strategy=native は Tomcat の RemoteIpValve を有効にします。プライベートネットワーク(10.x、172.16–31.x、192.168.x、ループバック)上のプロキシから来たリクエストでは、そうしたプロキシが追加していない X-Forwarded-For の最後の値を取り、getRemoteAddr() がそれを返します。パブリックなアドレスのプロキシは、server.tomcat.remoteip.internal-proxies で指定するまで信頼されません。Cloudflare の背後にあり、サーバーが Cloudflare からのトラフィックしか受け付けない場合は、代わりに request.getHeader("CF-Connecting-IP") を読んでください。
アクションを適用する
読むのは action だけです。block の場合は、その操作がほかの理由で失敗したときと同じエラーを返し、bot に何も悟らせないようにします。challenge の場合は、すでにある確認手段(CAPTCHA、メールや電話の確認コード)を使います。何もなければそのまま続けます。アカウントは monitor mode で始まり、その間 action は常に allow です。block mode をオンにするまで、訪問者にとっては何も変わりません。
request_id を保存する
すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。
手元のマシンでテストする
./mvnw spring-boot:run(または ./gradlew bootRun)を実行し、localhost で開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または 0:0:0:0:0:0:0:1 になりますが、問題ありません。
- 環境変数に
KORZEN_API_KEYを設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに_kzCookie が表示されます。 - 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
- fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは
401を返し、新規登録はそのまま完了します。
よくある間違い
- ロードバランサーの背後なのに
server.forward-headers-strategyを設定しない。すべての訪問者がロードバランサーのプライベート IP になり、Bot Protection はそれを IP なしとして扱います。見えるものが大きく減り、セットアップページに警告が表示されます。 X-Forwarded-Forの最初の値を自分で読む。訪問者が書き込める値なので、bot が自分の IP を好きに選べてしまいます。- 呼び出しのたびに新しい
RestClientやHttpClientを作る。それぞれが自分の接続を開き、タイムアウトの中で TLS ハンドシェイクが発生します。 - ブラウザーから
/v1/decideを呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。 - タイムアウトがない、またはエラーで新規登録が止まる。
200以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。