Este guia é para ASP.NET Core 8 em diante, com minimal APIs; num controller MVC a chamada é a mesma. Um HttpClient serve o processo e mantém a conexão aberta, e um CancellationTokenSource limita a chamada inteira, conexão incluída.
A tag
Coloque a tag no <head> do layout que as páginas de cadastro, login e checkout usam (Pages/Shared/_Layout.cshtml ou Views/Shared/_Layout.cshtml):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
A chamada
// Korzen.cs using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; public sealed class Korzen(string apiKey) { // The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. static readonly string[] Forwarded = ["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"]; // One for the process: it keeps the connection open, and opens a new one every 15 minutes to follow DNS. static readonly HttpClient Http = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(15) }); /// <summary>The answer for this request, or null on any error: then go on as on allow (fail open).</summary> public async Task<JsonElement?> Decide(HttpContext context, string path) { var request = context.Request; var headers = new Dictionary<string, string>(); foreach (var name in Forwarded) { if (request.Headers.TryGetValue(name, out var value)) { headers[name] = value.ToString(); } } var ip = context.Connection.RemoteIpAddress; // see "The visitor's IP" using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); // the whole call try { using var call = new HttpRequestMessage(HttpMethod.Post, "https://korzen.dev/v1/decide") { Content = JsonContent.Create(new Dictionary<string, object?> { ["ip"] = ip is { IsIPv4MappedToIPv6: true } ? ip.MapToIPv4().ToString() : ip?.ToString(), ["user_agent"] = request.Headers.UserAgent.ToString(), ["headers"] = headers, ["token"] = request.Cookies["_kz"], ["path"] = path, ["method"] = "POST", }), }; call.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); call.Headers.Add("Korzen-Deadline-Ms", "200"); using var res = await Http.SendAsync(call, deadline.Token); if (res.StatusCode != HttpStatusCode.OK) { return null; } return await res.Content.ReadFromJsonAsync<JsonElement>(deadline.Token); } catch (Exception) { return null; // a timeout, a network error or an answer that is no JSON } } }
O Kestrel não guarda a ordem dos headers da requisição, então nenhuma é enviada. Depois, em Program.cs, os forwarded headers e o endpoint que cria a conta:
// Program.cs using System.Net; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(new Korzen(builder.Configuration["KORZEN_API_KEY"] ?? "")); builder.Services.Configure<ForwardedHeadersOptions>(o => { o.ForwardedHeaders = ForwardedHeaders.XForwardedFor; // your proxy's entry: see "The visitor's IP" // o.KnownProxies.Add(IPAddress.Parse("10.0.0.5")); // your proxy, when it is not on this machine }); var app = builder.Build(); app.UseForwardedHeaders(); app.MapPost("/signup", async (HttpContext context, Korzen korzen) => { var d = await korzen.Decide(context, "/signup"); if (d?.GetProperty("action").GetString() == "block") { return Results.Text("We could not create your account.", statusCode: 400); } // "challenge": your CAPTCHA, or an email or phone check, before going on var user = await CreateAccount(await context.Request.ReadFormAsync()); // your own code await SaveRequestId(user, d?.GetProperty("request_id").GetString()); // see "Keep the request_id" return Results.Redirect("/welcome"); }); app.Run();
O IP do visitante
Connection.RemoteIpAddress é o endereço da conexão, o do seu proxy se houver um. UseForwardedHeaders com XForwardedFor o troca pela última entrada do X-Forwarded-For, a que o seu proxy acrescentou, mas só quando a conexão vem de um proxy conhecido: o loopback, por padrão. Adicione o endereço do seu load balancer a KnownProxies (ou a rede dele às redes conhecidas), e chame UseForwardedHeaders antes de qualquer coisa que leia o IP. Atrás do Cloudflare, leia context.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 dotnet run e abra 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.
- Defina
KORZEN_API_KEYno ambiente (ou comdotnet user-secrets set KORZEN_API_KEY …) e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie_kzaparece depois de alguns segundos. - Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
- Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde
401, e o cadastro passa.
Erros comuns
- Um load balancer fora de
KnownProxies. O middleware então ignora oX-Forwarded-For, e todo visitante fica com o IP privado do balancer, que o Bot Protection toma por nenhum IP. - Esvaziar
KnownProxiese as redes conhecidas para confiar em todo salto. O IP vira o que o visitante escreveu noX-Forwarded-For. new HttpClient()a cada chamada. Cada um abre a própria conexão, com um handshake TLS dentro do seu timeout, e os sockets podem acabar.- Chamar
/v1/decidedo 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.