Questa guida è per ASP.NET Core 8 e successivi, con le minimal API; in un controller MVC la chiamata è la stessa. Un solo HttpClient serve il processo e tiene aperta la connessione, e un CancellationTokenSource limita l'intera chiamata, connessione compresa.
Il tag
Metti il tag nel <head> del layout che usano le pagine di registrazione, login e checkout (Pages/Shared/_Layout.cshtml o Views/Shared/_Layout.cshtml):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
La chiamata
// 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 } } }
Kestrel non tiene l'ordine degli header della richiesta, quindi non ne viene inviato nessuno. Poi, in Program.cs, i forwarded header e l'endpoint che crea l'account:
// 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();
L'IP del visitatore
Connection.RemoteIpAddress è l'indirizzo della connessione, quello del tuo proxy se ne hai uno. UseForwardedHeaders con XForwardedFor lo sostituisce con l'ultima voce di X-Forwarded-For, quella che il tuo proxy ha aggiunto, ma solo quando la connessione arriva da un proxy noto: di default il loopback. Aggiungi l'indirizzo del tuo load balancer a KnownProxies (o la sua rete alle reti note), e chiama UseForwardedHeaders prima di qualsiasi cosa legga l'IP. Dietro Cloudflare, leggi invece context.Request.Headers["CF-Connecting-IP"], se il tuo server accetta traffico solo da Cloudflare.
Applica l'azione
Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode.
Conserva il request_id
Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso.
Provalo sulla tua macchina
Avvia dotnet run e aprilo su localhost. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o ::1, e va bene così.
- Imposta
KORZEN_API_KEYnell'ambiente (o condotnet user-secrets set KORZEN_API_KEY …) e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie_kzcompare dopo qualche secondo. - Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
- Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde
401, e la registrazione va a buon fine.
Errori comuni
- Un load balancer assente da
KnownProxies. Il middleware allora ignoraX-Forwarded-For, e ogni visitatore ha l'IP privato del balancer, che Bot Protection tratta come nessun IP. - Svuotare
KnownProxiese le reti note per fidarsi di ogni hop. L'IP diventa quello che il visitatore ha scritto inX-Forwarded-For. new HttpClient()per ogni chiamata. Ognuno apre la propria connessione, con un handshake TLS dentro il tuo timeout, e può esaurire i socket.- Chiamare
/v1/decidedal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server. - Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da
200, o nessuna risposta in tempo, significa: lascia passare il visitatore.