Korzen
Bot Protection

ASP.NET Core

Bot Protection dans ASP.NET Core : le tag, une classe pour l'appel avec un seul HttpClient sous un seul timeout, le middleware des forwarded headers pour l'IP du visiteur, et l'endpoint.

Ce guide vaut pour ASP.NET Core 8 et plus récent, avec les minimal APIs ; dans un contrôleur MVC, l'appel est le même. Un seul HttpClient sert le processus et garde la connexion ouverte, et un CancellationTokenSource borne l'appel entier, connexion comprise.

Le tag

Placez le tag dans le <head> du layout qu'utilisent vos pages d'inscription, de connexion et de checkout (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>

L'appel

// 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 ne garde aucun ordre des headers de la requête, donc aucun n'est envoyé. Puis, dans Program.cs, les forwarded headers et l'endpoint qui crée le compte :

// 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 du visiteur

Connection.RemoteIpAddress est l'adresse de la connexion, celle de votre proxy si vous en avez un. UseForwardedHeaders avec XForwardedFor la remplace par la dernière entrée de X-Forwarded-For, celle que votre proxy a ajoutée, mais seulement quand la connexion vient d'un proxy connu : le loopback par défaut. Ajoutez l'adresse de votre load balancer à KnownProxies (ou son réseau aux réseaux connus), et appelez UseForwardedHeaders avant tout ce qui lit l'IP. Derrière Cloudflare, lisez plutôt context.Request.Headers["CF-Connecting-IP"], si votre serveur ne reçoit du trafic que de Cloudflare.

Appliquer l'action

Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.

Garder le request_id

Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.

Tester sur votre machine

Lancez dotnet run et ouvrez-le sur localhost. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien.

  1. Définissez KORZEN_API_KEY dans l'environnement (ou avec dotnet user-secrets set KORZEN_API_KEY …) et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie _kz apparaît après quelques secondes.
  2. Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
  3. Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond 401, et l'inscription passe.

Erreurs fréquentes

  • Un load balancer absent de KnownProxies. Le middleware ignore alors X-Forwarded-For, et chaque visiteur a l'IP privée du load balancer, que Bot Protection traite comme une absence d'IP.
  • Vider KnownProxies et les réseaux connus pour faire confiance à chaque saut. L'IP devient ce que le visiteur a écrit dans X-Forwarded-For.
  • new HttpClient() pour chaque appel. Chacun ouvre sa propre connexion, avec une poignée de main TLS dans votre timeout, et peut épuiser les sockets.
  • Appeler /v1/decide depuis le navigateur. L'API key atteindrait chaque visiteur ; l'appel a sa place sur votre serveur.
  • Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que 200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.