RO EN

Rate Limiting în .NET — protejează-ți API-urile de abuz

Rate Limiting în .NET — protejează-ți API-urile de abuz
Doru Bulubașa
25 mai 2026

Un API fără rate limiting este un API care poate fi doborât de oricine — intenționat sau accidental. Un client cu un bug în cod care face 10.000 de request-uri pe minut, un script de scraping, un atac de tip brute-force pe endpoint-ul de autentificare — toate produc același efect: resurse epuizate, latență crescută, serviciu degradat pentru utilizatorii legitimi.

Până în .NET 7, rate limiting necesita librării externe (AspNetCoreRateLimit, etc.) sau soluții custom. Începând cu .NET 8 și rafinat în versiunile ulterioare, framework-ul include un sistem de rate limiting complet, flexibil și bine integrat cu middleware pipeline-ul ASP.NET Core. Toate exemplele din acest articol sunt valide pe .NET 10.


1. Cei patru algoritmi de rate limiting

Înainte de orice cod, merită să înțelegi ce algoritm se potrivește fiecărui scenariu. Alegerea greșită produce fie protecție insuficientă, fie frustrarea utilizatorilor legitimi.

Fixed Window

Permite un număr fix de request-uri într-o fereastră de timp fixă (ex: 100 request-uri / minut). La expirarea ferestrei, contorul se resetează complet.

Avantaj: simplu, predictibil, ușor de comunicat utilizatorilor.
Dezavantaj: vulnerable la burst attack — un client poate face 100 de request-uri în ultimele 5 secunde ale ferestrei și 100 în primele 5 secunde ale ferestrei următoare: 200 de request-uri în 10 secunde, deși limita e 100/minut.

Sliding Window

Similar cu Fixed Window, dar fereastra se deplasează în timp real față de ultimul request. Elimină burst attack-ul de la granița ferestrei.

Avantaj: distribuție mai uniformă a request-urilor.
Dezavantaj: mai costisitor în memorie (trebuie să ții timestamps-urile request-urilor recente).

Token Bucket

Un „coș” cu un număr maxim de token-uri. Fiecare request consumă un token. Token-urile se regenerează cu o rată constantă. Permite burst-uri scurte (dacă coșul e plin), dar limitează rata medie pe termen lung.

Avantaj: cel mai natural pentru utilizare umană reală — un utilizator poate face câteva request-uri rapide, dar nu poate susține o rată ridicată la nesfârșit.
Dezavantaj: mai complex de comunicat utilizatorilor (când se reîncarcă exact token-urile?).

Concurrency Limiter

Limitează numărul de request-uri procesate simultan, nu rata în timp. Nu contorizează request-uri pe secundă, ci câte sunt active în același moment.

Avantaj: protecție directă contra supraîncărcării resurselor (conexiuni DB, memorie, CPU).
Dezavantaj: nu previne abuzul pe termen lung dacă request-urile sunt scurte.


2. Setup de bază

Rate limiting este disponibil în System.Threading.RateLimiting (built-in) și integrat în ASP.NET Core prin middleware.

2.1 Fixed Window — exemplu de start

// Program.cs
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("fixed", limiterOptions =>
    {
        limiterOptions.PermitLimit         = 100;           // max request-uri
        limiterOptions.Window              = TimeSpan.FromMinutes(1);
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit          = 10;            // request-uri la coadă
    });

    // Răspuns 429 custom
    options.OnRejected = async (context, cancellationToken) =>
    {
        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;

        if (context.Lease.TryGetMetadata(
                MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString();
        }

        await context.HttpContext.Response.WriteAsJsonAsync(new
        {
            error   = "Too many requests.",
            message = "Ai depășit limita de request-uri. Încearcă din nou mai târziu."
        }, cancellationToken);
    };
});

// IMPORTANT: UseRateLimiter înainte de UseRouting / MapControllers
app.UseRateLimiter();
app.MapControllers();

2.2 Aplicare pe endpoint

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    [EnableRateLimiting("fixed")]  // aplică politica "fixed"
    public IActionResult GetAll() => Ok();

    [HttpGet("public")]
    [DisableRateLimiting]  // exclude explicit de la orice rate limiting
    public IActionResult GetPublic() => Ok();
}

2.3 Aplicare globală pe toate endpoint-urile

builder.Services.AddRateLimiter(options =>
{
    // Politica globală — se aplică tuturor endpoint-urilor
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
        httpContext => RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 200,
                Window      = TimeSpan.FromMinutes(1)
            }));
});

3. Sliding Window

builder.Services.AddRateLimiter(options =>
{
    options.AddSlidingWindowLimiter("sliding", limiterOptions =>
    {
        limiterOptions.PermitLimit          = 100;
        limiterOptions.Window               = TimeSpan.FromMinutes(1);
        limiterOptions.SegmentsPerWindow    = 6;   // fereastră împărțită în 6 segmente de 10s
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit           = 5;
    });
});

