Korzen
Bot Protection

Rails

Bot Protection in Rails: il tag nel layout, la chiamata con Net::HTTP e una connessione tenuta aperta, request.remote_ip, e Devise.

Questa guida usa il Net::HTTP di Ruby, senza nessuna gem: una connessione per thread, tenuta aperta tra una chiamata e l'altra, così una chiamata costa un round trip e non un nuovo handshake TLS.

Il tag

Nel layout dell'applicazione:

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

La chiamata

Un modulo in app/lib, che Rails carica da solo. Ogni timeout limita un singolo passaggio (connessione, scrittura, lettura), quindi una chiamata lenta in ogni passaggio può durare un po' più del 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 è l'ordine in cui sono arrivati gli header della richiesta: uno script che copia i valori di un browser ne copia di rado l'ordine. Puma lo tiene nelle chiavi HTTP_* dell'env di Rack, e mette per ultimo un nome con _. Dietro nginx, un load balancer o Cloudflare, è l'ordine in cui quel passaggio li inoltra, non quello del browser.

Poi, nel controller che crea l'account. Con Devise, metti le stesse righe nel tuo Users::RegistrationsController#create prima di 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

L'IP del visitatore

request.remote_ip prende l'IP del visitatore da X-Forwarded-For, saltando gli indirizzi privati che Rails considera proxy fidati (10.0.0.0/8, 192.168.0.0/16 e simili). Un load balancer su una rete privata non richiede altro. Dietro Cloudflare, i cui indirizzi sono pubblici, aggiungi i range di Cloudflare a config.action_dispatch.trusted_proxies, oppure invia request.headers["CF-Connecting-IP"] se il tuo server accetta traffico solo da Cloudflare.

Applica l'azione

Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode.

Conserva il request_id

Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso.

Provalo sulla tua macchina

Avvia bin/rails server e apri http://localhost:3000. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o ::1, e va bene così. In Docker sulla tua macchina, l'app vede invece l'indirizzo del bridge, come 172.17.0.1: la pagina di challenge ti riporta comunque su localhost, perché il tag ha visto lì la tua pagina.

  1. Imposta KORZEN_API_KEY nell'ambiente (o nel tuo .env, con dotenv) e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie _kz compare dopo qualche secondo.
  2. Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
  3. Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde 401, e la registrazione va a buon fine.

Errori comuni

  • request.ip al posto di request.remote_ip. Non salta i proxy.
  • Un nuovo Net::HTTP.start per ogni chiamata. Ognuno apre una connessione TLS verso la Germania: uno o due round trip in più.
  • Dietro Cloudflare senza i suoi range in trusted_proxies. Ogni visitatore ha allora un indirizzo di Cloudflare.
  • Chiamare /v1/decide dal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server.
  • Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da 200, o nessuna risposta in tempo, significa: lascia passare il visitatore.