Korzen
Bot Protection

Spring Boot

Bot Protection in Spring Boot: il tag, un componente per la chiamata con RestClient sotto un solo timeout, i forwarded header di Tomcat per l'IP del visitatore, e il controller.

Questa guida è per Spring Boot 3.2 e successivi (Spring MVC su Tomcat, Java 17 o successivi). La chiamata usa il RestClient di Spring sull'HttpClient del JDK, uno per il processo, che tiene aperta la connessione; il suo read timeout limita l'intera chiamata, dall'invio della richiesta alla risposta.

Il tag

Metti il tag nel <head> del layout che usano le pagine di registrazione, login e checkout (con Thymeleaf, il fragment che condividono):

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

La chiamata

Leggi la chiave dall'ambiente, e lascia che Tomcat legga i forwarded header del tuo 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 servlet container non tiene l'ordine degli header della richiesta, quindi non ne viene inviato nessuno. Poi, nel controller che crea l'account:

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

request.getRemoteAddr() è l'indirizzo della connessione, quello del tuo proxy se ne hai uno. server.forward-headers-strategy=native attiva il RemoteIpValve di Tomcat: da un proxy su una rete privata (10.x, 172.16–31.x, 192.168.x, loopback), prende l'ultima voce di X-Forwarded-For che nessuno di questi proxy ha aggiunto, e getRemoteAddr() la restituisce. Un proxy su un indirizzo pubblico non è fidato finché non lo indichi in server.tomcat.remoteip.internal-proxies. Dietro Cloudflare, leggi invece request.getHeader("CF-Connecting-IP"), se il tuo server accetta traffico solo da Cloudflare.

Applica l'azione

Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode.

Conserva il request_id

Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso.

Provalo sulla tua macchina

Avvia ./mvnw spring-boot:run (o ./gradlew bootRun) e aprilo su localhost. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o 0:0:0:0:0:0:0:1, e va bene così.

  1. Imposta KORZEN_API_KEY nell'ambiente e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie _kz compare dopo qualche secondo.
  2. Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
  3. Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde 401, e la registrazione va a buon fine.

Errori comuni

  • Nessun server.forward-headers-strategy dietro un load balancer. Ogni visitatore ha allora l'IP privato del balancer, che Bot Protection tratta come nessun IP: vede molto meno, e la pagina Configurazione ti avvisa.
  • Leggere tu stesso la prima voce di X-Forwarded-For. La scrive il visitatore, quindi un bot si sceglie il proprio IP.
  • Un nuovo RestClient o HttpClient per ogni chiamata. Ognuno apre la propria connessione, con un handshake TLS dentro il tuo timeout.
  • Chiamare /v1/decide dal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server.
  • Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da 200, o nessuna risposta in tempo, significa: lascia passare il visitatore.