Korzen
Bot Protection

Spring Boot

Bot Protection dans Spring Boot : le tag, un composant pour l'appel avec RestClient sous un seul timeout, les forwarded headers de Tomcat pour l'IP du visiteur, et le contrôleur.

Ce guide vaut pour Spring Boot 3.2 et plus récent (Spring MVC sur Tomcat, Java 17 ou plus récent). L'appel utilise le RestClient de Spring sur le HttpClient du JDK lui-même, un seul pour le processus, qui garde la connexion ouverte ; son timeout de lecture borne l'appel entier, de l'envoi de la requête à sa réponse.

Le tag

Placez le tag dans le <head> du layout qu'utilisent vos pages d'inscription, de connexion et de checkout (avec Thymeleaf, le fragment qu'elles partagent) :

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

L'appel

Lisez la clé dans l'environnement, et laissez Tomcat lire les forwarded headers de votre proxy :

# 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
    }
  }
}

Un conteneur de servlets ne garde aucun ordre des headers de la requête, donc aucun n'est envoyé. Puis, dans le contrôleur qui crée le compte :

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

L'IP du visiteur

request.getRemoteAddr() est l'adresse de la connexion, celle de votre proxy si vous en avez un. server.forward-headers-strategy=native active le RemoteIpValve de Tomcat : depuis un proxy sur un réseau privé (10.x, 172.16–31.x, 192.168.x, loopback), il prend la dernière entrée de X-Forwarded-For qu'aucun de ces proxies n'a ajoutée, et getRemoteAddr() la renvoie. Un proxy sur une adresse publique n'est pas de confiance tant que vous ne le nommez pas dans server.tomcat.remoteip.internal-proxies. Derrière Cloudflare, lisez plutôt request.getHeader("CF-Connecting-IP"), si votre serveur ne reçoit du trafic que 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, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.

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.

Tester sur votre machine

Lancez ./mvnw spring-boot:run (ou ./gradlew bootRun) et ouvrez-le sur localhost. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou 0:0:0:0:0:0:0:1, et c'est très bien.

  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

  • Aucun server.forward-headers-strategy derrière un load balancer. Chaque visiteur a alors l'IP privée du load balancer, que Bot Protection traite comme une absence d'IP : il en voit bien moins, et la page Configuration vous prévient.
  • Lire vous-même la première entrée de X-Forwarded-For. Le visiteur l'écrit, donc un bot choisit sa propre IP.
  • Un nouveau RestClient ou HttpClient pour chaque appel. Chacun ouvre sa propre connexion, avec une poignée de main TLS dans votre timeout.
  • Appeler /v1/decide depuis le navigateur. L'API key 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.