IP-blokkeren is dood: wat de DDoS op Read the Docs .NET-teams leert

Read the Docs kreeg tien dagen lang 5,5 miljoen requests per minuut. De aanval liep zo langs elke IP-blokkade. Zo bouw je gelaagde abuse-verdediging in ASP.NET Core.

Jean-Pierre Broeders

Freelance .NET Developer

10 september 202612 min. leestijd
IP-blokkeren is dood: wat de DDoS op Read the Docs .NET-teams leert

Read the Docs schreef deze week een post-mortem over de zwaarste DDoS die ze ooit hebben opgevangen. Tien dagen lang. Op de piek 5,5 miljoen requests per minuut, ongeveer honderd keer hun normale verkeer. Het raakte hun documentatiesites, hun commerciele hosting en de dashboards van auteurs tegelijk. Het verslag belandde op de voorpagina van Hacker News, en de reacties splitsten zoals altijd. De helft riep "zet er gewoon Cloudflare voor." De andere helft had ooit iets in productie gedraaid en wist beter.

Die andere helft heeft gelijk, en de reden is de ene zin die het hele verslag de moeite waard maakt: blokkeren op IP-adres werkt niet meer. De aanval kwam van miljoenen unieke IP-adressen over honderden netwerken. Elke request had willekeurige headers en willekeurige TLS-parameters. Er was geen lijst om te bannen, geen handtekening om op te matchen. Begint jouw verdediging nog steeds bij "we blokkeren de foute IP's," dan verdedig je tegen 2015.

Ik loop even langs wat het echt tegenhield, en vertaal het daarna naar ASP.NET Core. Want de meeste onderdelen heb je al, en je hebt ze waarschijnlijk verkeerd staan.

Waarom de IP-blokkade faalde

Bedenk wat een IP-gebaseerde rate limit aanneemt. Hij gaat ervan uit dat een aanvaller een handjevol adressen heeft en daar veel verkeer overheen stuurt. Je telt requests per adres, en zodra er een over de grens gaat gooi je hem eruit. Dat model klopte lang.

Nu klopt het niet meer. Residentiele proxynetwerken en botnets geven een aanvaller voor bijna elke request een vers IP. Read the Docs zag verkeer dat zo dun verspreid was over zoveel adressen dat geen enkel IP ooit raar oogde. Elk adres deed een paar requests en verdween. Je teller per IP slaat nooit aan, en ondertussen is het totaal honderd keer je baseline.

De aanvallers hadden ook hun huiswerk gedaan over caching. Ze gingen bewust achter cache-miss-URL's aan: 404's, 302-redirects, alles wat naar de origin moet in plaats van vanaf de edge geserveerd te worden. Dat is de slimme zet. Een gecachte pagina kost de aanvaller niks en jou niks. Een cache miss kost je een database-hit, een render, een stuk CPU. Dus zochten ze de plekken die missen.

En ze draaiden wat het verslag een jojopatroon noemt. Opvoeren tot er iets begint te limiteren, de drempel noteren, terugzakken, wachten, en er net onder terugkomen. Ze aftastten je limieten zoals je een loginformulier zou aftasten. Dit is het deel dat mensen onderschatten. Een DDoS in 2026 is geen domme stortvloed. Hij past zich aan.

De drie dingen die wel werkten

Kook het verslag in en drie ideeen droegen de verdediging.

Ten eerste: cache agressief, al is het maar voor een paar minuten. Hun zin is bot en klopt: al een cachevenster van een paar minuten zorgt dat een resource je infrastructuur niet meer kan aanvallen. Als een URL vanaf de edge geserveerd kan worden, mag de aanvaller er de hele dag op beuken zonder je origin te raken. Het hele spel is het cache-miss-oppervlak zo dicht mogelijk naar nul brengen.

Ten tweede: rate limit op hoe een request eruitziet, niet op waar hij vandaan komt. Ze gebruikten bot-scores en fingerprints: TLS-afwijkingen, de volgorde van headers, de vorm van de request. Dat is het antwoord op het miljoenen-IP's-probleem. Je stopt met adressen tellen en begint gedrag te scoren.

Ten derde: laat echte gebruikers altijd een uitweg. Hun zwaarste reactie was een JavaScript-challenge, geen harde blokkade. Los hem een keer op en je bent een dag lang vertrouwd. Een echte browser komt er zonder het te merken doorheen. Een domme bot kan de JS niet draaien en valt om. Zo filter je zonder een muur van 403's voor je echte klanten.

Nu het .NET-deel.

Haal eerst het echte client-IP op, anders liegt de rest

Dit is de fout die ik het vaakst zie, en hij sloopt stilletjes al je andere maatregelen. Zit je achter Cloudflare of een andere reverse proxy, dan is HttpContext.Connection.RemoteIpAddress de proxy, niet de bezoeker. Elke rate-limit-partitie die je daarop bouwt gooit al je verkeer in een bak. Je limiteert of de hele wereld als een client, of niemand.

