Korzen
Bot Protection

Spring Boot

Bot Protection no Spring Boot: a tag, um componente para a chamada com RestClient sob um só timeout, os forwarded headers do Tomcat para o IP do visitante e o controller.

Este guia é para Spring Boot 3.2 em diante (Spring MVC no Tomcat, Java 17 em diante). A chamada usa o RestClient do Spring sobre o HttpClient do próprio JDK, um para o processo, que mantém a conexão aberta; o read timeout dele limita a chamada inteira, do envio da requisição até a resposta.

A tag

Coloque a tag no <head> do layout que as páginas de cadastro, login e checkout usam (com Thymeleaf, o fragmento que elas compartilham):

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

A chamada

Leia a chave do ambiente e deixe o Tomcat ler os forwarded headers do seu 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
    }
  }
}

Um servlet container não guarda a ordem dos headers da requisição, então nenhuma é enviada. Depois, no controller que cria a conta:

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

O IP do visitante

request.getRemoteAddr() é o endereço da conexão, o do seu proxy se houver um. server.forward-headers-strategy=native liga o RemoteIpValve do Tomcat: vindo de um proxy numa rede privada (10.x, 172.16–31.x, 192.168.x, loopback), ele pega a última entrada do X-Forwarded-For que nenhum proxy desses acrescentou, e o getRemoteAddr() a devolve. Um proxy num endereço público não é confiável até você nomeá-lo em server.tomcat.remoteip.internal-proxies. Atrás do Cloudflare, leia request.getHeader("CF-Connecting-IP"), se o seu servidor só recebe tráfego do Cloudflare.

Siga a action

Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode.

Guarde o request_id

Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa.

Teste na sua máquina

Rode ./mvnw spring-boot:run (ou ./gradlew bootRun) e abra em localhost. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou 0:0:0:0:0:0:0:1, e não tem problema.

  1. Defina KORZEN_API_KEY no ambiente e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie _kz aparece depois de alguns segundos.
  2. Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
  3. Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde 401, e o cadastro passa.

Erros comuns

  • Nenhum server.forward-headers-strategy atrás de um load balancer. Todo visitante fica com o IP privado do balancer, que o Bot Protection toma por nenhum IP: ele vê bem menos, e a página Configuração avisa você.
  • Ler você mesmo a primeira entrada do X-Forwarded-For. Quem a define é o visitante, então o bot escolhe o próprio IP.
  • Um RestClient ou HttpClient novo a cada chamada. Cada um abre a própria conexão, com um handshake TLS dentro do seu timeout.
  • Chamar /v1/decide do navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor.
  • Sem timeout, ou um erro que trava o cadastro. Se a resposta não for 200, ou não chegar a tempo, deixe o visitante passar.