Diagramme intitulé « Why HTTPS Can Block Compression (gzip) » : un petit colis gzip circule librement dans un tube HTTP non verrouillé vers un navigateur, tandis qu'un colis gzip volumineux est arrêté par un cadenas marqué BLOCKED sur un tube HTTPS chiffré
Retour au blog
ASP.NET CoreCompression HTTPPerformance

Compression des réponses ASP.NET Core : pourquoi votre trafic HTTPS n'est peut-être jamais compressé

Sascha KieferDéveloppement

Chez un client, le trafic réseau était nettement plus élevé que ce que le volume de données réellement servi aurait dû justifier. Une capture HAR a confirmé la raison : zéro réponse compressée, alors que tout était correctement configuré. Un seul booléen explique cela : EnableForHttps vaut false par défaut, une protection délibérée contre une attaque TLS vieille de dix ans que la plupart des équipes ne connaissent pas. Pourquoi ce défaut existe, pourquoi il échappe à tout contrôle local, et quand il est sûr de l'activer.

Chez un client, le trafic réseau était nettement plus élevé que ce que le volume de données réellement servi aurait dû justifier. Cet écart, pas un ticket support ni un journal d'erreurs, a été le premier signe que quelque chose clochait. Le service avait déjà AddResponseCompression() en place, une liste MimeTypes couvrant les réponses JSON et JavaScript, UseResponseCompression() dans le pipeline, exactement comme le montre la documentation. L'étape suivante logique était donc de confirmer que la compression ne faisait pas déjà son travail : capturer un fichier HAR du trafic réel et y chercher Content-Encoding.

Rien. Pas une seule réponse, parmi des dizaines, n'était compressée. Le client avait pourtant envoyé Accept-Encoding: gzip, deflate, br. Le middleware était enregistré. Le type MIME figurait dans la liste. Chaque corps de réponse a quand même traversé le réseau non compressé.

La raison est précise et tient à une seule propriété de Microsoft.AspNetCore.ResponseCompression, qui neutralise silencieusement la compression pour le seul protocole de transport que quasiment tout service en production utilise : HTTPS.

La seule propriété que personne ne configure

ResponseCompressionOptions possède une propriété nommée EnableForHttps. Elle vaut false par défaut.

builder.Services.AddResponseCompression(options =>
{
    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
    {
        "application/json",
        "text/javascript",
    });
    // EnableForHttps reste silencieusement à false ici
});

Rien n'est incorrect dans ce code. UseResponseCompression() est correctement branché, et la liste des types MIME couvre exactement ce qui est servi. Testez cela en HTTP simple, en développement local ou dans un hôte de test en mémoire, et tout fonctionne comme prévu. Dès que la même requête arrive en HTTPS, ResponseCompressionMiddleware consulte HttpContext.Request.IsHttps, constate true, et ignore complètement la compression : pas d'avertissement, pas de ligne de log, pas de health check en échec. Elle sert simplement la réponse non compressée, comme si le middleware n'avait jamais été enregistré.

Pourquoi ce défaut existe : BREACH

Ce n'est pas un oubli, mais un choix par défaut délibéré, dont l'origine remonte à une attaque précise de 2013 : BREACH.

BREACH (Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext) vise la compression au niveau HTTP par-dessus TLS. En résumé : si une réponse mélange une entrée influencée par l'attaquant, une valeur reflétée depuis la query string ou un champ de recherche par exemple, avec un secret que l'attaquant convoite (un jeton CSRF, un identifiant de session, une clé d'API intégrée à la page), la compression devient elle-même un canal auxiliaire. La taille compressée diminue davantage lorsque la sous-chaîne devinée par l'attaquant correspond à une partie du secret, parce que gzip et ses semblables dédupliquent le texte répété. En envoyant de nombreuses requêtes, chacune avec une sous-chaîne devinée différente, et en observant comment varie la longueur de la réponse chiffrée, un attaquant peut reconstituer le secret octet par octet, sans jamais briser le chiffrement lui-même.

TLS masque le contenu d'une réponse. Il ne masque pas sa longueur, et la compression transforme cette longueur en signal.

BREACH prolonge l'attaque CRIME antérieure, qui visait la compression intégrée à TLS lui-même et a conduit à retirer cette fonctionnalité du protocole. BREACH vise la même idée un niveau au-dessus, au niveau du corps de la réponse HTTP, exactement ce que produit ResponseCompressionMiddleware. Plutôt que de détecter le motif vulnérable, entrée reflétée plus secret dans la même réponse compressée, au cas par cas, les mainteneurs d'ASP.NET Core ont rendu toute la fonctionnalité opt-in pour HTTPS. Un instrument brutal, mais sûr : pas de compression, pas d'oracle.

Pourquoi aucun contrôle local ne le détecte

Le développement local tourne en HTTP simple, tout comme la majeure partie du trafic conteneur-à-conteneur derrière un load balancer. IsHttps vaut false, donc la compression fonctionne exactement comme configurée, et tout ce qui est visible depuis un poste local semble correct.

WebApplicationFactory utilise HTTP par défaut, elle aussi. L'hôte de test en mémoire d'ASP.NET Core vous fournit un HttpClient dont la BaseAddress est http://localhost, sauf si vous la remplacez explicitement. Un test comme celui-ci passe et ne dit rien de la production :

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); // passe