Repareer dit voordat je iets anders doet. Vertrouw de forwarded headers, en alleen van je proxy:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    // Vertrouw alleen de ranges van Cloudflare. Laat dit in productie nooit leeg;
    // een lege lijst betekent dat iedereen X-Forwarded-For kan spoofen.
    options.KnownNetworks.Clear();
    options.KnownProxies.Clear();
    foreach (var range in CloudflareRanges.V4)
        options.KnownNetworks.Add(range);
});

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

Zet je alles achter Cloudflare, gebruik dan liever hun CF-Connecting-IP-header. Die is een enkele waarde en kun je niet aan elkaar knopen zoals X-Forwarded-For:

static string ClientKey(HttpContext ctx)
{
    var cf = ctx.Request.Headers["CF-Connecting-IP"].FirstOrDefault();
    if (!string.IsNullOrEmpty(cf))
        return cf;

    return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}

Doe je dit verkeerd, dan is de rest van dit artikel toneel.

Rate limiting zoals .NET het echt levert

Sinds .NET 7 zit er een echte rate limiter in het framework, en de meeste teams knutselen nog steeds iets slechters. Gebruik AddRateLimiter. Een sliding window per client, plus een globaal concurrency-plafond zodat geen enkel endpoint het hele proces kan opeten:

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddPolicy("per-client", httpContext =>
        RateLimitPartition.GetSlidingWindowLimiter(
            partitionKey: ClientKey(httpContext),
            factory: _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = 60,
                Window = TimeSpan.FromMinutes(1),
                SegmentsPerWindow = 6,
                QueueLimit = 0
            }));

    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.Headers.RetryAfter = "60";
        await context.HttpContext.Response.WriteAsync(
            "Rate limit exceeded.", token);
    };
});

app.UseRateLimiter();

Hang de policy op de endpoints die je echt geld kosten:

app.MapGet("/search", SearchHandler).RequireRateLimiter("per-client");

Dat is een prima basis. Het is ook precies wat het jojopatroon wil verslaan. Een vaste limiet van 60 per minuut is een getal dat de aanvaller vindt en waar hij net onder gaat zitten. Dus stop hier niet.

Score de request, tel hem niet alleen

De les van Read the Docs, vertaald naar code, is deze: je partitiesleutel moet een signaal dragen over hoe betrouwbaar de beller is, en de limiet moet met dat signaal meebewegen. Cloudflare geeft je een bot-score op de request. Gebruik hem.

static int PermitFor(HttpContext ctx)
{
    // Cloudflare bot-managementscore: 1 = vrijwel zeker een bot,
    // 99 = vrijwel zeker een mens. Stem de banden af op je eigen verkeer.
    var raw = ctx.Request.Headers["Cf-Bot-Score"].FirstOrDefault();
    if (int.TryParse(raw, out var score))
    {
        if (score < 30) return 5;    // waarschijnlijk een bot: piepklein budget
        if (score < 60) return 30;   // verdacht: bescheiden budget
    }
    return 120;                      // oogt menselijk: royaal budget
}

options.AddPolicy("scored", httpContext =>
    RateLimitPartition.GetSlidingWindowLimiter(
        partitionKey: ClientKey(httpContext),
        factory: _ => new SlidingWindowRateLimiterOptions
        {
            PermitLimit = PermitFor(httpContext),
            Window = TimeSpan.FromMinutes(1),
            SegmentsPerWindow = 6,
            QueueLimit = 0
        }));

Nu krijgt een client die naar een bot ruikt vijf requests per minuut en een client die op een mens lijkt honderdtwintig. Het jojogedrag van de aanvaller vindt een limiet, maar het is zijn limiet, niet die van je klant. Dat is de hele verschuiving van "waar kwam het vandaan" naar "hoe ziet het eruit." Je blokkeert niemand. Je beprijst ze.

Heb je geen Cloudflare, dan kun je een ruwe versie van dat signaal zelf bouwen: een ontbrekende Accept-Language, een User-Agent die over duizenden requests nooit verandert, een headervolgorde die geen echte browser produceert. Grover, maar het principe blijft staan.

Ruim het cache-miss-oppervlak op

Rate limiting is je tweede linie. Caching is je eerste, en hij is goedkoper. De aanval mikte bewust op cache misses, dus jouw taak is ervoor zorgen dat de URL's die ze kunnen bereiken saai, goedkoop en gecacht zijn.

.NET heeft output caching sinds .NET 7. Dit is niet de oude response cache, hij leeft op de server en je kunt hem netjes varieren en verversen:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(b => b.Expire(TimeSpan.FromMinutes(5)));

    options.AddPolicy("public-read", b => b
        .Expire(TimeSpan.FromMinutes(10))
        .SetVaryByQuery("page", "q")
        .Tag("public"));
});

app.UseOutputCache();

app.MapGet("/docs/{slug}", DocsHandler).CacheOutput("public-read");

