Korzen
Bot Protection

Go net/http

Bot Protection num servidor Go só com net/http: um cliente para o processo, a chamada inteira sob um timeout e o IP do visitante.

Este guia usa só a biblioteca padrão: net/http e um http.Client para o processo, que mantém as conexões abertas. O Timeout dele limita a chamada inteira, da conexão até a leitura do corpo.

A tag

Coloque a tag no <head> do layout que renderiza as páginas de cadastro, login e checkout:

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

Na página, antes de enviar o formulário, chame await window.korzen?.ready(): ela informa o que o visitante fez desde o último relato da tag e resolve em poucos segundos, aconteça o que acontecer. A tag

A chamada

O cliente é um arquivo só, apenas com a biblioteca padrão, o korzen.go: ele pergunta ao /v1/decide, desiste no seu timeout e deixa o visitante passar em qualquer erro. Baixe-o para um pacote do seu módulo e leia-o antes do commit:

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

Um Client para o processo: ele mantém as conexões abertas, e o seu Timeout limita a chamada inteira. O Protect pergunta antes do handler que cria a conta e põe a resposta no contexto da requisição:

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"
}

Para perguntar dentro de um handler: d, err := bot.Decide(r.Context(), korzen.FromHTTP(r, korzen.RemoteIP(r))). Em qualquer erro, d.Action é allow, e err diz o motivo, para o seu log. Uma resposta cujo action não é allow, challenge ou block também conta como erro; o *korzen.Problem de uma resposta de erro está em err (errors.As). Para registrar cada chamada, defina o OnAnswer do client: ele recebe o status, o Korzen-Request-Id e os headers RateLimit.

O IP do visitante

r.RemoteAddr é o endereço da conexão: o do seu proxy, se houver. O korzen.RemoteIP o lê: use-o quando nada fica na frente do seu servidor. O korzen.CloudflareIP lê CF-Connecting-IP, o que só está certo se o seu servidor recebe tráfego apenas do Cloudflare; senão, o visitante pode forjá-lo. Atrás do seu próprio load balancer, o korzen.LastForwardedIP pega a última entrada do X-Forwarded-For, a que ele acrescentou.

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. Ou mande o visitante para a nossa página de desafio, em challenge.url: veja a página de desafio.

Sem OnChallenge, o middleware não deixa passar nenhum desafio: um carregamento de página (GET) vai para a nossa página de desafio, e um envio de formulário ou uma chamada de API é recusado com 403. Proteja também a página que mostra o formulário, para que a pessoa encontre o desafio ali e a aprovação dela deixe o envio passar. Com uma verificação sua, passe OnChallenge e rode-a ali. O middleware manda a pessoa de volta ao endereço que ela pediu, lido de X-Forwarded-Host e X-Forwarded-Proto quando seu proxy os define; atrás de um proxy que repassa Host: localhost e nenhum dos dois (o padrão do nginx), dê PublicURL: "https://shop.example".

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. Quando a conta se mostrar boa ou ruim, avise: bot.Feedback(ctx, []korzen.Feedback{{RequestID: id, Outcome: "chargeback"}}, korzen.Batch{}) (veja feedback).

Teste na sua máquina

Rode go run . e abra o seu servidor em localhost. 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 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

  • Um HTTPClient com um http.Transport novo por chamada, ou o padrão da biblioteca, que mantém duas conexões abertas. Cada nova é uma conexão TLS nova: deixe o HTTPClient vazio, ou dê ao seu um korzen.NewTransport(), uma vez para o processo.
  • Ler CF-Connecting-IP num servidor que não está atrás do Cloudflare. O bot forja o cabeçalho e escolhe o próprio IP.
  • 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.
  • Uma linha no seu log que começa com korzen: the visitor's IP sent to Bot Protection. O cliente a escreve uma vez, na primeira decisão cujo IP é da sua própria rede (de um load balancer, de um proxy, de um contêiner), que o Bot Protection julga como sem IP: defina Options.IP como o endereço do visitante (o IP do visitante). No Docker na sua máquina, é o endereço da bridge, e ali não faz mal.