Korzen
Bot Protection

ASP.NET Core

Bot Protection in ASP.NET Core: de tag, één class voor de call met één HttpClient onder één timeout, de forwarded headers-middleware voor het IP-adres van de bezoeker, en het endpoint.

Deze gids is voor ASP.NET Core 8 en nieuwer, met minimal APIs; in een MVC-controller is de call hetzelfde. Eén HttpClient dient het hele proces en houdt de verbinding open, en een CancellationTokenSource begrenst de hele call, de verbinding inbegrepen.

De tag

Zet de tag in de <head> van de layout die je registratie-, inlog- en checkoutpagina's gebruiken (Pages/Shared/_Layout.cshtml of Views/Shared/_Layout.cshtml):

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

De call

// 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 houdt geen volgorde van de headers van het request bij, dus er wordt er geen meegestuurd. Daarna, in Program.cs, de forwarded headers en het endpoint dat het account aanmaakt:

// 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();

Het IP-adres van de bezoeker

Connection.RemoteIpAddress is het adres van de verbinding, dat van je proxy als je er een hebt. UseForwardedHeaders met XForwardedFor vervangt het door de laatste entry van X-Forwarded-For, die je proxy heeft toegevoegd, maar alleen als de verbinding van een bekende proxy komt: standaard loopback. Zet het adres van je load balancer in KnownProxies (of zijn netwerk bij de bekende netwerken), en roep UseForwardedHeaders aan vóór alles wat het IP-adres leest. Achter Cloudflare lees je in plaats daarvan context.Request.Headers["CF-Connecting-IP"], als je server alleen verkeer van Cloudflare accepteert.

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.

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.

Test het op je eigen machine

Draai dotnet run en open het 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.

  1. Zet KORZEN_API_KEY in de omgeving (of met dotnet user-secrets set KORZEN_API_KEY …) 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 load balancer die ontbreekt in KnownProxies. De middleware negeert dan X-Forwarded-For, en elke bezoeker heeft het privé-IP-adres van de balancer, dat Bot Protection als geen IP-adres behandelt.
  • KnownProxies en de bekende netwerken leegmaken om elke hop te vertrouwen. Het IP-adres wordt dan wat de bezoeker in X-Forwarded-For schreef.
  • new HttpClient() voor elke call. Elke client opent zijn eigen verbinding, met een TLS-handshake binnen je timeout, en de sockets kunnen opraken.
  • /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.