SegmentsPerWindow controlează granularitatea ferestrei glisante. Cu 6 segmente pe un minut, fereastra se actualizează la fiecare 10 secunde — mai fin decât Fixed Window, mai puțin costisitor decât timestamp per request.


4. Token Bucket

builder.Services.AddRateLimiter(options =>
{
    options.AddTokenBucketLimiter("token-bucket", limiterOptions =>
    {
        limiterOptions.TokenLimit            = 50;   // capacitate maximă coș
        limiterOptions.ReplenishmentPeriod   = TimeSpan.FromSeconds(10);
        limiterOptions.TokensPerPeriod       = 10;   // 10 token-uri la fiecare 10s = 1/s
        limiterOptions.AutoReplenishment     = true; // reîncărcare automată în background
        limiterOptions.QueueProcessingOrder  = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit            = 5;
    });
});

Configurarea de mai sus permite burst-uri de până la 50 de request-uri dacă coșul e plin, dar rata medie susținută e de 1 request/secundă. Ideal pentru endpoint-uri care trebuie să fie responsive la interacțiuni umane normale, dar să blocheze scripturi automatizate.


5. Concurrency Limiter

builder.Services.AddRateLimiter(options =>
{
    options.AddConcurrencyLimiter("concurrency", limiterOptions =>
    {
        limiterOptions.PermitLimit          = 20;  // max 20 request-uri simultane
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit           = 10;
    });
});

Potrivit pentru endpoint-uri care fac operații costisitoare: generare rapoarte, procesare imagini, apeluri spre servicii externe lente. Limitezi câte se procesează simultan, nu câte vin pe secundă.


6. Rate limiting per utilizator — PartitionedRateLimiter

Scenariul cel mai comun în producție: limite diferite pentru utilizatori autentificați vs. anonimi, sau limite per plan de abonament.

builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("per-user", httpContext =>
    {
        var user = httpContext.User;

        // Utilizator autentificat — limită mai generoasă
        if (user.Identity?.IsAuthenticated == true)
        {
            var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)
                ?? "authenticated-unknown";

            // Limită per plan de abonament
            var plan  = user.FindFirstValue("subscription_plan") ?? "basic";
            var limit = plan switch
            {
                "pro"      => 1000,
                "business" => 5000,
                _          => 100    // basic
            };

            return RateLimitPartition.GetSlidingWindowLimiter(
                partitionKey: $"user:{userId}",
                factory: _ => new SlidingWindowRateLimiterOptions
                {
                    PermitLimit       = limit,
                    Window            = TimeSpan.FromMinutes(1),
                    SegmentsPerWindow = 6
                });
        }

        // Utilizator anonim — limitat per IP
        var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: $"anon:{ip}",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 20,
                Window      = TimeSpan.FromMinutes(1)
            });
    });

    options.OnRejected = async (context, ct) =>
    {
        context.HttpContext.Response.StatusCode = 429;

        if (context.Lease.TryGetMetadata(
                MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString();
        }

        await context.HttpContext.Response.WriteAsJsonAsync(new
        {
            error = "Rate limit exceeded.",
            retryAfterSeconds = retryAfter.TotalSeconds
        }, ct);
    };
});

Aplicare pe controller

[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("per-user")]
public class ApiController : ControllerBase
{
    // Toate endpoint-urile din controller respectă politica "per-user"
}

7. Rate limiting pe endpoint-uri critice — autentificare și înregistrare

Endpoint-urile de autentificare sunt ținta principală a atacurilor brute-force. Trebuie tratate separat, cu limite mult mai stricte:

builder.Services.AddRateLimiter(options =>
{
    // Endpoint de login — strict, per IP
    options.AddFixedWindowLimiter("auth-strict", limiterOptions =>
    {
        limiterOptions.PermitLimit = 5;                        // 5 încercări
        limiterOptions.Window      = TimeSpan.FromMinutes(15); // per 15 minute
        limiterOptions.QueueLimit  = 0;                        // fără coadă
    });

    // Endpoint de înregistrare — moderat, per IP
    options.AddFixedWindowLimiter("register-moderate", limiterOptions =>
    {
        limiterOptions.PermitLimit = 3;
        limiterOptions.Window      = TimeSpan.FromHours(1);
        limiterOptions.QueueLimit  = 0;
    });

    // Reset parolă — foarte strict
    options.AddFixedWindowLimiter("password-reset", limiterOptions =>
    {
        limiterOptions.PermitLimit = 3;
        limiterOptions.Window      = TimeSpan.FromHours(24);
        limiterOptions.QueueLimit  = 0;
    });
});
[HttpPost("login")]
[EnableRateLimiting("auth-strict")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginDto dto) { ... }

