Korzen
Bot Protection

ASP.NET Core

ASP.NET Core に Bot Protection を導入します。タグ、1 つのタイムアウトで 1 つの HttpClient を使う呼び出し用クラス、訪問者の IP を得るための forwarded headers middleware、エンドポイントを説明します。

このガイドは、minimal API を使う ASP.NET Core 8 以降を対象にしています。MVC のコントローラーでも呼び出しは同じです。1 つの HttpClient がプロセス全体で使われて接続を維持し、CancellationTokenSource が接続を含む呼び出し全体を制限します。

タグ

新規登録、ログイン、チェックアウトの各ページが使うレイアウト(Pages/Shared/_Layout.cshtml または Views/Shared/_Layout.cshtml)の <head> にタグを置きます。

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

呼び出し

// 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 はリクエストのヘッダーの順序を保たないため、順序は送られません。次に、Program.cs で forwarded ヘッダーと、アカウントを作成するエンドポイントを設定します。

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

訪問者の IP

Connection.RemoteIpAddress は接続元のアドレスで、プロキシがあればプロキシのアドレスになります。XForwardedFor を指定した UseForwardedHeaders は、これを X-Forwarded-For の最後の値、つまりプロキシが追加した値に置き換えます。ただし、接続が既知のプロキシ(既定ではループバック)から来た場合だけです。ロードバランサーのアドレスを KnownProxies に(またはそのネットワークを既知のネットワークに)追加し、IP を読むどの処理よりも前に UseForwardedHeaders を呼び出してください。Cloudflare の背後にあり、サーバーが Cloudflare からのトラフィックしか受け付けない場合は、代わりに context.Request.Headers["CF-Connecting-IP"] を読んでください。

アクションを適用する

読むのは action だけです。block の場合は、その操作がほかの理由で失敗したときと同じエラーを返し、bot に何も悟らせないようにします。challenge の場合は、すでにある確認手段(CAPTCHA、メールや電話の確認コード)を使います。何もなければそのまま続けます。アカウントは monitor mode で始まり、その間 action は常に allow です。block mode をオンにするまで、訪問者にとっては何も変わりません。

request_id を保存する

すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。

手元のマシンでテストする

dotnet run を実行し、localhost で開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。

  1. 環境変数に(または dotnet user-secrets set KORZEN_API_KEY … で)KORZEN_API_KEY を設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに _kz Cookie が表示されます。
  2. 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
  3. fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは 401 を返し、新規登録はそのまま完了します。

よくある間違い

  • ロードバランサーが KnownProxies にない。middleware が X-Forwarded-For を無視し、すべての訪問者がロードバランサーのプライベート IP になります。Bot Protection はそれを IP なしとして扱います。
  • すべてのホップを信頼するために KnownProxies と既知のネットワークを空にする。IP が訪問者が X-Forwarded-For に書いた値になります。
  • 呼び出しのたびに new HttpClient() する。それぞれが自分の接続を開き、タイムアウトの中で TLS ハンドシェイクが発生し、ソケットが枯渇することもあります。
  • ブラウザーから /v1/decide を呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。
  • タイムアウトがない、またはエラーで新規登録が止まる。200 以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。