L'assertion est authentique, et le test exerce bel et bien le middleware de compression. Il n'exerce simplement jamais la branche précise de ce middleware, la vérification HTTPS, qui décide si la compression s'active une fois qu'un client se connecte en HTTPS.

Le plus difficile : la plupart des services ne terminent pas TLS eux-mêmes. Dans une architecture cloud native typique, TLS se termine au niveau d'un ingress, d'un load balancer ou d'un reverse proxy devant les pods, et le trafic entre cette lisière et l'application est souvent en HTTP simple. Pourquoi IsHttps revient-il quand même à true au cœur de l'application ? À cause de UseForwardedHeaders(). Ce middleware lit X-Forwarded-Proto depuis la lisière de confiance et réécrit HttpContext.Request.Scheme pour refléter ce que le client a utilisé, exactement ce dont on a besoin pour des redirections, des cookies et une génération d'URL corrects. Mais cela signifie que IsHttps reflète le schéma côté client, pas le transport sur lequel le processus écoute littéralement. Si UseForwardedHeaders() s'exécute avant UseResponseCompression(), ce qui est généralement souhaitable pour ces autres raisons, la compression voit https et se désactive elle-même, même sur un socket qui reste en HTTP simple d'un bout à l'autre.

Tout signal observable depuis un poste local, un test unitaire ou un health check conteneur indique que la compression fonctionne. Seul le trafic côté client dit le contraire.

Le trouver : capturer du trafic réel

La revue de code ne détecte pas ce problème, car le code est correct par rapport à ce qu'il configure. Les tests locaux ne le détectent pas non plus, pour les raisons évoquées ci-dessus. Le seul contrôle fiable consiste à observer ce que les clients réels reçoivent sur le protocole réel.

Capturez un fichier HAR du trafic réel contre l'environnement que vos utilisateurs atteignent (le panneau réseau de votre navigateur, « Save all as HAR », ou une capture de session existante), puis vérifiez-le par script plutôt qu'en parcourant des dizaines de requêtes à l'œil :

# Avec jq sur un fichier .har exporté
jq '[.log.entries[] | select(.request.url | startswith("https://votre-service"))] | length' traffic.har
jq '[.log.entries[] | select(.request.url | startswith("https://votre-service"))
       | select(.response.headers[] | select(.name | test("content-encoding";"i")))] | length' traffic.har

Si le premier nombre correspond à votre nombre total de requêtes et que le second vaut zéro, vous avez exactement ce bug : du trafic HTTPS, zéro en-tête Content-Encoding, quelle que soit la configuration affichée par le middleware. C'est une vérification de cinq minutes, bien plus concluante que la lecture du code de configuration, et la méthode se généralise largement au-delà de ce bug précis. Chaque fois que « la configuration semble correcte, mais je ne peux pas confirmer qu'elle est active en production » se pose, une capture HAR suivie d'une recherche d'en-tête tranche directement, à partir de ce que les clients ont reçu plutôt que de ce que le code promet.

Décider quand l'activer

Le fait qu'EnableForHttps soit opt-in ne signifie pas qu'il doive rester désactivé pour toujours. La plupart des services ne s'approchent jamais du motif exploité par BREACH. La question à poser pour chaque type de réponse que vous voudriez compresser est simple : cette réponse mélange-t-elle un jour un secret avec quelque chose qu'un attaquant peut influencer ?

Généralement sûr à compresser en HTTPS :

  • Les ressources statiques ou versionnées (bundles JS, CSS, images) qui ne varient pas selon l'utilisateur et ne reflètent pas l'entrée de la requête.
  • Les réponses d'API en lecture seule, construites entièrement à partir de l'état serveur ou base de données, sans qu'aucune chaîne fournie par l'utilisateur ne soit renvoyée dans le corps.
  • Les métadonnées JSON publiques, listings de catalogue et contenus similaires, sans secret par session intégré.

