Korzen
Bot Protection

Rails

Bot Protection dans Rails : le tag dans le layout, l'appel avec Net::HTTP et une connexion gardée ouverte, request.remote_ip, et Devise.

Ce guide utilise le Net::HTTP de Ruby, sans gem : une connexion par thread, gardée ouverte entre les appels, donc un appel coûte un aller-retour et non une nouvelle poignée de main TLS.

Le tag

Dans le layout de l'application :

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

L'appel

Un module dans app/lib, que Rails charge tout seul. Chaque timeout borne une étape (connexion, écriture, lecture), donc un appel lent à chaque étape peut durer un peu plus que le 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 est l'ordre dans lequel les headers de la requête sont arrivés : un script qui copie les valeurs d'un navigateur en copie rarement l'ordre. Puma le garde dans les clés HTTP_* de l'env Rack, et place en dernier un nom avec _. Derrière nginx, un load balancer ou Cloudflare, c'est l'ordre dans lequel ce saut les transmet, pas celui du navigateur.

Puis, dans le contrôleur qui crée le compte. Avec Devise, mettez les mêmes lignes dans votre propre Users::RegistrationsController#create avant 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 du visiteur

request.remote_ip prend l'IP du visiteur dans X-Forwarded-For, en sautant les adresses privées auxquelles Rails fait confiance comme proxies (10.0.0.0/8, 192.168.0.0/16 et autres). Un load balancer sur un réseau privé n'a besoin de rien de plus. Derrière Cloudflare, dont les adresses sont publiques, ajoutez les plages de Cloudflare à config.action_dispatch.trusted_proxies, ou envoyez request.headers["CF-Connecting-IP"] si votre serveur ne reçoit du trafic que de Cloudflare.

Appliquer l'action

Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.

Garder le request_id

Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.

Tester sur votre machine

Lancez bin/rails server et ouvrez http://localhost:3000. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien. Dans Docker sur votre machine, l'app voit à la place l'adresse du bridge, comme 172.17.0.1 : la page de challenge vous ramène quand même sur localhost, puisque le tag y a vu votre page.

  1. Définissez KORZEN_API_KEY dans l'environnement (ou votre .env, avec dotenv) et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie _kz apparaît après quelques secondes.
  2. Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
  3. Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond 401, et l'inscription passe.

Erreurs fréquentes

  • request.ip au lieu de request.remote_ip. Il ne saute pas les proxies.
  • Un nouveau Net::HTTP.start par appel. Chacun ouvre une connexion TLS vers l'Allemagne : un ou deux allers-retours de plus.
  • Derrière Cloudflare sans ses plages dans trusted_proxies. Chaque visiteur a alors une adresse Cloudflare.
  • Appeler /v1/decide depuis le navigateur. L'API key atteindrait chaque visiteur ; l'appel a sa place sur votre serveur.
  • Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que 200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.