Korzen
Bot Protection

Go net/http

Bot Protection in een Go-server met alleen net/http: één client voor het proces, de hele call onder één timeout, en het IP-adres van de bezoeker.

Deze gids gebruikt alleen de standaardbibliotheek: net/http en één http.Client voor het proces, die zijn verbindingen openhoudt. Zijn Timeout begrenst de hele call, van verbinden tot het lezen van de body.

De tag

Zet de tag in de <head> van de layout die je registratie-, inlog- en checkoutpagina's rendert:

<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

Roep op de pagina, voordat het formulier wordt verstuurd, await window.korzen?.ready() aan: het meldt wat de bezoeker deed sinds de tag het laatst meldde, en is hoe dan ook binnen een paar seconden klaar. De tag

De call

De client is één bestand met alleen de standaardbibliotheek, korzen.go: hij vraagt /v1/decide, geeft op bij je timeout en laat de bezoeker door bij elke fout. Download hem in een package van je module en lees hem voordat je hem commit:

mkdir -p internal/korzen
curl -o internal/korzen/korzen.go https://korzen.dev/bot-protection/sdk/korzen.go

Eén Client voor het proces: hij houdt zijn verbindingen open, en zijn Timeout begrenst de hele call. Protect vraagt vóór de handler die het account aanmaakt, en zet het antwoord in de context van het request:

var bot = &korzen.Client{APIKey: os.Getenv("KORZEN_API_KEY"), URL: "https://korzen.dev", Timeout: 200 * time.Millisecond}

mux.Handle("POST /signup", bot.Protect(http.HandlerFunc(signup), korzen.Options{
	IP: korzen.RemoteIP, // see "The visitor's IP"
	OnBlock: func(w http.ResponseWriter, r *http.Request, d korzen.Decision) bool {
		http.Error(w, "We could not create your account.", http.StatusBadRequest)
		return true
	},
	// Your own check for challenge runs in signup: return false to go on to it. Without
	// OnChallenge, a challenged post is refused (403).
	OnChallenge: func(http.ResponseWriter, *http.Request, korzen.Decision) bool { return false },
}))

func signup(w http.ResponseWriter, r *http.Request) {
	d, _ := korzen.FromContext(r.Context()) // no answer: the zero Decision
	if d.Action == korzen.Challenge {
		// your CAPTCHA, or an email or phone check, before going on
	}
	user, err := createAccount(r) // your own code
	// ...
	saveRequestID(user.ID, d.RequestID) // see "Keep the request_id"
}

Om in een handler te vragen: d, err := bot.Decide(r.Context(), korzen.FromHTTP(r, korzen.RemoteIP(r))). Bij elke fout is d.Action allow, en err zegt waarom, voor je log. Een antwoord waarvan action niet allow, challenge of block is, telt ook als fout; de *korzen.Problem van een foutantwoord zit in err (errors.As). Om elke call te loggen, zet je OnAnswer van de client: die krijgt de status, Korzen-Request-Id en de RateLimit-headers.

Het IP-adres van de bezoeker

r.RemoteAddr is het adres van de verbinding: dat van je proxy, als je er een hebt. korzen.RemoteIP leest het: gebruik het als er niets voor je server staat. korzen.CloudflareIP leest CF-Connecting-IP, wat alleen klopt als je server uitsluitend verkeer van Cloudflare aanneemt; anders kan een bezoeker hem schrijven. Achter je eigen load balancer neemt korzen.LastForwardedIP de laatste waarde van X-Forwarded-For, die hij zelf toevoegde.

Voer de actie uit

Lees alleen action. Bij block antwoord je met dezelfde foutmelding die de actie toont als hij om een andere reden mislukt, zodat een bot niets leert. Bij challenge gebruik je de controle die je al hebt (een CAPTCHA, een code per e-mail of telefoon); heb je er geen, ga dan door. Het account begint in monitor mode, waar action altijd allow is: voor bezoekers verandert er niets tot je block mode aanzet. Of stuur de bezoeker naar onze challengepagina, op challenge.url: zie de challengepagina.

Zonder OnChallenge laat de middleware geen challenge door: het laden van een pagina (GET) gaat naar onze challengepagina, en een verstuurd formulier of een API-call wordt geweigerd met 403. Bescherm ook de pagina die het formulier toont: daar krijgt een persoon de challenge, en zijn pass laat het formulier door. Heb je een eigen controle, geef dan OnChallenge en voer hem daar uit. De middleware stuurt de persoon terug naar het opgevraagde adres, gelezen uit X-Forwarded-Host en X-Forwarded-Proto als je proxy die zet; achter een proxy die Host: localhost doorgeeft en geen van beide (standaard bij nginx), geef je PublicURL: "https://shop.example".

Bewaar de request_id

Elk antwoord heeft een request_id. Bewaar die bij wat de actie heeft aangemaakt, in een kolom van het account of de bestelling, of in een logregel met hun id. Zo weet je welke beslissing het was als je ons ernaar vraagt, en kun je later nagaan welk antwoord een nepaccount binnenliet. Blijkt het account goed of slecht, meld het dan terug: bot.Feedback(ctx, []korzen.Feedback{{RequestID: id, Outcome: "chargeback"}}, korzen.Batch{}) (zie feedback).

Test het op je eigen machine

Draai go run . en open je server op localhost. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP-adres 127.0.0.1 of ::1, en dat is prima. In Docker op je eigen machine ziet de app in plaats daarvan het adres van de bridge, zoals 172.17.0.1: de challengepagina brengt je toch terug naar localhost, want de tag zag je pagina daar.

  1. Zet KORZEN_API_KEY in de omgeving en open de registratiepagina. In de developer tools van de browser verschijnt na een paar seconden de _kz-cookie.
  2. Registreer je. De beslissing verschijnt op de pagina Beslissingen van het dashboard, onder het pad dat je stuurde.
  3. Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met 401, en de registratie gaat door.

Veelgemaakte fouten

  • Een HTTPClient met een nieuwe http.Transport per call, of de standaard van de bibliotheek, die twee verbindingen openhoudt. Elke nieuwe is een nieuwe TLS-verbinding: laat HTTPClient leeg, of geef de jouwe korzen.NewTransport(), één keer voor het proces.
  • CF-Connecting-IP lezen op een server waar Cloudflare niet voor staat. Een bot schrijft de header zelf en kiest zijn eigen IP-adres.
  • /v1/decide aanroepen vanuit de browser. De API key zou dan bij elke bezoeker terechtkomen; de call hoort op je server.
  • Geen timeout, of een fout die de registratie tegenhoudt. Elk ander antwoord dan 200, en geen antwoord op tijd, betekent: laat de bezoeker door.
  • Een regel in je log die begint met korzen: the visitor's IP sent to Bot Protection. De client schrijft hem één keer, bij de eerste beslissing waarvan het IP van je eigen netwerk is (van een load balancer, een proxy, een container), die Bot Protection beoordeelt als zonder IP: zet Options.IP op het adres van de bezoeker (het IP van de bezoeker). In Docker op je eigen machine is het het adres van de bridge, en daar onschuldig.