Twee dingen zijn hier belangrijk. Een: zelfs een venster van vijf minuten maakt van een dreigende origin-hit een edge-hit voor bijna elke herhaalde request. Dat is precies het punt van Read the Docs. Twee: tag je entries zodat je op een schrijfactie kunt invalideren in plaats van uit angst een korte TTL te zetten:

public async Task InvalidatePublic(IOutputCacheStore store, CancellationToken ct)
{
    await store.EvictByTagAsync("public", ct);
}

En let op je 404's. Een niet-gematchte route die je hele middleware-stack draait en de database raakt om te "checken of hij misschien bestaat" is een cadeau voor een aanvaller die bewust onzin-URL's opvraagt. Maak not-found goedkoop. Kap vroeg af, cache het negatieve resultaat, en laat een 404 nooit meer kosten dan een 200.

De uitweg is een feature, geen noodrem

Het laatste idee is het idee waar engineers tegen sputteren, omdat het voelt als opgeven. Een harde blokkade is bevredigend. Hij is ook bot, en hij vangt echte mensen. Read the Docs maakte van hun ergste reactie een challenge in plaats van een muur, en dat is het juiste instinct.

In .NET-termen: je afwijspad moet degraderen, niet de deur dichtslaan. Geef een 429 met een eerlijke Retry-After. Draai je een challenge-laag, stuur het twijfelverkeer daarheen in plaats van naar een 403. Log de afwijzing met de client-sleutel en de bot-score, zodat je het patroon de volgende ochtend echt kunt zien. Een blokkade die je niet kunt waarnemen is een blokkade die je niet kunt bijstellen, en bijstellen is tijdens een lopend incident het hele werk.

Beheer dit allemaal als code. Read the Docs draaide hun Cloudflare-regels via Terraform, zodat ze een wijziging in seconden konden uitrollen terwijl het vuur al brandde. Diezelfde discipline geldt voor je rate-limit-getallen en cachevensters: die horen in config die je kunt deployen, niet in een waarde die iemand om twee uur 's nachts in een portal typte en zich niet meer herinnert.

Niks hiervan is exotisch. Forwarded headers, de ingebouwde rate limiter, output caching, een bot-score waar je Cloudflare toch al voor betaalt. De verschuiving zit in je hoofd, niet in je stack. Stop met vragen waar een request vandaan komt. Begin te vragen hoe hij eruitziet, wat het je kost om hem te beantwoorden, en of je hem vanaf de edge kunt serveren.

Veelgestelde vragen over DDoS-verdediging in ASP.NET Core

Waarom werkt blokkeren op IP-adres niet meer tegen moderne DDoS? Omdat aanvallers hun verkeer nu spreiden over miljoenen residentiele proxy- en botnet-IP's, waardoor elk afzonderlijk adres maar een handvol requests stuurt en nooit afwijkend oogt, terwijl het totaal wel honderd keer je baseline kan zijn. Er is geen kleine set foute adressen meer om te bannen.

Wat is een cache-miss-oppervlak en waarom mikken aanvallers daarop? Een cache-miss-oppervlak is elke URL die niet vanaf de edge geserveerd kan worden en je origin moet raken, zoals 404's, redirects en gepersonaliseerde pagina's. Aanvallers zoeken die op omdat een gecachte pagina hen en jou vrijwel niks kost, terwijl een cache miss een database-hit of een render forceert en echte CPU op je servers verbruikt.

Vervangt de ingebouwde .NET rate limiter Cloudflare of een WAF? Nee, hij vult ze aan. De edge houdt het volume tegen en geeft je signalen zoals een bot-score, terwijl de in-process limiter budgetten per client afdwingt op het verkeer dat er doorheen komt, specifieke dure endpoints beschermt en blijft staan ook als je edge-regels verkeerd staan.

Waarom moet ik ForwardedHeaders instellen voordat ik rate limit achter een proxy? Omdat zonder die instelling elke request lijkt te komen van het IP van je proxy, waardoor elke rate-limit-partitie in een bak samenklapt en je of al je verkeer samen throttelt of niemand. Je moet eerst het echte client-IP oplossen, en forwarded headers alleen vertrouwen van bekende proxy-ranges om spoofing te voorkomen.

Is een JavaScript-challenge beter dan een harde blokkade? Voor twijfelverkeer meestal wel, omdat een echte browser hem stilletjes oplost en daarna een tijd vertrouwd blijft, terwijl een domme bot het script niet kan draaien en afhaakt. Een harde 403 vangt ook echte gebruikers, dus bewaar die voor verkeer waar je zeker van bent en stuur het onzekere verkeer naar een challenge.

Verder lezen


Conclusie: De aanval op Read the Docs gaat niet over volume, maar over identiteit. Als een aanvaller voor elke request een vers IP heeft, is de enige verdediging die overblijft: requests beoordelen op hoe ze zich gedragen en wat het je kost om ze te serveren. Cache hard, score in plaats van tellen, en laat echte mensen een weg naar binnen.

Bronnen: post-mortem van Read the Docs · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op