このガイドは、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 になりますが、問題ありません。
- 環境変数に(または
dotnet user-secrets set KORZEN_API_KEY …で)KORZEN_API_KEYを設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに_kzCookie が表示されます。 - 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
- 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以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。