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.
- Définissez
KORZEN_API_KEYdans l'environnement (ou votre.env, avec dotenv) et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie_kzapparaît après quelques secondes. - Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
- 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.ipau lieu derequest.remote_ip. Il ne saute pas les proxies.- Un nouveau
Net::HTTP.startpar 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/decidedepuis 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.