Diese Anleitung nutzt Rubys eigenes Net::HTTP, ohne Gem: eine Verbindung pro Thread, zwischen Aufrufen offen gehalten, sodass ein Aufruf einen Round Trip kostet und keinen neuen TLS-Handshake.
Der Tag
Im Application-Layout:
<%# app/views/layouts/application.html.erb, in <head> %> <script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
Der Aufruf
Ein Modul in app/lib, das Rails selbst lädt. Jeder Timeout begrenzt einen Schritt (Verbinden, Schreiben, Lesen), sodass ein Aufruf, der bei jedem Schritt langsam ist, etwas länger als der Timeout dauern kann:
# 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 ist die Reihenfolge, in der die Header der Anfrage ankamen: Ein Skript, das die Werte eines Browsers kopiert, kopiert selten ihre Reihenfolge. Puma hält sie in den HTTP_*-Schlüsseln des Rack-Env und stellt einen Namen mit _ ans Ende. Hinter nginx, einem Load Balancer oder Cloudflare ist es die Reihenfolge, in der dieser Hop sie weitergibt, nicht die des Browsers.
Dann im Controller, der das Konto anlegt. Mit Devise setzen Sie dieselben Zeilen in Ihren eigenen Users::RegistrationsController#create vor 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
Die IP des Besuchers
request.remote_ip nimmt die IP des Besuchers aus X-Forwarded-For und überspringt die privaten Adressen, denen Rails als Proxys vertraut (10.0.0.0/8, 192.168.0.0/16 und ähnliche). Ein Load Balancer in einem privaten Netz braucht nichts weiter. Hinter Cloudflare, dessen Adressen öffentlich sind, fügen Sie Cloudflares Bereiche zu config.action_dispatch.trusted_proxies hinzu, oder senden request.headers["CF-Connecting-IP"], wenn Ihr Server nur Traffic von Cloudflare annimmt.
Die Aktion anwenden
Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.
Die request_id aufbewahren
Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat.
Auf Ihrem Rechner testen
Führen Sie bin/rails server aus und öffnen Sie http://localhost:3000. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung. Läuft die App auf Ihrem Rechner in Docker, sieht sie stattdessen die Adresse der Bridge, etwa 172.17.0.1: Die Challenge-Seite bringt Sie trotzdem zu localhost zurück, denn der Tag hat Ihre Seite dort gesehen.
- Setzen Sie
KORZEN_API_KEYin der Umgebung (oder in Ihrer.env, mit dotenv) und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das_kz-Cookie. - Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
- Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet
401, und die Registrierung geht durch.
Häufige Fehler
request.ipstattrequest.remote_ip. Es überspringt die Proxys nicht.- Ein neues
Net::HTTP.startpro Aufruf. Jedes öffnet eine TLS-Verbindung nach Deutschland: ein oder zwei weitere Round Trips. - Hinter Cloudflare ohne dessen Bereiche in
trusted_proxies. Jeder Besucher hat dann eine Cloudflare-Adresse. /v1/decideaus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.- Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als
200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.