Diagramm mit dem Titel 'Why HTTPS Can Block Compression (gzip)': Ein kleines gzip-Paket gelangt ungehindert durch ein offenes HTTP-Rohr zum Browser, während ein sperriges gzip-Paket an einem Vorhängeschloss mit der Aufschrift BLOCKED in einem verschlüsselten HTTPS-Rohr aufgehalten wird
Zurück zum Blog
ASP.NET CoreHTTP-KomprimierungPerformance

ASP.NET Core Response Compression: Warum dein HTTPS-Traffic vielleicht nie komprimiert wird

Sascha KieferEntwicklung

Bei einem Kunden lief der Netzwerk-Traffic deutlich höher, als es die tatsächlich ausgelieferte Datenmenge hätte erklären können. Ein HAR-Mitschnitt bestätigte den Grund: null komprimierte Antworten, obwohl alles korrekt konfiguriert war. Ein einziger Boolean erklärt das: EnableForHttps steht standardmäßig auf false, ein bewusster Schutz vor einem jahrzehntealten TLS-Angriff, von dem die meisten Teams noch nie gehört haben. Warum dieser Default existiert, warum er sich vor jedem lokalen Check versteckt, und wann es sicher ist, ihn einzuschalten.

Bei einem Kunden lief der Netzwerk-Traffic deutlich höher, als es die tatsächlich übertragene Datenmenge hätte erklären können. Dieses Missverhältnis, nicht ein Support-Ticket oder ein Fehlerlog, war das erste Anzeichen, dass etwas nicht stimmte. Der Service hatte AddResponseCompression() bereits eingerichtet, eine MimeTypes-Liste, die JSON- und JavaScript-Antworten abdeckt, UseResponseCompression() in der Pipeline, genau so, wie es die Dokumentation zeigt. Der naheliegende nächste Schritt war also, zu bestätigen, dass die Komprimierung nicht ohnehin schon lief: eine HAR-Datei mit echtem Traffic ziehen und sie nach Content-Encoding durchsuchen.

Nichts. Keine einzige Antwort, von Dutzenden, war komprimiert. Der Client hatte Accept-Encoding: gzip, deflate, br gesendet. Die Middleware war registriert. Der MIME-Type stand auf der Liste. Trotzdem lief jeder einzelne Response-Body unkomprimiert über die Leitung.

Der Grund ist konkret und lässt sich auf eine einzige Property in Microsoft.AspNetCore.ResponseCompression zurückführen, die Komprimierung ausgerechnet für das eine Transportprotokoll still aushebelt, das fast jeder Produktionsservice nutzt: HTTPS.

Die eine Property, die niemand setzt

ResponseCompressionOptions hat eine Property namens EnableForHttps. Sie steht standardmäßig auf false.

builder.Services.AddResponseCompression(options =>
{
    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
    {
        "application/json",
        "text/javascript",
    });
    // EnableForHttps steht hier still auf false
});

An diesem Code ist nichts falsch. UseResponseCompression() ist korrekt eingebunden, und die MIME-Type-Liste enthält genau das, was ausgeliefert wird. Getestet über reines HTTP, in der lokalen Entwicklung oder in einem In-Process-Test-Host, funktioniert das genau wie erwartet. Sobald dieselbe Anfrage über HTTPS ankommt, schaut die ResponseCompressionMiddleware auf HttpContext.Request.IsHttps, sieht true und überspringt die Komprimierung komplett: keine Warnung, keine Log-Zeile, kein fehlgeschlagener Health-Check. Sie liefert die Antwort einfach unkomprimiert aus, als wäre die Middleware nie registriert worden.

Warum dieser Default existiert: BREACH

Das ist kein Versehen, sondern ein bewusster Default mit einem konkreten Ursprung: einem Angriff aus dem Jahr 2013 namens BREACH.

