Este guia usa o Net::HTTP do próprio Ruby, sem gem: uma conexão por thread, mantida aberta entre chamadas, para que uma chamada custe uma ida e volta e não um novo handshake TLS.
A tag
No layout da aplicação:
<%# app/views/layouts/application.html.erb, in <head> %> <script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
A chamada
Um módulo em app/lib, que o Rails carrega sozinho. Cada timeout limita uma etapa (conectar, escrever, ler), então uma chamada lenta em todas as etapas pode passar um pouco do 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 é a ordem em que os headers da requisição chegaram: um script que copia os valores de um navegador raramente copia a ordem deles. O Puma guarda essa ordem nas chaves HTTP_* do env do Rack, e põe por último um nome com _. Atrás do nginx, de um load balancer ou do Cloudflare, é a ordem em que esse salto os repassa, não a do navegador.
Depois, no controller que cria a conta. Com Devise, ponha as mesmas linhas no seu Users::RegistrationsController#create antes do 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
O IP do visitante
request.remote_ip tira o IP do visitante do X-Forwarded-For, pulando os endereços privados que o Rails trata como proxies (10.0.0.0/8, 192.168.0.0/16 e afins). Um load balancer numa rede privada não precisa de mais nada. Atrás do Cloudflare, cujos endereços são públicos, acrescente as faixas do Cloudflare a config.action_dispatch.trusted_proxies, ou envie request.headers["CF-Connecting-IP"] se o seu servidor só recebe tráfego do Cloudflare.
Siga a action
Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode.
Guarde o request_id
Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa.
Teste na sua máquina
Rode bin/rails server e abra http://localhost:3000. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou ::1, e não tem problema. No Docker na sua máquina, o app vê em vez disso o endereço da bridge, como 172.17.0.1: a página de desafio ainda traz você de volta para localhost, porque a tag viu sua página lá.
- Defina
KORZEN_API_KEYno ambiente (ou o seu.env, com dotenv) e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie_kzaparece depois de alguns segundos. - Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
- Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde
401, e o cadastro passa.
Erros comuns
request.ipem vez derequest.remote_ip. Ele não pula os proxies.- Um
Net::HTTP.startnovo por chamada. Cada um abre uma conexão TLS até a Alemanha: uma ou duas idas e voltas a mais. - Atrás do Cloudflare sem as faixas dele em
trusted_proxies. Todo visitante fica com um endereço do Cloudflare. - Chamar
/v1/decidedo navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor. - Sem timeout, ou um erro que trava o cadastro. Se a resposta não for
200, ou não chegar a tempo, deixe o visitante passar.