Korzen
Bot Protection

Rails

Bot Protection no Rails: a tag no layout, a chamada com Net::HTTP e uma conexão mantida aberta, request.remote_ip e Devise.

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á.

  1. Defina KORZEN_API_KEY no ambiente (ou o seu .env, com dotenv) e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie _kz aparece depois de alguns segundos.
  2. Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
  3. Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde 401, e o cadastro passa.

Erros comuns

  • request.ip em vez de request.remote_ip. Ele não pula os proxies.
  • Um Net::HTTP.start novo 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/decide do 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.