Esta guía usa el Net::HTTP de Ruby, sin gems: una conexión por thread, abierta entre llamadas, para que una llamada cueste un viaje de ida y vuelta y no un nuevo handshake TLS.
El tag
En el layout de la aplicación:
<%# 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 llamada
Un módulo en app/lib, que Rails carga solo. Cada timeout limita un paso (conectar, escribir, leer), así que una llamada lenta en todos los pasos puede pasarse un poco 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 es el orden en que llegaron los headers de la petición: un script que copia los valores de un navegador rara vez copia su orden. Puma lo guarda en las claves HTTP_* del env de Rack, y pone al final un nombre con _. Detrás de nginx, un balanceador de carga o Cloudflare, es el orden en que ese salto los reenvía, no el del navegador.
Luego, en el controller que crea la cuenta. Con Devise, pon las mismas líneas en tu Users::RegistrationsController#create antes de 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
La IP del visitante
request.remote_ip toma la IP del visitante de X-Forwarded-For, saltando las direcciones privadas que Rails trata como proxies (10.0.0.0/8, 192.168.0.0/16 y similares). Un load balancer en una red privada no necesita nada más. Detrás de Cloudflare, cuyas direcciones son públicas, añade los rangos de Cloudflare a config.action_dispatch.trusted_proxies, o envía request.headers["CF-Connecting-IP"] si tu servidor solo recibe tráfico de Cloudflare.
Actúa según la action
Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, sigue adelante. La cuenta empieza en monitor mode, donde action es siempre allow: nada cambia para los visitantes hasta que actives el block mode.
Guarda el request_id
Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa.
Pruébalo en tu máquina
Ejecuta bin/rails server y abre http://localhost:3000. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o ::1, y es normal. En Docker en tu máquina, la app ve en su lugar la dirección del bridge, como 172.17.0.1: la página de desafío te devuelve igual a localhost, porque el tag vio tu página allí.
- Define
KORZEN_API_KEYen el entorno (o tu.env, con dotenv) y abre la página de registro. En las herramientas de desarrollo del navegador, la cookie_kzaparece tras unos segundos. - Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
- Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde
401, y el registro pasa.
Errores comunes
request.ipen vez derequest.remote_ip. No salta los proxies.- Un
Net::HTTP.startnuevo por llamada. Cada uno abre una conexión TLS hasta Alemania: uno o dos viajes de ida y vuelta más. - Detrás de Cloudflare sin sus rangos en
trusted_proxies. Todos los visitantes tienen una dirección de Cloudflare. - Llamar a
/v1/decidedesde el navegador. La API key quedaría expuesta a cada visitante; la llamada se hace desde tu servidor. - No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de
200, o la falta de respuesta a tiempo, significa: deja pasar al visitante.