ASP.NET Core のレート制限ミドルウェア

作成者: Arvin KahbaziMaarten BalliauwRick Anderson

Microsoft.AspNetCore.RateLimiting ミドルウェアは、レート制限ミドルウェアです。 アプリでレート制限ポリシーを構成し、エンドポイントにポリシーをアタッチします。 レート制限を使用するアプリは、デプロイする前に慎重にロード テストし、確認する必要があります。 詳細については、この記事の「レート制限を使用したエンドポイントのテスト」を参照してください。

レート制限の概要については、「レート制限ミドルウェア」を参照してください。

レート リミッターアルゴリズム

RateLimiterOptionsExtensions クラスには、レート制限のための次の拡張メソッドが用意されています:

固定時間枠リミッター

AddFixedWindowLimiter メソッドは、固定時間枠を使用して要求を制限します。 時間枠の有効期限が切れると、新しい時間枠が開始され、要求の制限がリセットされます。

次のコードがあるとします。

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: "fixed", options =>
    {
        options.PermitLimit = 4;
        options.Window = TimeSpan.FromSeconds(12);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = 2;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}"))
                           .RequireRateLimiting("fixed");

app.Run();

上記のコードでは次の操作が行われます。

  • AddRateLimiter を呼び出して、レート制限サービスをサービス コレクションに追加します。
  • AddFixedWindowLimiter を呼び出して、ポリシー名 "fixed" と セットを持つ固定時間枠リミッターを作成します:
  • PermitLimit を 4 に、時間 Window を 12 に設定します。 各 12 秒の時間枠あたり最大 4 つの要求が許可されます。
  • QueueProcessingOrder から OldestFirst
  • QueueLimit を 2 に指定します。
  • UseRateLimiter を呼び出してレート制限を有効にします。

アプリでは 構成を使用してリミッター オプションを設定する必要があります。 次のコードでは、MyRateLimitOptions を使用して構成用に上記のコードを更新します:

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Fixed Window Limiter {GetTicks()}"))
                           .RequireRateLimiting(fixedPolicy);

app.Run();

レート制限エンドポイント固有の API を使う場合は、UseRouting の後に UseRateLimiter を呼び出す必要があります。 たとえば、[EnableRateLimiting] 属性を使う場合は、UseRouting の後に UseRateLimiter を呼び出す必要があります。 グローバル リミッターのみを呼び出す場合は、UseRouting の前に UseRateLimiter を呼び出すことができます。

スライド式時間枠のリミッター

スライド式時間枠のアルゴリズム:

  • 固定時間枠リミッターに似ていますが、時間枠ごとにセグメントが追加されます。 時間枠は、セグメント間隔ごとに 1 つのセグメントをスライドします。 セグメント間隔は (時間枠の時間)/(1 つの時間枠あたりのセグメント数) です。
  • 1 つの時間枠の要求件数を permitLimit 件の要求に制限します。
  • 各時間枠は、時間枠あたり n セグメントに分割されます。
  • 1 つ前の時間枠で期限切れの時間セグメントから取得した要求 (現在のセグメントより n セグメント前) は、現在のセグメントに追加されます。 1 つ前の時間枠で最も有効期限が切れている時間セグメントを、有効期限切れセグメントと呼びます。

30 秒の時間枠、1 時間枠あたり 3 つのセグメント、および 100 件の要求の制限を持つスライド式時間枠リミッターを示す次の表について考えてみましょう。

  • 上部の行と最初の列には、時間セグメントが表示されます。
  • 2 行目には、使用可能な残りの要求が表示されます。 残りの要求は、使用可能な要求から処理された要求とリサイクルされた要求を差し引いた値として計算されます。
  • 要求は、毎回斜めの青い線に沿って移動します。
  • 時刻 30 から、期限切れの時間セグメントから取得された要求は、赤い行に示すように、要求の制限に戻されます。

Table showing requests, limits, and recycled slots

次の表は、前のグラフのデータを別の形式で示しています。 [使用可能] 列には、前のセグメントから使用可能な要求が表示されます (前の行の引き継ぎ)。 前のセグメントがないため、最初の行には使用可能な 100 の要求が表示されます。