[HttpPost("register")]
[EnableRateLimiting("register-moderate")]
[AllowAnonymous]
public async Task<IActionResult> Register([FromBody] RegisterDto dto) { ... }

[HttpPost("forgot-password")]
[EnableRateLimiting("password-reset")]
[AllowAnonymous]
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordDto dto) { ... }

Atenție la IP spoofing: Dacă aplicația ta e în spatele unui proxy sau load balancer, RemoteIpAddress va fi întotdeauna IP-ul proxy-ului. Trebuie să citești IP-ul real din header-ul X-Forwarded-For sau X-Real-IP, configurat prin ForwardedHeaders middleware.

// Program.cs — citire IP real din spatele proxy-ului
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    // Restricționează la IP-urile proxy-urilor de încredere
    options.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));
});

app.UseForwardedHeaders();
app.UseRateLimiter(); // după ForwardedHeaders

8. Răspunsul 429 și headers standard

Un răspuns 429 bine format permite clienților să se comporte inteligent — să aștepte exact cât trebuie înainte de a reîncerca:

options.OnRejected = async (context, cancellationToken) =>
{
    var response = context.HttpContext.Response;
    response.StatusCode  = StatusCodes.Status429TooManyRequests;
    response.ContentType = "application/json";

    // Retry-After: câte secunde să aștepte clientul
    if (context.Lease.TryGetMetadata(
            MetadataName.RetryAfter, out var retryAfter))
    {
        response.Headers.RetryAfter =
            ((int)retryAfter.TotalSeconds).ToString();
    }

    // Loghează pentru monitorizare
    var logger = context.HttpContext.RequestServices
        .GetRequiredService<ILogger<Program>>();

    var ip   = context.HttpContext.Connection.RemoteIpAddress;
    var path = context.HttpContext.Request.Path;
    var user = context.HttpContext.User.Identity?.Name ?? "anonymous";

    logger.LogWarning(
        "Rate limit exceeded. IP: {IP}, Path: {Path}, User: {User}",
        ip, path, user);

    await response.WriteAsJsonAsync(new
    {
        type    = "https://tools.ietf.org/html/rfc6585#section-4",
        title   = "Too Many Requests",
        status  = 429,
        detail  = "Ai depășit limita de request-uri permise.",
        retryAfterSeconds = retryAfter.TotalSeconds
    }, cancellationToken);
};

9. Rate limiting distribuit — multiple instanțe

Rate limiting-ul built-in este in-process — stochează contoarele în memorie. Dacă ai multiple instanțe ale aplicației (scale-out, Kubernetes), fiecare instanță are propriile contoare — un client poate face de N ori mai multe request-uri decât limita dacă ajunge pe instanțe diferite.

Soluții pentru rate limiting distribuit:

9.1 Redis cu RedisRateLimiting

dotnet add package RedisRateLimiting
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration
        .GetConnectionString("Redis");
});

builder.Services.AddRateLimiter(options =>
{
    var redisConnection = ConnectionMultiplexer.Connect(
        builder.Configuration.GetConnectionString("Redis")!);

    options.AddRedisSlidingWindowLimiter("distributed-sliding",
        limiterOptions =>
        {
            limiterOptions.ConnectionMultiplexerFactory = () => redisConnection;
            limiterOptions.PermitLimit       = 100;
            limiterOptions.Window            = TimeSpan.FromMinutes(1);
        });
});

9.2 Azure API Management

Dacă folosești Azure API Management ca gateway, rate limiting-ul poate fi configurat la nivel de gateway — înainte ca request-ul să ajungă la aplicație, indiferent de câte instanțe backend există:

<!-- Politică APIM: 100 request-uri per minut per subscription key -->
<rate-limit-by-key
    calls="100"
    renewal-period="60"
    counter-key="@(context.Subscription.Id)" />

<!-- SAU per IP -->
<rate-limit-by-key
    calls="20"
    renewal-period="60"
    counter-key="@(context.Request.IpAddress)" />

APIM și rate limiting-ul din aplicație pot coexista — APIM face protecție la nivel macro (per client/plan), aplicația face protecție granulară (per endpoint specific).


10. Rate limiting combinat — politici multiple pe același endpoint

Poți combina mai mulți limiteri pentru protecție în straturi. De exemplu: limită per IP și limită globală simultan:

builder.Services.AddRateLimiter(options =>
{
    // Limita globală — protejează resursele serverului
    options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
        // Strat 1: limită per IP
        PartitionedRateLimiter.Create<HttpContext, string>(
            ctx => RateLimitPartition.GetFixedWindowLimiter(
                partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
                factory: _ => new FixedWindowRateLimiterOptions
                {
                    PermitLimit = 200,
                    Window      = TimeSpan.FromMinutes(1)
                })),

        // Strat 2: limită globală totală (toate IP-urile)
        PartitionedRateLimiter.Create<HttpContext, string>(
            _ => RateLimitPartition.GetConcurrencyLimiter(
                partitionKey: "global",
                factory: _ => new ConcurrencyLimiterOptions
                {
                    PermitLimit = 500,
                    QueueLimit  = 0
                }))
    );
});

11. Testare rate limiting

[TestFixture]
public class RateLimitingTests
{
    private WebApplicationFactory<Program> _factory = default!;

    [SetUp]
    public void Setup()
    {
        _factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureServices(services =>
                {
                    // Override cu limite mici pentru teste
                    services.AddRateLimiter(options =>
                    {
                        options.AddFixedWindowLimiter("fixed",
                            o =>
                            {
                                o.PermitLimit = 3;
                                o.Window      = TimeSpan.FromSeconds(10);
                                o.QueueLimit  = 0;
                            });
                        options.OnRejected = async (ctx, ct) =>
                        {
                            ctx.HttpContext.Response.StatusCode = 429;
                            await Task.CompletedTask;
                        };
                    });
                });
            });
    }

    [Test]
    public async Task RateLimit_ExceedingLimit_Returns429()
    {
        var client = _factory.CreateClient();

        // Primele 3 request-uri trebuie să treacă
        for (int i = 0; i < 3; i++)
        {
            var response = await client.GetAsync("/api/products");
            Assert.That(response.StatusCode,
                Is.Not.EqualTo(HttpStatusCode.TooManyRequests));
        }

        // Al 4-lea trebuie respins
        var rejected = await client.GetAsync("/api/products");
        Assert.That(rejected.StatusCode,
            Is.EqualTo(HttpStatusCode.TooManyRequests));
    }

    [TearDown]
    public void TearDown() => _factory.Dispose();
}

12. Probleme comune și soluțiile lor

Problemă Cauza probabilă Soluție
Rate limiting nu funcționează app.UseRateLimiter() lipsește sau e în ordinea greșită Adaugă înainte de app.MapControllers() / app.UseRouting()
Toți utilizatorii primesc 429, nu doar abuzatorii IP-ul proxy-ului e folosit ca partition key Configurează ForwardedHeaders middleware înainte de rate limiter
Limite diferite între instanțe (scale-out) Contoare in-process, nu distribuite Migrează la Redis cu RedisRateLimiting sau folosește APIM
Clientul nu știe când să reîncerce Retry-After header lipsește din răspunsul 429 Adaugă MetadataName.RetryAfter în OnRejected
Endpoint-uri interne blocat de rate limiting Health checks, metrics endpoints limitate Adaugă [DisableRateLimiting] pe health check endpoints sau exclude prin path

13. Checklist de producție

  • app.UseRateLimiter() înregistrat în ordinea corectă în pipeline
  • ForwardedHeaders middleware configurat dacă aplicația e în spatele unui proxy
  • ✅ Endpoint-uri de autentificare (/login, /register, /forgot-password) cu limite stricte separate
  • ✅ Răspuns 429 cu header Retry-After și body JSON structurat
  • ✅ Logging în OnRejected cu IP, path și utilizator pentru monitorizare
  • ✅ Limite diferite pentru utilizatori autentificați vs. anonimi
  • ✅ Limite per plan de abonament dacă aplicația are mai multe tiere
  • ✅ Redis sau APIM pentru deployment cu multiple instanțe
  • [DisableRateLimiting] pe health check și metrics endpoints
  • ✅ Teste de integrare care verifică comportamentul la depășirea limitei
  • ✅ Alertă în Application Insights / Azure Monitor când rata de 429 depășește un prag

Concluzie

Rate limiting-ul built-in din .NET 10 elimină nevoia de librării externe pentru cele mai comune scenarii. Cei patru algoritmi acoperă cazuri diferite: Fixed Window pentru simplitate, Sliding Window pentru distribuție uniformă, Token Bucket pentru comportament uman natural, Concurrency Limiter pentru protecție la nivel de resurse.

Cea mai importantă decizie de arhitectură: in-process sau distribuit. Dacă rulezi o singură instanță, built-in e suficient. Dacă scalezi orizontal, ai nevoie de Redis sau un gateway (APIM, Nginx) care centralizează contoarele.

Seria de securitate se încheie cu ultimul articol: Audit Logging și Security Events — cum înregistrezi ce se întâmplă în sistem astfel încât să poți detecta atacuri, să investighezi incidente și să demonstrezi conformitate.