Korzen
Bot Protection

Rails

Rails に Bot Protection を導入します。レイアウトのタグ、接続を維持した Net::HTTP による呼び出し、request.remote_ip、Devise を説明します。

このガイドは gem を使わず、Ruby 標準の Net::HTTP を使います。スレッドごとに 1 つの接続を呼び出し間で維持するため、1 回の呼び出しにかかるのは往復 1 回分だけで、新しい TLS ハンドシェイクは発生しません。

タグ

アプリケーションのレイアウトに置きます。

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

呼び出し

app/lib にモジュールを置けば、Rails が自動で読み込みます。各タイムアウトは 1 つのステップ(接続、書き込み、読み取り)ごとの上限なので、すべてのステップが遅い呼び出しはタイムアウトより少し長くかかることがあります。

# 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 はリクエストのヘッダーが届いた順序です。ブラウザの値をコピーするスクリプトでも、その順序まで真似ることはまれです。Puma はこの順序を Rack の env の HTTP_* キーに保ち、_ を含む名前は最後に回します。nginx、ロードバランサー、Cloudflare の後ろでは、ブラウザの順序ではなく、その中継が転送する順序になります。

次に、アカウントを作成するコントローラーで呼び出します。Devise を使っている場合は、独自の Users::RegistrationsController#create の 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

訪問者の IP

request.remote_ip は、Rails がプロキシとして信頼するプライベートアドレス(10.0.0.0/8、192.168.0.0/16 など)を飛ばして、X-Forwarded-For から訪問者の IP を取り出します。プライベートネットワーク上のロードバランサーなら、追加の設定は不要です。アドレスがパブリックな Cloudflare の背後では、config.action_dispatch.trusted_proxies に Cloudflare のアドレス範囲を追加するか、サーバーが Cloudflare からのトラフィックしか受け付けない場合は request.headers["CF-Connecting-IP"] を送ってください。

アクションを適用する

読むのは action だけです。block の場合は、その操作がほかの理由で失敗したときと同じエラーを返し、bot に何も悟らせないようにします。challenge の場合は、すでにある確認手段(CAPTCHA、メールや電話の確認コード)を使います。何もなければそのまま続けます。アカウントは monitor mode で始まり、その間 action は常に allow です。block mode をオンにするまで、訪問者にとっては何も変わりません。

request_id を保存する

すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。

手元のマシンでテストする

bin/rails server を実行し、http://localhost:3000 を開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。自分のマシンの Docker でアプリを動かすと、代わりにブリッジのアドレス(172.17.0.1 など)になりますが、タグがそこであなたのページを見ているので、チャレンジページは localhost へ戻してくれます。

  1. 環境変数(dotenv を使うなら .env)に KORZEN_API_KEY を設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに _kz Cookie が表示されます。
  2. 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
  3. fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは 401 を返し、新規登録はそのまま完了します。

よくある間違い

  • request.remote_ip ではなく request.ip を使う。プロキシを飛ばしてくれません。
  • 呼び出しごとに Net::HTTP.start する。そのたびにドイツへの TLS 接続が開かれ、往復が 1〜2 回増えます。
  • Cloudflare の背後なのに、そのアドレス範囲を trusted_proxies に入れていない。すべての訪問者が Cloudflare のアドレスになります。
  • ブラウザーから /v1/decide を呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。
  • タイムアウトがない、またはエラーで新規登録が止まる。200 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。