時刻 利用可能 取得 期限切れからリサイクル済み 引き継ぎ
0 100 20 0 80
10 80 30 0 50
20 50 40 0 10
30 10 30 20 0
40 0 10 30 20
50 20 10 40 50
60 50 35 30 45

次のコードでは、スライド式時間枠リミッターを使用しています:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Sliding Window Limiter {GetTicks()}"))
                           .RequireRateLimiting(slidingPolicy);

app.Run();

トークン バケット リミッター

トークン バケット リミッターはスライド式時間枠リミッターに似ていますが、期限切れのセグメントから取得した要求を追加するのではなく、補充期間ごとに固定数のトークンが追加されます。 各セグメントに追加されたトークンは、トークン バケットの制限を超える数に使用可能なトークンを増やすことはできません。 次の表は、100 トークンの制限と 10 秒の補充期間を持つトークン バケットリミッターを示しています。

時刻 利用可能 取得 追加 引き継ぎ
0 100 20 0 80
10 80 10 20 90
20 90 5 15 100
30 100 30 20 90
40 90 6 16 100
50 100 40 20 80
60 80 50 20 50

次のコードでは、トークン バケット リミッターを使用しています:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var tokenPolicy = "token";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddTokenBucketLimiter(policyName: tokenPolicy, options =>
    {
        options.TokenLimit = myOptions.TokenLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
        options.ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod);
        options.TokensPerPeriod = myOptions.TokensPerPeriod;
        options.AutoReplenishment = myOptions.AutoReplenishment;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Token Limiter {GetTicks()}"))
                           .RequireRateLimiting(tokenPolicy);

app.Run();

AutoReplenishmenttrue に設定されている場合、内部タイマーはトークンを ReplenishmentPeriod ごとに補充します。false に設定すると、アプリはリミッターで TryReplenish を呼び出す必要があります。

コンカレンシー リミッター

コンカレンシー リミッターにより、同時実行要求の数は制限されます。 要求が 1 回行われると、コンカレンシーの制限が 1 つ減ります。 要求が完了すると、制限は 1 つ増やされます。 指定した期間の要求の合計数を制限する他の要求リミッターとは異なり、コンカレンシー リミッターは同時要求の数のみを制限し、一定期間の要求数の上限は制限しません。

次のコードでは、コンカレンシー リミッターを使用します:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var concurrencyPolicy = "Concurrency";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddConcurrencyLimiter(policyName: concurrencyPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", async () =>
{
    await Task.Delay(500);
    return Results.Ok($"Concurrency Limiter {GetTicks()}");
                              
}).RequireRateLimiting(concurrencyPolicy);

app.Run();

チェーン リミッタを作成する

CreateChained API では、1 つの PartitionedRateLimiter に結合された複数の PartitionedRateLimiter を渡すことができます。 結合されたリミッターは、すべての入力リミッターを順番に実行します。

次のコードでは CreateChained を使用します。

using System.Globalization;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(_ =>
{
    _.OnRejected = (context, _) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int) retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }

        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");

        return new ValueTask();
    };
    _.GlobalLimiter = PartitionedRateLimiter.CreateChained(
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            var userAgent = httpContext.Request.Headers.UserAgent.ToString();

            return RateLimitPartition.GetFixedWindowLimiter
            (userAgent, _ =>
                new FixedWindowRateLimiterOptions
                {
                    AutoReplenishment = true,
                    PermitLimit = 4,
                    Window = TimeSpan.FromSeconds(2)
                });
        }),
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            var userAgent = httpContext.Request.Headers.UserAgent.ToString();
            
            return RateLimitPartition.GetFixedWindowLimiter
            (userAgent, _ =>
                new FixedWindowRateLimiterOptions
                {
                    AutoReplenishment = true,
                    PermitLimit = 20,    
                    Window = TimeSpan.FromSeconds(30)
                });
        }));
});

var app = builder.Build();
app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}"));

app.Run();

詳細については、「CreateChained のソース コード」を参照してください

EnableRateLimiting 属性および DisableRateLimiting 属性

[EnableRateLimiting] 属性と [DisableRateLimiting] 属性は、コントローラー、アクション メソッド、または Razor Page に適用できます。 Razor Pages の場合、ページ ハンドラーではなく Page Razor に属性を適用する必要があります。 たとえば、[EnableRateLimiting] は、OnGetOnPost、または他のページ ハンドラーには適用できません。