BREACH (Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext) zielt auf HTTP-seitige Komprimierung über TLS. Kurz gesagt: Mischt eine Antwort angreiferbeeinflussten Input, etwa einen aus der Query-String oder einem Suchfeld gespiegelten Wert, mit einem Geheimnis, das der Angreifer haben will (ein CSRF-Token, eine Session-ID, ein in die Seite eingebetteter API-Key), wird die Komprimierung selbst zum Seitenkanal. Die komprimierte Größe schrumpft stärker, wenn der geratene Substring des Angreifers zu einem Teil des Geheimnisses passt, weil gzip und Verwandte wiederholten Text deduplizieren. Über viele Anfragen hinweg, jede mit einem anderen geratenen Substring, kann ein Angreifer allein durch Beobachten der verschlüsselten Antwortlänge das Geheimnis Byte für Byte rekonstruieren, ohne die Verschlüsselung selbst zu brechen.

TLS verbirgt den Inhalt einer Antwort. Ihre Länge verbirgt es nicht, und Komprimierung macht aus dieser Länge ein Signal.

BREACH ist eine Weiterentwicklung des früheren CRIME-Angriffs, der die in TLS selbst eingebaute Komprimierung angriff und dazu führte, dass dieses Feature vollständig aus dem Protokoll entfernt wurde. BREACH greift dieselbe Idee eine Ebene höher an, beim HTTP-Response-Body, genau dem, was die ResponseCompressionMiddleware erzeugt. Statt das verwundbare Muster, gespiegelter Input plus Geheimnis in derselben komprimierten Antwort, Fall für Fall zu erkennen, haben die Maintainer von ASP.NET Core das gesamte Feature für HTTPS zum Opt-in gemacht. Grob, aber zuverlässig: keine Komprimierung, kein Oracle.

Warum kein lokaler Check das auffängt

Lokale Entwicklung läuft über reines HTTP, genau wie der meiste Container-zu-Container-Traffic hinter einem Load Balancer. IsHttps ist false, also verhält sich die Komprimierung genau wie konfiguriert, und alles, was auf einem Laptop sichtbar ist, sieht korrekt aus.

WebApplicationFactory steht ebenfalls standardmäßig auf HTTP. ASP.NET Cores In-Memory-Test-Host gibt dir einen HttpClient, dessen BaseAddress http://localhost ist, sofern du sie nicht überschreibst. Ein Test wie dieser ist grün und sagt trotzdem nichts über die Produktion:

var client = factory.CreateClient(); // BaseAddress: http://localhost
client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");

var response = await client.GetAsync("/api/data");
Assert.Contains("gzip", response.Content.Headers.ContentEncoding); // grün

Die Assertion ist echt, und der Test prüft die Compression-Middleware durchaus. Er prüft nur nie den einen Zweig dieser Middleware, den HTTPS-Check, der entscheidet, ob Komprimierung überhaupt läuft, sobald ein Client über HTTPS verbindet.

Der schwierigere Teil: Die meisten Services terminieren TLS gar nicht selbst. In einem typischen Cloud-native-Setup endet TLS an einem Ingress, einem Load Balancer oder einem Reverse Proxy vor den Pods, und der Traffic zwischen dieser Edge und der Anwendung ist oft reines HTTP. Warum kommt IsHttps dann tief in der App trotzdem auf true? Wegen UseForwardedHeaders(). Es liest X-Forwarded-Proto von der vertrauenswürdigen Edge und schreibt HttpContext.Request.Scheme so um, dass es dem entspricht, was der Client genutzt hat, genau das, was für korrekte Redirects, Cookies und URL-Generierung gebraucht wird. Nur bedeutet das: IsHttps spiegelt das clientseitige Schema, nicht das Transportprotokoll, auf dem der Prozess tatsächlich lauscht. Läuft UseForwardedHeaders() vor UseResponseCompression(), was die Pipeline aus diesen anderen Gründen meistens so haben sollte, sieht die Komprimierung https und schaltet sich selbst ab, selbst auf einem Socket, der von Anfang bis Ende reines HTTP ist.

Jedes Signal, das sich von einem Laptop, einem Unit-Test oder einem Container-Health-Check aus beobachten lässt, sagt: Die Komprimierung läuft. Nur der clientseitige Traffic widerspricht.

Es finden: echten Traffic mitschneiden

