Korzen
Bot Protection

Spring Boot

Bot Protection en Spring Boot: el tag, un componente para la llamada con RestClient bajo un solo timeout, los forwarded headers de Tomcat para la IP del visitante y el controller.

Esta guía es para Spring Boot 3.2 y posterior (Spring MVC sobre Tomcat, Java 17 o posterior). La llamada usa el RestClient de Spring sobre el HttpClient propio del JDK, uno para el proceso, que mantiene abierta la conexión; su read timeout acota la llamada entera, desde que se envía la petición hasta su respuesta.

El tag

Pon el tag en el <head> del layout que usan tus páginas de registro, inicio de sesión y checkout (con Thymeleaf, el fragmento que comparten):

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

La llamada

Lee la clave del entorno y deja que Tomcat lea los forwarded headers de tu 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 no guarda el orden de los headers de la petición, así que no se envía ninguno. Después, en el controller que crea la cuenta:

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

La IP del visitante

request.getRemoteAddr() es la dirección de la conexión, la de tu proxy si tienes uno. server.forward-headers-strategy=native activa el RemoteIpValve de Tomcat: desde un proxy en una red privada (10.x, 172.16–31.x, 192.168.x, loopback), toma la última entrada de X-Forwarded-For que no añadió ningún proxy de ese tipo, y getRemoteAddr() la devuelve. Un proxy con dirección pública no es de confianza hasta que lo nombras en server.tomcat.remoteip.internal-proxies. Detrás de Cloudflare, lee en su lugar request.getHeader("CF-Connecting-IP"), si tu servidor solo recibe tráfico de Cloudflare.

Actúa según la action

Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, sigue adelante. La cuenta empieza en monitor mode, donde action es siempre allow: nada cambia para los visitantes hasta que actives el block mode.

Guarda el request_id

Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa.

Pruébalo en tu máquina

Ejecuta ./mvnw spring-boot:run (o ./gradlew bootRun) y ábrelo en localhost. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o 0:0:0:0:0:0:0:1, y es normal.

  1. Define KORZEN_API_KEY en el entorno y abre la página de registro. En las herramientas de desarrollo del navegador, la cookie _kz aparece tras unos segundos.
  2. Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
  3. Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde 401, y el registro pasa.

Errores comunes

  • No configurar server.forward-headers-strategy detrás de un load balancer. Todos los visitantes tienen la IP privada del balancer, que Bot Protection toma por ninguna IP: ve mucho menos, y la página Configuración te avisa.
  • Leer tú mismo la primera entrada de X-Forwarded-For. La escribe el visitante, así que un bot elige su propia IP.
  • Un RestClient o un HttpClient nuevo en cada llamada. Cada uno abre su propia conexión, con un handshake TLS dentro de tu timeout.
  • Llamar a /v1/decide desde el navegador. La API key quedaría expuesta a cada visitante; la llamada se hace desde tu servidor.
  • No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de 200, o la falta de respuesta a tiempo, significa: deja pasar al visitante.