[DisableRateLimiting] 属性は、適用された名前付きレートリミッタまたはグローバルリミッタに関係なく、Controller、action メソッド、または Razor Page へのレート制限を無効にします。 たとえば、すべてのコントローラー エンドポイントに fixedPolicy レート制限を適用するために RequireRateLimiting を呼び出す次のコードを考えてみましょう:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.SlidingPermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();
app.UseRateLimiter();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.MapRazorPages().RequireRateLimiting(slidingPolicy);
app.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy);

app.Run();

次のコードでは、[DisableRateLimiting] でレート制限を無効にし、Program.cs で呼び出された Home2Controllerapp.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy) に適用された [EnableRateLimiting("fixed")] をオーバーライドしています:

[EnableRateLimiting("fixed")]
public class Home2Controller : Controller
{
    private readonly ILogger<Home2Controller> _logger;

    public Home2Controller(ILogger<Home2Controller> logger)
    {
        _logger = logger;
    }

    public ActionResult Index()
    {
        return View();
    }

    [EnableRateLimiting("sliding")]
    public ActionResult Privacy()
    {
        return View();
    }

    [DisableRateLimiting]
    public ActionResult NoLimit()
    {
        return View();
    }

    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

前のコードでは、Program.csapp.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy)を呼び出したため、[EnableRateLimiting("sliding")]Privacy アクション メソッドに適用されません

MapRazorPages または MapDefaultControllerRouteRequireRateLimitingを呼び出さない次のコードを考えてみましょう:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.SlidingPermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.MapRazorPages();
app.MapDefaultControllerRoute();  // RequireRateLimiting not called

app.Run();

次のようなコントローラーがあるとします。

[EnableRateLimiting("fixed")]
public class Home2Controller : Controller
{
    private readonly ILogger<Home2Controller> _logger;

    public Home2Controller(ILogger<Home2Controller> logger)
    {
        _logger = logger;
    }

    public ActionResult Index()
    {
        return View();
    }

    [EnableRateLimiting("sliding")]
    public ActionResult Privacy()
    {
        return View();
    }

    [DisableRateLimiting]
    public ActionResult NoLimit()
    {
        return View();
    }

    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

上記の コントローラーのでは次のようになります:

  • "fixed" ポリシー レート リミッターは、EnableRateLimiting 属性と DisableRateLimiting 属性を持たないすべてのアクション メソッドに適用されます。
  • "sliding" ポリシー レート リミッターが Privacy アクションに適用されます。
  • NoLimit アクション メソッドでレート制限が無効になっています。

RazorPages への属性の適用

Razor Pages の場合、ページ ハンドラーではなく Page Razor に属性を適用する必要があります。 たとえば、[EnableRateLimiting] は、OnGetOnPost、または他のページ ハンドラーには適用できません。

DisableRateLimiting 属性は Razor Page のレート制限を無効にします。 EnableRateLimiting は、MapRazorPages().RequireRateLimiting(Policy) が呼び出されていない場合にのみ Razor Page に適用されます。

リミッター アルゴリズムの比較

固定リミッター、スライド式リミッター、トークン リミッターはすべて、一定期間の要求の最大数を制限します。 コンカレンシー リミッターは、同時要求の数のみを制限し、一定期間の要求数を制限しません。 リミッターを選択するときは、エンドポイントのコストを考慮する必要があります。 エンドポイントのコストには、時間、データ アクセス、CPU、I/O など、使用されるリソースが含まれます。

レート リミッターのサンプル

次のサンプルは実稼働コードを対象としていませんが、リミッターの使用方法の例です。

OnRejectedRetryAfter および GlobalLimiter のあるリミッター

次のサンプルでは、次のことが行われます。

  • 要求が指定された制限を超えたときに呼び出される RateLimiterOptions.OnRejected コールバックを作成します。 retryAfter は、TokenBucketRateLimiterFixedWindowLimiterSlidingWindowLimiter と共に使用できます。これらのアルゴリズムは、許可が追加されるタイミングを推定できるためです。 ConcurrencyLimiter には、許可がいつ利用可能になるのか計算する方法はありません。

  • 次のリミッタを追加します:

    • IRateLimiterPolicy<TPartitionKey> インターフェイスを実装する SampleRateLimiterPolicySampleRateLimiterPolicy クラスについては、この記事の後半で説明します。
    • SlidingWindowLimiter は:
      • 認証された各ユーザーのパーティションを使用します。
      • すべての匿名ユーザーに対して 1 つの共有パーティション。
    • すべての要求に適用される GlobalLimiter。 グローバル リミッターが最初に実行され、次にエンドポイント固有のリミッターが実行されます (存在する場合)。 GlobalLimiter は、IPAddress ごとにパーティションを作成します。
// Preceding code removed for brevity.
using System.Globalization;
using System.Net;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using WebRateLimitAuth;
using WebRateLimitAuth.Data;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ??
    throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

var userPolicyName = "user";
var helloPolicy = "hello";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.OnRejected = (context, cancellationToken) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int) retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }

        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        context.HttpContext.RequestServices.GetService<ILoggerFactory>()?
            .CreateLogger("Microsoft.AspNetCore.RateLimitingMiddleware")
            .LogWarning("OnRejected: {GetUserEndPoint}", GetUserEndPoint(context.HttpContext));

        return new ValueTask();
    };

    limiterOptions.AddPolicy<string, SampleRateLimiterPolicy>(helloPolicy);
    limiterOptions.AddPolicy(userPolicyName, context =>
    {
        var username = "anonymous user";
        if (context.User.Identity?.IsAuthenticated is true)
        {
            username = context.User.ToString()!;
        }

        return RateLimitPartition.GetSlidingWindowLimiter(username,
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = myOptions.PermitLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                Window = TimeSpan.FromSeconds(myOptions.Window),
                SegmentsPerWindow = myOptions.SegmentsPerWindow
            });

    });
    
    limiterOptions.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, IPAddress>(context =>
    {
        IPAddress? remoteIpAddress = context.Connection.RemoteIpAddress;

        if (!IPAddress.IsLoopback(remoteIpAddress!))
        {
            return RateLimitPartition.GetTokenBucketLimiter
            (remoteIpAddress!, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetNoLimiter(IPAddress.Loopback);
    });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseRateLimiter();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages().RequireRateLimiting(userPolicyName);
app.MapDefaultControllerRoute();

static string GetUserEndPoint(HttpContext context) =>
   $"User {context.User.Identity?.Name ?? "Anonymous"} endpoint:{context.Request.Path}"
   + $" {context.Connection.RemoteIpAddress}";
static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/a", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
    .RequireRateLimiting(userPolicyName);

app.MapGet("/b", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
    .RequireRateLimiting(helloPolicy);

app.MapGet("/c", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}");

app.Run();

警告

クライアント IP アドレスにパーティションを作成すると、IP ソース アドレス スプーフィングを使用するサービス拒否攻撃に対してアプリが脆弱になります。 詳細については、『BCP 38 RFC 2827 ネットワーク イングレス フィルタリング: IP ソース アドレス スプーフィングを使用するサービス拒否攻撃の無効化』を参照してください。

完全な Program.cs ファイルについては、サンプル リポジトリを参照してください。

SampleRateLimiterPolicy クラス

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using WebRateLimitAuth.Models;

namespace WebRateLimitAuth;

public class SampleRateLimiterPolicy : IRateLimiterPolicy<string>
{
    private Func<OnRejectedContext, CancellationToken, ValueTask>? _onRejected;
    private readonly MyRateLimitOptions _options;

    public SampleRateLimiterPolicy(ILogger<SampleRateLimiterPolicy> logger,
                                   IOptions<MyRateLimitOptions> options)
    {
        _onRejected = (ctx, token) =>
        {
            ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
            logger.LogWarning($"Request rejected by {nameof(SampleRateLimiterPolicy)}");
            return ValueTask.CompletedTask;
        };
        _options = options.Value;
    }

    public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => _onRejected;

    public RateLimitPartition<string> GetPartition(HttpContext httpContext)
    {
        return RateLimitPartition.GetSlidingWindowLimiter(string.Empty,
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = _options.PermitLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = _options.QueueLimit,
                Window = TimeSpan.FromSeconds(_options.Window),
                SegmentsPerWindow = _options.SegmentsPerWindow
            });
    }
}

前のコードでは、OnRejectedOnRejectedContext を使用して応答状態を「429 応答が多過ぎます」に設定します。 既定の拒否状態は 「503 サービス利用不」です。

承認付きリミッター

次の例では、JSON Web トークン (JWT) を使用し、JWT アクセス トークンを使用してパーティションを作成します。 運用アプリでは、JWT は通常、セキュリティ トークン サービス (STS) として機能するサーバーによって提供されます。 ローカル開発では、dotnet user-jwts コマンドライン ツールを使用して、アプリ固有のローカル JWT を作成および管理できます。

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Primitives;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer").AddJwtBearer();

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var jwtPolicyName = "jwt";

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    limiterOptions.AddPolicy(policyName: jwtPolicyName, partitioner: httpContext =>
    {
        var accessToken = httpContext.Features.Get<IAuthenticateResultFeature>()?
                              .AuthenticateResult?.Properties?.GetTokenValue("access_token")?.ToString()
                          ?? string.Empty;

        if (!StringValues.IsNullOrEmpty(accessToken))
        {
            return RateLimitPartition.GetTokenBucketLimiter(accessToken, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetTokenBucketLimiter("Anon", _ =>
            new TokenBucketRateLimiterOptions
            {
                TokenLimit = myOptions.TokenLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                TokensPerPeriod = myOptions.TokensPerPeriod,
                AutoReplenishment = true
            });
    });
});

var app = builder.Build();

app.UseAuthorization();
app.UseRateLimiter();

app.MapGet("/", () => "Hello, World!");

app.MapGet("/jwt", (HttpContext context) => $"Hello {GetUserEndPointMethod(context)}")
    .RequireRateLimiting(jwtPolicyName)
    .RequireAuthorization();

app.MapPost("/post", (HttpContext context) => $"Hello {GetUserEndPointMethod(context)}")
    .RequireRateLimiting(jwtPolicyName)
    .RequireAuthorization();

app.Run();

static string GetUserEndPointMethod(HttpContext context) =>
    $"Hello {context.User.Identity?.Name ?? "Anonymous"} " +
    $"Endpoint:{context.Request.Path} Method: {context.Request.Method}";

ConcurrencyLimiterTokenBucketRateLimiter および承認付きリミッター

次のサンプルでは、次のことが行われます。

  • Razor Pages で使用される "get" という名前のポリシーを持つ ConcurrencyLimiter を追加します。
  • 承認された各ユーザーのパーティションと、すべての匿名ユーザーのパーティションを含む TokenBucketRateLimiter を追加します。
  • RateLimiterOptions.RejectionStatusCode を「429 要求が多すぎます」に設定します。
var getPolicyName = "get";
var postPolicyName = "post";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddConcurrencyLimiter(policyName: getPolicyName, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    })
    .AddPolicy(policyName: postPolicyName, partitioner: httpContext =>
    {
        string userName = httpContext.User.Identity?.Name ?? string.Empty;

        if (!StringValues.IsNullOrEmpty(userName))
        {
            return RateLimitPartition.GetTokenBucketLimiter(userName, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetTokenBucketLimiter("Anon", _ =>
            new TokenBucketRateLimiterOptions
            {
                TokenLimit = myOptions.TokenLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                TokensPerPeriod = myOptions.TokensPerPeriod,
                AutoReplenishment = true
            });
    }));

完全な Program.cs ファイルについては、サンプル リポジトリを参照してください。

レート制限を使用したエンドポイントのテスト

レート制限を使用して運用環境にアプリをデプロイする前に、アプリをストレス テストして、使用されるレート制限とオプションを検証します。 たとえば、BlazeMeterApache JMeter HTTP(S) Test Script Recorder などのツールを使用して JMeter スクリプトを作成し、スクリプトを Azure Load Testing に読み込みます。

ユーザー入力を使用してパーティションを作成すると、アプリは サービス拒否 (DoS) 攻撃に対して脆弱になります。 たとえば、クライアント IP アドレスにパーティションを作成すると、アプリは IP ソース アドレス スプーフィングを使用するサービス拒否攻撃に対して脆弱になります。 詳細については、『BCP 38 RFC 2827 ネットワーク イングレス フィルタリング: IP ソース アドレス スプーフィングを使用するサービス拒否攻撃の無効化』を参照してください。

その他のリソース