Code-Review fängt das nicht ab, weil der Code für das, was er konfiguriert, korrekt ist. Lokale Tests fangen es aus den genannten Gründen ebenfalls nicht ab. Der einzige verlässliche Check ist, sich anzuschauen, was echte Clients über das echte Protokoll empfangen.

Zieh eine HAR-Datei mit echtem Traffic gegen die Umgebung, die deine Nutzer treffen (das Netzwerk-Panel deines Browsers, „Save all as HAR", oder einen bestehenden Session-Mitschnitt), und prüfe sie dann per Skript, statt Dutzende Requests einzeln durchzugehen:

# Mit jq gegen eine exportierte .har-Datei
jq '[.log.entries[] | select(.request.url | startswith("https://dein-service"))] | length' traffic.har
jq '[.log.entries[] | select(.request.url | startswith("https://dein-service"))
       | select(.response.headers[] | select(.name | test("content-encoding";"i")))] | length' traffic.har

Entspricht die erste Zahl deiner Gesamtzahl an Requests und die zweite ist null, hast du genau diesen Bug: HTTPS-Traffic, null Content-Encoding-Header, egal was die Middleware-Konfiguration behauptet. Das ist eine Fünf-Minuten-Prüfung, deutlich schlüssiger als Konfigurationscode zu lesen, und die Technik trägt weit über diesen einen Bug hinaus: Steht „die Config sieht richtig aus, aber ich kann nicht bestätigen, dass sie in Produktion aktiv ist" im Raum, klärt ein HAR-Mitschnitt plus Header-Suche das direkt, anhand dessen, was Clients empfangen haben, statt anhand dessen, was der Code verspricht.

Entscheiden, wann du es einschaltest

Dass EnableForHttps Opt-in ist, heißt nicht, dass es für immer aus bleiben sollte. Die meisten Services kommen dem Muster, das BREACH ausnutzt, nie nahe. Die Frage, die sich für jeden komprimierten Antworttyp stellt, ist einfach: Mischt diese Antwort jemals ein Geheimnis mit etwas, das ein Angreifer beeinflussen kann?

Grundsätzlich sicher, über HTTPS zu komprimieren:

  • Statische oder versionierte Assets (JS-Bundles, CSS, Bilder), die weder nutzerabhängig sind noch Request-Input spiegeln.
  • Read-only API-Antworten, vollständig aus serverseitigem oder Datenbank-Zustand aufgebaut, ohne dass ein nutzergelieferter String in den Body zurückgespiegelt wird.
  • Öffentliche JSON-Metadaten, Katalog-Listings und ähnlicher Content ohne pro Session eingebettetes Geheimnis.

Einen genaueren Blick wert:

  • Eine Antwort, die einen Query-Parameter, ein Formularfeld oder einen Header-Wert in den Body zurückspiegelt und gleichzeitig ein sessiongebundenes Geheimnis (CSRF-Token, API-Key, personalisierte Daten) in derselben Antwort trägt.
  • Authentifizierte Seiten, die Suchbegriffe, Fehlermeldungen oder Nutzer-Input zusammen mit Anti-Forgery-Tokens spiegeln.

Näher an der ersten Liste ist EnableForHttps = true ein einfacher, sicherer Gewinn. Näher an der zweiten, entweder diesen spezifischen Antworttyp von der Komprimierung ausschließen (ResponseCompressionOptions erlaubt einen Ausschluss nach MIME-Type oder einen eigenen Provider), oder den Default für diese Routen aus lassen und es im Einzelfall behandeln.

Der Fix, und ein Test, der ihn gefangen hätte

Der Fix ist eine Zeile, dazu ein Kommentar, der erklärt, warum es überhaupt aus war. Ohne diesen Kontext entfernt ein späterer Leser, vielleicht du selbst, es sonst „hilfreich" als toten Konfigurationseintrag:

builder.Services.AddResponseCompression(options =>
{
    // Standardmäßig false, als Schutz vor BREACH-artigen Compression-Oracle-Angriffen
    // auf HTTPS-Antworten, die angreifergespiegelten Input mit einem Geheimnis mischen.
    // Hier sicher: jede Antwort ist statischer Content oder Read-only-Daten ohne
    // gespiegelten Input oder eingebettete Geheimnisse.
    options.EnableForHttps = true;
    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
    {
        "application/json",
        "text/javascript",
    });
});