À examiner de plus près :

  • Une réponse qui reflète un paramètre de requête, un champ de formulaire ou une valeur d'en-tête dans le corps et porte aussi un secret lié à la session (jeton CSRF, clé d'API, données personnalisées) dans cette même réponse.
  • Les pages authentifiées qui renvoient des termes de recherche, des messages d'erreur ou une saisie utilisateur aux côtés de jetons anti-CSRF.

Plus proche de la première liste, EnableForHttps = true est un gain simple et sûr. Plus proche de la seconde, mieux vaut exclure ce type de réponse précis de la compression (ResponseCompressionOptions permet une exclusion par type MIME ou un provider personnalisé), ou laisser le défaut désactivé pour ces routes et traiter le cas au coup par coup.

Le correctif, et un test qui l'aurait détecté

Le correctif tient en une ligne, accompagnée d'un commentaire expliquant pourquoi c'était désactivé à l'origine. Sans ce contexte, un lecteur futur, peut-être vous-même, le supprimera « utilement » comme une configuration morte :

builder.Services.AddResponseCompression(options =>
{
    // Vaut false par défaut pour se prémunir contre les attaques de type oracle de
    // compression BREACH sur des réponses HTTPS mélangeant une entrée reflétée par
    // l'attaquant et un secret. Sûr ici : chaque réponse est du contenu statique ou
    // des données en lecture seule, sans entrée reflétée ni secret intégré.
    options.EnableForHttps = true;
    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
    {
        "application/json",
        "text/javascript",
    });
});

Le correctif qui tient dans le temps, c'est le test. Puisque WebApplicationFactory.CreateClient() utilise http://localhost par défaut, exercer le chemin de code que rencontre le trafic de production suppose de pointer le client de test vers une BaseAddress en https://. ResponseCompressionMiddleware ne vérifie que le schéma de la requête, donc cela fonctionne contre l'hôte de test en mémoire, sans aucune négociation TLS :

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);

Exécutez ce test contre le code d'avant le correctif et il échoue, reproduisant exactement ce que montrait la capture HAR. Ajoutez EnableForHttps = true et il passe. C'est la différence entre un test qui exerce la compression et un test qui exerce la compression sur le protocole sur lequel le service est réellement déployé.

Une checklist avant de basculer l'interrupteur

  1. Confirmez d'abord le symptôme. Capturez un fichier HAR de trafic HTTPS réel et cherchez-y Content-Encoding. S'il est absent partout, c'est très probablement la cause.
  2. Vérifiez l'ordre de votre pipeline. Si UseForwardedHeaders() s'exécute avant UseResponseCompression(), l'application voit le schéma côté client, et IsHttps vaudra true derrière un ingress qui termine HTTPS, même si le socket local est en HTTP simple.
  3. Classez chaque type de réponse compressée. Le contenu statique ou versionné et les données en lecture seule sans entrée reflétée sont sûrs. Tout ce qui reflète une entrée de requête aux côtés d'un secret lié à la session mérite un examen plus approfondi ou une exclusion.
  4. Activez explicitement EnableForHttps = true, avec un commentaire consignant pourquoi c'était désactivé et pourquoi c'est désormais sûr, afin que personne ne le « nettoie » plus tard en relisant le défaut.
  5. Ajoutez un test d'intégration en schéma https://. C'est le seul type de test qui exerce cette branche ; un client de test en http://localhost passera au vert que le correctif soit présent ou non.
  6. Recapturez le trafic après le déploiement pour confirmer que Content-Encoding apparaît là où vous l'attendez, et seulement là.

Conclusion

Un seul booléen, dont la valeur par défaut répond à une raison précise, peut rendre inopérante toute une catégorie d'optimisation en production, alors que chaque signal local, revue de code, test unitaire, health check, affirme que tout fonctionne. La compression n'est ici qu'un exemple parmi d'autres. Chaque fois que l'activation d'une fonctionnalité dépend du schéma, de l'environnement, ou de tout autre élément qui diverge entre un harnais de test et le trafic réel, « le code semble correct » et « ça fonctionne en production » sont deux affirmations distinctes, et la lecture du diff ne vérifie que l'une des deux. Vérifiez ce que les clients reçoivent avant de faire confiance à ce que la configuration promet.

Sources

Besoin d'aide ?

Votre service ASP.NET Core délivre-t-il la performance pour laquelle vous l'avez configuré, ou la laisse-t-il silencieusement de côté ? Nous aidons les équipes à auditer et à renforcer leur pipeline HTTP : compression, résilience, en-têtes de sécurité, et les tests qui détectent ce genre de problème avant qu'il ne parte en production sans que personne ne le remarque. Contactez-nous simplement, et nous veillerons à ce que vos services soient performants, et restent sûrs, comme vous l'aviez prévu.

Nous contacter