Korzen
Bot Protection

Rails

Bot Protection in Rails: de tag in de layout, de call met Net::HTTP en een verbinding die openblijft, request.remote_ip, en Devise.

Deze gids gebruikt het eigen Net::HTTP van Ruby, zonder gem: één verbinding per thread, open gehouden tussen calls, zodat een call één round trip kost en geen nieuwe TLS-handshake.

De tag

In de 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>

De call

Een module in app/lib, die Rails zelf laadt. Elke timeout begrenst één stap (verbinden, schrijven, lezen), dus een call die bij elke stap traag is, kan iets langer duren dan de timeout:

# 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 is de volgorde waarin de headers van het verzoek binnenkwamen: een script dat de waarden van een browser kopieert, kopieert zelden hun volgorde. Puma houdt die bij in de HTTP_*-sleutels van de Rack-env, en zet een naam met _ achteraan. Achter nginx, een load balancer of Cloudflare is het de volgorde waarin die hop ze doorstuurt, niet die van de browser.

Daarna, in de controller die het account aanmaakt. Met Devise zet je dezelfde regels in je eigen Users::RegistrationsController#create, vóór 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

Het IP-adres van de bezoeker

request.remote_ip haalt het IP-adres van de bezoeker uit X-Forwarded-For, en slaat daarbij de privéadressen over die Rails als proxy vertrouwt (10.0.0.0/8, 192.168.0.0/16 en dergelijke). Een load balancer op een privénetwerk heeft verder niets nodig. Achter Cloudflare, waarvan de adressen publiek zijn, voeg je de ranges van Cloudflare toe aan config.action_dispatch.trusted_proxies, of stuur je request.headers["CF-Connecting-IP"] als je server alleen verkeer van Cloudflare accepteert.

Voer de actie uit

Lees alleen action. Bij block antwoord je met dezelfde foutmelding die de actie toont als hij om een andere reden mislukt, zodat een bot niets leert. Bij challenge gebruik je de controle die je al hebt (een CAPTCHA, een code per e-mail of telefoon); heb je er geen, ga dan door. Het account begint in monitor mode, waar action altijd allow is: voor bezoekers verandert er niets tot je block mode aanzet.

Bewaar de request_id

Elk antwoord heeft een request_id. Bewaar die bij wat de actie heeft aangemaakt, in een kolom van het account of de bestelling, of in een logregel met hun id. Zo weet je welke beslissing het was als je ons ernaar vraagt, en kun je later nagaan welk antwoord een nepaccount binnenliet.

Test het op je eigen machine

Draai bin/rails server en open http://localhost:3000. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP-adres 127.0.0.1 of ::1, en dat is prima. In Docker op je eigen machine ziet de app in plaats daarvan het adres van de bridge, zoals 172.17.0.1: de challengepagina brengt je toch terug naar localhost, want de tag zag je pagina daar.

  1. Zet KORZEN_API_KEY in de omgeving (of in je .env, met dotenv) en open de registratiepagina. In de developer tools van de browser verschijnt na een paar seconden de _kz-cookie.
  2. Registreer je. De beslissing verschijnt op de pagina Beslissingen van het dashboard, onder het pad dat je stuurde.
  3. Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met 401, en de registratie gaat door.

Veelgemaakte fouten

  • request.ip in plaats van request.remote_ip. Dat slaat de proxy's niet over.
  • Een nieuwe Net::HTTP.start per call. Elk daarvan opent een TLS-verbinding naar Duitsland: een of twee round trips extra.
  • Achter Cloudflare zonder de ranges van Cloudflare in trusted_proxies. Elke bezoeker heeft dan een adres van Cloudflare.
  • /v1/decide aanroepen vanuit de browser. De API key zou dan bij elke bezoeker terechtkomen; de call hoort op je server.
  • Geen timeout, of een fout die de registratie tegenhoudt. Elk ander antwoord dan 200, en geen antwoord op tijd, betekent: laat de bezoeker door.