Rate Limiting in ASP.NET Core: APIs vor Missbrauch schützen
Am Tag des Go-live ist der Datenverkehr einer API wohlerzogen. Wenige Wochen später schickt eine fehlerhafte Schleife Hunderte Anfragen pro Sekunde, jemand beginnt, Passwörter zu raten, und das Skript eines Partners beschließt, um Mitternacht alle Daten abzuziehen. Rate Limiting ist in solchen Situationen der günstigste Weg, den Rest des Systems zu schützen.
Seit .NET 7 bringt ASP.NET Core dafür eine integrierte Middleware mit, ganz ohne zusätzliches Paket. In diesem Beitrag geht es um die Algorithmen, die Partitionierung pro Benutzer, die Antwort auf abgelehnte Anfragen und die Stellen, an denen die integrierte Lösung nicht ausreicht.
Was begrenzen wir und warum?
Rate Limiting kann drei verschiedene Ziele verfolgen, und welches Sie anpeilen, bestimmt die Einstellungen:
- Schutz: Verhindern, dass ein fehlerhafter oder böswilliger Client alle Ressourcen verbraucht.
- Fairness: Verhindern, dass ein lauter Kunde die Antwortzeiten aller anderen verschlechtert.
- Kosten: Teure Endpunkte (Reporting, KI-Aufrufe, E-Mail-Versand) unter Kontrolle halten.
Ein Limit schließt keine Sicherheitslücke, es erhöht nur die Kosten eines Angriffs. Es ersetzt weder Authentifizierung noch Autorisierung, sondern ergänzt sie.
Vier integrierte Algorithmen
Die Middleware basiert auf System.Threading.RateLimiting und bietet vier Limiter:
- Fixed Window: Eine feste Zahl erlaubter Anfragen pro Zeitfenster (etwa 100 pro Minute). Er ist am einfachsten, aber an den Fenstergrenzen können Lastspitzen auftreten. Ein Client, der 100 Anfragen in der letzten Sekunde eines Fensters und 100 in der ersten Sekunde des nächsten sendet, hat in sehr kurzer Zeit 200 Anfragen gestellt.
- Sliding Window: Teilt das Fenster in Segmente und verschiebt es. Das mildert das Problem der Grenzspitzen, kostet aber etwas mehr Speicher.
- Token Bucket: Der Eimer füllt sich mit fester Rate, jede Anfrage verbraucht ein Token. Kurze Lastspitzen sind erlaubt, die durchschnittliche Rate wird langfristig begrenzt. Für die meisten allgemeinen APIs ist das ein guter Standard.
- Concurrency: Begrenzt, wie viele Anfragen gleichzeitig bearbeitet werden. Er beschränkt die Parallelität, nicht die Rate, und passt zu langsamen, teuren Endpunkten.
Meine Faustregel: Token Bucket für die allgemeine API, Fixed oder Sliding Window für harte Limits wie Passwortversuche, Concurrency für aufwendige Jobs.
Grundlegende Einrichtung
Richtlinien werden mit AddRateLimiter definiert und mit UseRateLimiter aktiviert:
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddTokenBucketLimiter("allgemein", o =>
{
o.TokenLimit = 50;
o.TokensPerPeriod = 10;
o.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
o.QueueLimit = 0;
o.AutoReplenishment = true;
});
options.AddConcurrencyLimiter("aufwendiger-job", o =>
{
o.PermitLimit = 4;
o.QueueLimit = 10;
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/produkte", () => Results.Ok())
.RequireRateLimiting("allgemein");
app.MapPost("/berichte", () => Results.Accepted())
.RequireRateLimiting("aufwendiger-job");
app.Run();
QueueLimit = 0 bedeutet, dass eine Anfrage sofort abgelehnt wird, sobald das Limit überschritten ist. Eine größere Warteschlange lässt Anfragen warten, was für Nutzer angenehmer ist, aber wartende Anfragen binden ebenfalls Ressourcen. Null ist für allgemeine APIs ein sinnvoller Start, eine kleine Warteschlange für Endpunkte, an denen kurze Lastspitzen zu erwarten sind.
Nicht alle in denselben Eimer stecken: Partitionierung
Die obigen Richtlinien führen einen einzigen Zähler für alle Clients. Schöpft ein Benutzer das Limit aus, erhalten alle ein 429. Um das zu vermeiden, partitioniert man Anfragen nach einem Schlüssel:
options.AddPolicy("pro-benutzer", httpContext =>
{
var schluessel =
httpContext.User.Identity?.IsAuthenticated == true
? $"user:{httpContext.User.FindFirst("sub")?.Value}"
: $"ip:{httpContext.Connection.RemoteIpAddress}";
return RateLimitPartition.GetTokenBucketLimiter(schluessel, _ =>
new TokenBucketRateLimiterOptions
{
TokenLimit = 30,
TokensPerPeriod = 5,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 0
});
});
Die Wahl des Schlüssels ist wichtig. Bei authentifizierten Benutzern ist die Benutzer- oder Mandanten-ID der fairste Schlüssel. Bei anonymen Anfragen müssen wir auf die IP-Adresse zurückgreifen, und viele Nutzer hinter einem gemeinsamen Netzwerk (Büro, Mobilfunkanbieter) können sich eine IP teilen. Deshalb muss das Limit für Anonyme unter Umständen großzügiger sein als das für angemeldete Benutzer.
Wird der Schlüssel hinter einem Proxy oder Load Balancer aus RemoteIpAddress gewonnen, sehen ohne korrekt konfigurierte ForwardedHeaders-Middleware alle aus wie die IP des Proxys, und der gesamte Verkehr landet in einem Eimer. Vertrauen Sie umgekehrt dem Header X-Forwarded-For bedingungslos, kann ein Angreifer bei jeder Anfrage einen anderen Wert senden und das Limit umgehen. Vertrauen Sie dem Header nur von Proxys, die Sie kennen.
Auf abgelehnte Anfragen gut antworten
Die Standardantwort 429 hat keinen Body. Dem Client mitzuteilen, wann er es erneut versuchen soll, verbessert die Nutzererfahrung und verringert unnötige Wiederholungslast:
options.OnRejected = async (context, ct) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var wartezeit))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)wartezeit.TotalSeconds).ToString();
}
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(new
{
fehler = "zu_viele_anfragen",
nachricht = "Sie haben das Anfragelimit erreicht. Bitte versuchen Sie es gleich erneut."
}, ct);
};
Der Header Retry-After ist ein Standardvertrag, und gut geschriebene Clients halten sich daran. Nicht jeder Limiter liefert diese Metadaten; der Concurrency-Limiter kann etwa die Wartezeit nicht kennen, deshalb schreiben wir den Header bedingt.
Leisten Sie auch auf der Client-Seite Ihren Beitrag: Ihr eigenes SDK oder Frontend sollte nach einem 429 nicht sofort erneut versuchen, sondern die Dauer aus Retry-After abwarten und exponentielles Backoff mit Jitter anwenden. Sonst nährt das Limit einen Retry-Sturm.
Eigene, strenge Limits für kritische Endpunkte
Nicht alle Endpunkte sind gleich. Login-, Passwort-zurücksetzen- und Registrierungs-Endpunkte brauchen gegen Brute-Force-Versuche deutlich engere Limits:
options.AddPolicy("login", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
$"login:{httpContext.Connection.RemoteIpAddress}",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
Nur auf die IP zu schauen reicht hier nicht; man braucht zusätzlich einen Zähler pro Zielkonto, denn ein Angreifer kann Tausende IPs nutzen. Kontosperrung und Alarmierung werden meist separat in der Anwendungsschicht aufgebaut.
Grenzen der integrierten Lösung
Die integrierten Limiter laufen im Arbeitsspeicher des Prozesses. Das hat einige wichtige Folgen:
- Mehrere Instanzen: Hat die API drei Replikate, führt jedes seinen eigenen Zähler, das effektive Limit vervielfacht sich also mit der Replikatzahl. Verteilt der Load Balancer gleichmäßig, ist das oft eine akzeptable Näherung; für ein exaktes Limit braucht es einen gemeinsamen Speicher wie Redis oder Limitierung auf API-Gateway-Ebene.
- Neustarts: Die Zähler liegen im Speicher und werden beim Deployment zurückgesetzt.
- Reihenfolge der Middleware: Je früher Sie begrenzen, desto weniger Ressourcen verbrauchen schlechte Anfragen. Verwenden Sie jedoch einen benutzerbezogenen Schlüssel, muss
UseRateLimiternach der Authentifizierungs-Middleware stehen, damitHttpContext.Usergefüllt ist.
Eine gute Arbeitsteilung: eine grobe erste Verteidigungslinie am Reverse Proxy oder Gateway, die feineren, fachlich geprägten Limits in der Anwendung.
Testen und beobachten
Nachdem Sie ein Limit gesetzt haben, prüfen Sie sein Verhalten. Senden Sie im Integrationstest Anfragen knapp über dem Limit und kontrollieren Sie, dass die erste Ablehnung genau an der erwarteten Stelle kommt, der Header Retry-After vorhanden ist und sich verschiedene Benutzer nicht gegenseitig beeinflussen.
In Produktion beobachten Sie die Anzahl der 429-Antworten als Metrik. Ein plötzlicher Anstieg deutet entweder auf einen Angriff oder auf ein zu eng gesetztes Limit hin. Sehen echte Nutzer 429, ist das Limit falsch; lesen Sie das zuerst aus den Nutzungsdaten und passen Sie dann den Wert an.
Abschluss
Rate Limiting ist kein magischer Schutzschild, sondern Teil der Kapazitätsplanung. Legen Sie zuerst fest, welche Ressource Sie schützen wollen, wählen Sie den Algorithmus entsprechend, partitionieren Sie pro Benutzer, kommunizieren Sie die Ablehnung mit einer verständlichen Antwort und nehmen Sie die Ungenauigkeit mehrerer Instanzen bewusst in Kauf. Die integrierte Middleware löst den Großteil davon in wenigen Zeilen; der Rest ist die Wahl des richtigen Schlüssels und das Messen des Verhaltens.