Korzen
Bot Protection

Spring Boot

Bot Protection in Spring Boot: der Tag, eine Komponente für den Aufruf mit RestClient unter einem Timeout, die Forwarded-Header von Tomcat für die IP des Besuchers und der Controller.

Diese Anleitung gilt für Spring Boot 3.2 und neuer (Spring MVC auf Tomcat, Java 17 oder neuer). Der Aufruf verwendet den RestClient von Spring auf dem eigenen HttpClient des JDK, einer für den Prozess, der die Verbindung offen hält; sein Read-Timeout begrenzt den ganzen Aufruf, vom Senden der Anfrage bis zu ihrer Antwort.

Der Tag

Setzen Sie den Tag in den <head> des Layouts, das Ihre Registrierungs-, Anmelde- und Checkout-Seiten verwenden (mit Thymeleaf das Fragment, das sie teilen):

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

Der Aufruf

Lesen Sie den Schlüssel aus der Umgebung und lassen Sie Tomcat die Forwarded-Header Ihres Proxys lesen:

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

Ein Servlet-Container hält keine Reihenfolge der Header der Anfrage, also wird keine gesendet. Dann, im Controller, der das Konto anlegt:

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

Die IP des Besuchers

request.getRemoteAddr() ist die Adresse der Verbindung, die Ihres Proxys, wenn Sie einen haben. server.forward-headers-strategy=native schaltet das RemoteIpValve von Tomcat ein: Von einem Proxy in einem privaten Netz (10.x, 172.16–31.x, 192.168.x, Loopback) nimmt es den letzten Eintrag von X-Forwarded-For, den kein solcher Proxy angehängt hat, und getRemoteAddr() liefert ihn. Einem Proxy mit öffentlicher Adresse wird erst vertraut, wenn Sie ihn in server.tomcat.remoteip.internal-proxies nennen. Hinter Cloudflare lesen Sie stattdessen request.getHeader("CF-Connecting-IP"), wenn Ihr Server nur Traffic von Cloudflare annimmt.

Die Aktion anwenden

Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.

Die request_id aufbewahren

Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat.

Auf Ihrem Rechner testen

Starten Sie ./mvnw spring-boot:run (oder ./gradlew bootRun) und öffnen Sie es auf localhost. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder 0:0:0:0:0:0:0:1, und das ist in Ordnung.

  1. Setzen Sie KORZEN_API_KEY in der Umgebung und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das _kz-Cookie.
  2. Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
  3. Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet 401, und die Registrierung geht durch.

Häufige Fehler

  • Kein server.forward-headers-strategy hinter einem Load Balancer. Jeder Besucher hat dann die private IP des Balancers, die Bot Protection wie keine IP behandelt: Es sieht viel weniger, und die Seite Einrichtung weist Sie darauf hin.
  • Den ersten Eintrag von X-Forwarded-For selbst lesen. Der Besucher schreibt ihn, also wählt ein Bot seine eigene IP.
  • Ein neuer RestClient oder HttpClient für jeden Aufruf. Jeder öffnet seine eigene Verbindung, mit einem TLS-Handshake innerhalb Ihres Timeouts.
  • /v1/decide aus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.
  • Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als 200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.