Korzen
Bot Protection

Rails

Bot Protection in Rails: der Tag im Layout, der Aufruf mit Net::HTTP und einer offen gehaltenen Verbindung, request.remote_ip und Devise.

Diese Anleitung nutzt Rubys eigenes Net::HTTP, ohne Gem: eine Verbindung pro Thread, zwischen Aufrufen offen gehalten, sodass ein Aufruf einen Round Trip kostet und keinen neuen TLS-Handshake.

Der Tag

Im Application-Layout:

<%# app/views/layouts/application.html.erb, in <head> %>
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

Der Aufruf

Ein Modul in app/lib, das Rails selbst lädt. Jeder Timeout begrenzt einen Schritt (Verbinden, Schreiben, Lesen), sodass ein Aufruf, der bei jedem Schritt langsam ist, etwas länger als der Timeout dauern kann:

# app/lib/korzen.rb
require "net/http"
require "json"

module Korzen
  URL = URI("https://korzen.dev/v1/decide")
  TIMEOUT = 0.2
  # The headers Bot Protection reads: those one browser build sends alike, and Accept-Language.
  FORWARDED = %w[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].freeze

  # The answer for this request, or nil on any error: then go on as on allow (fail open).
  def self.decide(request, path)
    body = {
      ip: request.remote_ip,
      user_agent: request.user_agent,
      headers: FORWARDED.filter_map { |h| [h, request.headers[h]] if request.headers[h] }.to_h,
      header_order: header_order(request),
      token: request.cookies["_kz"],
      path: path,
      method: "POST"
    }
    res = http.post(URL.path, body.to_json,
                    "Authorization" => "Bearer #{ENV.fetch("KORZEN_API_KEY")}", "Content-Type" => "application/json")
    res.is_a?(Net::HTTPOK) ? JSON.parse(res.body) : nil
  rescue StandardError
    Thread.current[:korzen]&.finish rescue nil
    Thread.current[:korzen] = nil
    nil
  end

  # The order the request's headers came in, as Puma keeps it in the Rack env: at most 64
  # names, lower-cased, "_" as "-".
  def self.header_order(request)
    request.env.keys.grep(/\AHTTP_|\ACONTENT_(TYPE|LENGTH)\z/).first(64).map { |k| k.delete_prefix("HTTP_").downcase.tr("_", "-") }
  end

  # One connection per thread, kept open between calls.
  def self.http
    Thread.current[:korzen] ||= Net::HTTP.start(URL.host, URL.port, use_ssl: true, keep_alive_timeout: 30,
                                                open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT)
  end
end

header_order ist die Reihenfolge, in der die Header der Anfrage ankamen: Ein Skript, das die Werte eines Browsers kopiert, kopiert selten ihre Reihenfolge. Puma hält sie in den HTTP_*-Schlüsseln des Rack-Env und stellt einen Namen mit _ ans Ende. Hinter nginx, einem Load Balancer oder Cloudflare ist es die Reihenfolge, in der dieser Hop sie weitergibt, nicht die des Browsers.

Dann im Controller, der das Konto anlegt. Mit Devise setzen Sie dieselben Zeilen in Ihren eigenen Users::RegistrationsController#create vor super:

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def create
    decision = Korzen.decide(request, "/signup")
    if decision&.dig("action") == "block"
      flash.now[:alert] = "We could not create your account."
      return render :new, status: :unprocessable_entity
    end
    # "challenge": your CAPTCHA, or an email or phone check, before going on
    @user = User.create!(user_params.merge(korzen_request_id: decision&.dig("request_id")))
    redirect_to welcome_path
  end
end

Die IP des Besuchers

request.remote_ip nimmt die IP des Besuchers aus X-Forwarded-For und überspringt die privaten Adressen, denen Rails als Proxys vertraut (10.0.0.0/8, 192.168.0.0/16 und ähnliche). Ein Load Balancer in einem privaten Netz braucht nichts weiter. Hinter Cloudflare, dessen Adressen öffentlich sind, fügen Sie Cloudflares Bereiche zu config.action_dispatch.trusted_proxies hinzu, oder senden request.headers["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

Führen Sie bin/rails server aus und öffnen Sie http://localhost:3000. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung. Läuft die App auf Ihrem Rechner in Docker, sieht sie stattdessen die Adresse der Bridge, etwa 172.17.0.1: Die Challenge-Seite bringt Sie trotzdem zu localhost zurück, denn der Tag hat Ihre Seite dort gesehen.

  1. Setzen Sie KORZEN_API_KEY in der Umgebung (oder in Ihrer .env, mit dotenv) 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

  • request.ip statt request.remote_ip. Es überspringt die Proxys nicht.
  • Ein neues Net::HTTP.start pro Aufruf. Jedes öffnet eine TLS-Verbindung nach Deutschland: ein oder zwei weitere Round Trips.
  • Hinter Cloudflare ohne dessen Bereiche in trusted_proxies. Jeder Besucher hat dann eine Cloudflare-Adresse.
  • /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.