Korzen
Bot Protection

ASP.NET Core

Bot Protection in ASP.NET Core: der Tag, eine Klasse für den Aufruf mit einem HttpClient unter einem Timeout, die Forwarded-Headers-Middleware für die IP des Besuchers und der Endpoint.

Diese Anleitung gilt für ASP.NET Core 8 und neuer, mit Minimal APIs; in einem MVC-Controller ist der Aufruf derselbe. Ein HttpClient dient dem Prozess und hält die Verbindung offen, und eine CancellationTokenSource begrenzt den ganzen Aufruf, die Verbindung eingeschlossen.

Der Tag

Setzen Sie den Tag in den <head> des Layouts, das Ihre Registrierungs-, Anmelde- und Checkout-Seiten verwenden (Pages/Shared/_Layout.cshtml oder Views/Shared/_Layout.cshtml):

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

Der Aufruf

// 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 hält keine Reihenfolge der Header der Anfrage, also wird keine gesendet. Dann, in Program.cs, die Forwarded-Header und der Endpoint, der das Konto anlegt:

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

Die IP des Besuchers

Connection.RemoteIpAddress ist die Adresse der Verbindung, die Ihres Proxys, wenn Sie einen haben. UseForwardedHeaders mit XForwardedFor ersetzt sie durch den letzten Eintrag von X-Forwarded-For, den Ihr Proxy angehängt hat, aber nur, wenn die Verbindung von einem bekannten Proxy kommt: standardmäßig Loopback. Tragen Sie die Adresse Ihres Load Balancers in KnownProxies ein (oder sein Netz in die bekannten Netze), und rufen Sie UseForwardedHeaders vor allem auf, was die IP liest. Hinter Cloudflare lesen Sie stattdessen context.Request.Headers["CF-Connecting-IP"], wenn Ihr Server nur Traffic von Cloudflare annimmt.

Die Aktion anwenden

Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.

Die request_id aufbewahren

Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat.

Auf Ihrem Rechner testen

Starten Sie dotnet run und öffnen Sie es auf localhost. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung.

  1. Setzen Sie KORZEN_API_KEY in der Umgebung (oder mit dotnet user-secrets set KORZEN_API_KEY …) und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das _kz-Cookie.
  2. Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
  3. Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet 401, und die Registrierung geht durch.

Häufige Fehler

  • Ein Load Balancer, der in KnownProxies fehlt. Die Middleware ignoriert dann X-Forwarded-For, und jeder Besucher hat die private IP des Balancers, die Bot Protection wie keine IP behandelt.
  • KnownProxies und die bekannten Netze leeren, um jedem Hop zu vertrauen. Die IP wird zu dem, was der Besucher in X-Forwarded-For geschrieben hat.
  • new HttpClient() für jeden Aufruf. Jeder öffnet seine eigene Verbindung, mit einem TLS-Handshake innerhalb Ihres Timeouts, und die Sockets können ausgehen.
  • /v1/decide aus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.
  • Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als 200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.