Der Fix, der hält, ist der Test. Da WebApplicationFactory.CreateClient() standardmäßig auf http://localhost steht, bedeutet den Code-Pfad zu prüfen, den Produktions-Traffic trifft, den Test-Client stattdessen auf eine https://-BaseAddress zu setzen. Die ResponseCompressionMiddleware prüft nur das Schema der Anfrage, also funktioniert das gegen den In-Memory-Test-Host, ganz ohne echten TLS-Handshake:

var client = factory.CreateDefaultClient(new Uri("https://localhost"));
client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");

var response = await client.GetAsync("/api/data");
Assert.Contains("gzip", response.Content.Headers.ContentEncoding);

Läuft dieser Test gegen den Code vor dem Fix, schlägt er fehl und reproduziert genau das, was der HAR-Mitschnitt gezeigt hat. Mit EnableForHttps = true ist er grün. Das ist der Unterschied zwischen einem Test, der Komprimierung prüft, und einem Test, der Komprimierung auf dem Protokoll prüft, auf dem der Service tatsächlich läuft.

Eine Checkliste, bevor du umschaltest

  1. Bestätige zuerst das Symptom. Zieh eine HAR-Datei mit echtem HTTPS-Traffic und durchsuche sie nach Content-Encoding. Fehlt er überall, ist das sehr wahrscheinlich die Ursache.
  2. Prüfe die Reihenfolge deiner Pipeline. Läuft UseForwardedHeaders() vor UseResponseCompression(), sieht die App das clientseitige Schema, und IsHttps steht hinter einem HTTPS-terminierenden Ingress auf true, auch wenn der lokale Socket reines HTTP ist.
  3. Klassifiziere jeden komprimierten Antworttyp. Statischer oder versionierter Content und Read-only-Daten ohne gespiegelten Input sind sicher. Alles, was Request-Input zusammen mit einem sessionbezogenen Geheimnis spiegelt, braucht einen genaueren Blick oder einen Ausschluss.
  4. Setze EnableForHttps = true ausdrücklich, mit einem Kommentar, der festhält, warum es aus war und warum es jetzt sicher ist, damit es später niemand beim erneuten Lesen des Defaults „aufräumt".
  5. Ergänze einen Integrationstest mit https://-Schema. Nur diese Art Test prüft diesen Zweig; ein Test-Client mit http://localhost ist grün, egal ob der Fix drin ist.
  6. Zieh nach dem Deploy erneut Traffic, um zu bestätigen, dass Content-Encoding jetzt dort auftaucht, wo erwartet, und auch nur dort.

Fazit

Ein einziger Boolean, aus einem konkreten Grund defaultet, kann eine ganze Kategorie von Optimierung in der Produktion lahmlegen, während jedes lokale Signal, Code-Review, Unit-Test, Health-Check, behauptet, es funktioniere. Komprimierung ist hier nur das aktuelle Beispiel. Hängt die Aktivierung eines Features vom Schema, der Umgebung oder irgendetwas anderem ab, das zwischen Test-Harness und echtem Traffic auseinanderdriftet, sind „der Code sieht richtig aus" und „er läuft richtig in der Produktion" zwei getrennte Aussagen, und das Lesen des Diffs prüft nur eine davon. Zieh nach, was Clients tatsächlich empfangen, bevor du dem vertraust, was die Konfiguration verspricht.

Quellen

Brauchst du Unterstützung?

Liefert dein ASP.NET-Core-Service die Performance, für die du ihn konfiguriert hast, oder lässt er sie still liegen? Wir helfen Teams, ihre HTTP-Pipeline zu prüfen und zu härten: Komprimierung, Resilienz, Security-Header und die Tests, die genau sowas auffangen, bevor es unbemerkt live geht. Melde dich einfach bei uns, und wir sorgen dafür, dass deine Services so performant und sicher laufen, wie du es vorgesehen hast.

Kontakt aufnehmen