Aller au contenu

La clé de cache HTTP : pourquoi deux requêtes identiques ne le sont pas

La clé de cache HTTP : pourquoi deux requêtes identiques ne le sont pas

Section intitulée « La clé de cache HTTP : pourquoi deux requêtes identiques ne le sont pas »

Un cache partagé ne compare pas des requêtes, il compare des clés. Tout ce qui ne fait pas partie de la clé est invisible pour lui, et tout ce qui est invisible peut être vu comme identique à autre chose. C’est de là que viennent la tromperie du cache, l’empoisonnement du cache et les fuites de pages authentifiées vers l’utilisateur suivant. La RFC 9111, publiée en juin 2022, décrit exactement ce que la clé contient et ce qu’elle omet ; c’est la lecture qui manque à la plupart des discussions sur le sujet.

→ Lire l’explication complète de la marmotte

La clé : une méthode, une adresse, et rien d’autre par défaut

Section intitulée « La clé : une méthode, une adresse, et rien d’autre par défaut »

La RFC définit la clé de cache comme « l’information qu’un cache utilise pour choisir une réponse », composée au minimum de la méthode de la requête et de l’adresse cible utilisées pour obtenir la réponse stockée. Elle ajoute aussitôt l’observation qui compte en pratique : « beaucoup de caches HTTP d’usage courant aujourd’hui ne mettent en cache que les réponses aux requêtes GET et n’utilisent donc que l’adresse comme clé de cache ».

Retenez cette phrase, parce qu’elle contient tout le problème. Si la clé n’est que l’adresse, alors deux requêtes vers la même adresse sont la même requête aux yeux du cache, quels que soient leurs en-têtes, leurs cookies, leur jeton d’autorisation, leur langue demandée ou l’identité de l’utilisateur. Un cache correctement implémenté se comporte exactement ainsi, et ce n’est pas une faille : c’est la définition.

La RFC précise que le cache peut incorporer d’autres éléments dans la clé, et donne l’exemple le plus connu : les caches de navigateur peuvent y inclure l’identité du site référent, pratique appelée « double clé », pour écarter certains risques de vie privée. Elle note aussi que la méthode « détermine dans quelles circonstances cette réponse peut servir à satisfaire une requête ultérieure », et qu’un cache « DOIT faire traverser les requêtes dont les méthodes ne sont pas sûres jusqu’au serveur d’origine », c’est-à-dire ne jamais répondre à un POST depuis son stock.

Le choix entre plusieurs réponses stockées suit une règle d’arbitrage simple : « lorsque plus d’une réponse convenable est stockée, un cache DOIT utiliser la plus récente », déterminée par l’en-tête Date. Et un cache sans horloge « DOIT revalider les réponses stockées à chaque utilisation », ce qui est la seule remarque amusante de la RFC et la plus souvent ignorée des équipements embarqués.

Une ressource dont la représentation varie selon la requête, par la langue, par l’encodage accepté, par le format, doit le déclarer, et l’en-tête de réponse Vary est le mécanisme prévu. La règle de la RFC est précise : quand une réponse stockée contient un Vary, le cache « NE DOIT PAS utiliser cette réponse stockée sans revalidation, à moins que tous les champs d’en-tête de requête présentés et nommés par la valeur de ce Vary correspondent à ces mêmes champs dans la requête d’origine », c’est-à-dire celle qui a provoqué la mise en cache.

Le mot « correspondent » a une définition formelle, et elle vaut d’être connue parce qu’elle est plus souple qu’on ne croit. Deux jeux d’en-têtes correspondent si l’on peut transformer le premier en le second en ajoutant ou retirant des espaces là où la syntaxe l’autorise, en combinant plusieurs lignes de même nom, ou en normalisant les valeurs d’une manière connue pour être sémantiquement identique, par exemple en réordonnant des valeurs quand l’ordre n’est pas significatif ou en normalisant la casse quand les valeurs sont insensibles à la casse. Deux règles tranchées complètent le dispositif : si un en-tête est absent d’une requête après normalisation, « il ne peut correspondre à une autre requête que s’il y est également absent » ; et une réponse dont la valeur de Vary contient * « échoue toujours à correspondre », autrement dit n’est jamais réutilisable.

Un piège explicitement nommé par la RFC mérite d’être signalé, car il est contre-intuitif : « certaines ressources omettent par erreur l’en-tête Vary de leur réponse par défaut », celle envoyée quand la requête n’exprime aucune préférence, « avec pour effet de la choisir pour les requêtes ultérieures vers cette ressource, même lorsque des réponses plus appropriées sont disponibles ». Une réponse sans Vary est donc un joker qui écrase les variantes correctement déclarées, et la RFC recommande au cache, dans ce cas, de préférer la réponse stockée la plus récente qui porte une valeur de Vary valide.

Conséquence opérationnelle : Vary doit être exact et minimal. Trop restrictif, il détruit le taux de réutilisation, et un Vary: User-Agent sur un site public revient à désactiver le cache, puisque les chaînes d’agent utilisateur sont pratiquement uniques. Trop permissif, et deux représentations différentes se partagent une entrée. La bonne pratique consiste à faire varier sur un petit nombre d’en-têtes normalisés en amont, à la bordure, plutôt que sur l’en-tête brut envoyé par le client.

Ce que le cache ignore volontairement, et le cas des cookies

Section intitulée « Ce que le cache ignore volontairement, et le cas des cookies »

Les conditions de stockage sont une liste fermée dans la RFC, et deux lignes concernent directement la confidentialité. Un cache partagé ne doit pas stocker une réponse si la directive private est présente, et ne doit pas la stocker si « le champ d’en-tête Authorization est présent dans la requête », sauf si une directive de réponse autorise explicitement la mise en cache partagée. La RFC énumère les trois directives qui produisent cet effet : must-revalidate, public et s-maxage. En poser une sur une réponse authentifiée, par confort de performance, autorise donc un cache partagé à la servir à quelqu’un d’autre.

Et puis vient la phrase qui devrait être affichée dans tous les services d’exploitation : « l’en-tête de réponse Set-Cookie n’inhibe pas la mise en cache ; une réponse mettable en cache portant un Set-Cookie peut être, et est souvent, utilisée pour satisfaire des requêtes ultérieures ». Autrement dit, une page qui installe une session chez l’utilisateur A peut être servie telle quelle à l’utilisateur B, cookie compris, si rien ne l’interdit. La RFC invite les serveurs qui souhaitent contrôler cela à « émettre les champs Cache-Control appropriés », formulation diplomatique pour dire que l’absence de directive est une décision par défaut, et qu’elle est mauvaise. Le reste des propriétés du cookie de session est traité dans les attributs du cookie de session.

Le cookie de requête, symétriquement, ne fait pas partie de la clé à moins d’être nommé par Vary. Un cache ne distingue donc pas deux utilisateurs connectés par leurs cookies : il voit deux requêtes vers la même adresse. Toute la sécurité d’un cache partagé devant une application authentifiée repose sur l’une de deux décisions explicites : ne pas stocker ces réponses, ou les cloisonner par une clé qui inclut l’identité. Il n’y a pas de troisième option, et surtout pas celle qui consiste à espérer.

La RFC consacre enfin un paragraphe aux fuites par mesure du temps, qui relèvent du même mécanisme : « parce que l’un des usages premiers d’un cache est d’optimiser la performance, son usage peut divulguer quelles ressources ont été précédemment demandées ». Un site peut tenter de charger des ressources qu’il sait présentes sur un autre site : si elles arrivent vite, l’utilisateur les a visitées. L’atténuation est précisément l’ajout d’information à la clé, « parfois appelé double clé », et elle illustre bien que la clé de cache est un paramètre de confidentialité autant que de performance. Le lien avec le cloisonnement du navigateur est direct : voir la politique de même origine.

Deux familles d’attaques exploitent l’écart entre ce que le cache met dans la clé et ce que le serveur d’origine prend en compte pour produire sa réponse. C’est la même racine, prise dans les deux sens.

La tromperie consiste à faire mettre en cache une réponse privée sous une clé publique. L’attaquant obtient de la victime une requête vers une adresse qui, pour le cache, ressemble à une ressource statique anonyme, tandis que le serveur d’origine la traite comme la page authentifiée de la victime. La réponse personnelle entre dans le cache sous cette clé, et l’attaquant la lit ensuite en la demandant à son tour. Le mécanisme complet, ses variantes et ses contre-mesures sont détaillés dans l’attaque de tromperie du cache CDN ; cette page en fournit le soubassement, à savoir que le cache a décidé que le chemin suffisait à identifier la réponse.

L’empoisonnement fait l’inverse : il place dans le cache un contenu choisi par l’attaquant, servi ensuite à tous les visiteurs. La RFC le décrit comme une attaque où « un attaquant utilise des défauts d’implémentation, des privilèges élevés ou d’autres techniques pour insérer une réponse dans un cache », et souligne qu’elle est « particulièrement efficace quand des caches partagés sont utilisés pour distribuer du contenu malveillant à de nombreux clients ». Elle nomme le vecteur courant : « exploiter les différences d’analyse des messages entre les proxys et les navigateurs ». La même divergence d’interprétation entre deux composants de la chaîne produit la confusion de préfixe de chemin devant un proxy, et l’en-tête Host en est un véhicule classique, traité dans l’injection d’en-tête Host.

Le principe commun est l’existence d’entrées non clavetées : un élément de la requête qui influence la réponse produite sans faire partie de la clé. Tout en-tête que l’application lit pour construire sa réponse, et que le cache ne nomme pas dans Vary, est une telle entrée. La RFC ajoute l’argument de durée, qui explique pourquoi ces attaques comptent plus que leur sophistication ne le suggère : « puisque le contenu du cache persiste après la fin d’une requête HTTP, une attaque sur le cache peut révéler de l’information longtemps après que l’utilisateur croit celle-ci retirée du réseau. Le contenu des caches doit donc être protégé comme une information sensible. »

Cinq décisions, dans cet ordre, et aucune ne doit rester implicite.

Classer les réponses d’abord, en trois catégories : publiques et identiques pour tous, personnalisées, et authentifiées. Les deux dernières portent Cache-Control: private pour un cache partagé, voire no-store quand la réponse n’a aucune raison de survivre ; la première porte une directive explicite de mise en cache. Une réponse sans directive est laissée à l’heuristique du cache, ce que la RFC autorise mais que personne ne devrait souhaiter.

Déclarer les variations ensuite, par un Vary exact et court, en normalisant les en-têtes concernés à la bordure pour que la clé ne se fragmente pas inutilement. Et puisqu’un Access-Control-Allow-Origin calculé en fonction de la requête est une variation, il doit apparaître dans Vary sous peine de voir une autorisation accordée à une origine servie à une autre.

Décider la clé explicitement sur le cache partagé, troisième point, plutôt que de la laisser à la configuration par défaut du produit : quels en-têtes, quels paramètres de requête, et surtout quels paramètres de requête sont ignorés, car un paramètre ignoré dans la clé mais pris en compte par l’application est exactement une entrée non clavetée. Normaliser l’adresse à un seul endroit, quatrième point, pour que le cache et l’origine aient la même lecture du chemin, des extensions et de l’encodage. Et vérifier, cinquième point, en demandant deux fois la même adresse avec deux sessions différentes puis en comparant les réponses, octet par octet. C’est un test de dix minutes qui attrape l’essentiel, et il manque dans à peu près toutes les recettes de mise en production.

Sans directive de cache explicite sur les réponses authentifiées, un cache partagé place une page personnelle sous une clé composée de la seule adresse, et la sert à l’utilisateur suivant qui demande la même adresse. Sans connaissance du fait qu’un Set-Cookie n’empêche pas la mise en cache, une réponse qui installe une session est rejouée à un autre visiteur, avec le cookie de session de la première victime. Sans Vary exact, deux représentations différentes de la même ressource se partagent une entrée, et la variante servie n’est pas celle demandée. Sans Vary sur une autorisation inter-origine calculée dynamiquement, l’autorisation accordée à une origine est servie à une autre. Sans inventaire des entrées non clavetées, un en-tête que l’application lit sans que le cache le nomme devient le levier d’un empoisonnement qui touche tous les visiteurs. Et sans normalisation commune de l’adresse entre le cache et l’origine, les deux composants n’identifient pas la même ressource, ce qui est la condition exacte de la tromperie de cache.

Entre un visiteur et un site se trouvent souvent des caches : des machines qui gardent une copie des réponses pour ne pas redemander deux fois la même chose. Pour savoir si une réponse déjà gardée convient à une nouvelle demande, le cache ne compare pas les deux demandes en entier. Il fabrique une étiquette, appelée clé, et compare les étiquettes.

Cette étiquette contient, au minimum, le verbe de la demande et l’adresse demandée. En pratique, sur beaucoup de caches, elle ne contient que l’adresse. Tout le reste de la demande est donc ignoré : la langue souhaitée, le navigateur utilisé, et surtout les identifiants de session de la personne connectée. Autrement dit, si rien ne l’interdit, deux personnes différentes qui demandent la même adresse recevront la même réponse, même si cette réponse était la page personnelle de la première.

Il existe un moyen pour le site de dire « ma réponse dépend de tel détail de la demande » : un en-tête nommé Vary, dans lequel il énumère les éléments à prendre en compte. S’il l’oublie, le cache mélange les variantes. S’il en met trop, le cache ne sert plus à rien, parce que chaque visiteur a une étiquette unique.

Deux attaques exploitent cet écart. Dans la première, l’attaquant s’arrange pour que la page privée de sa victime soit rangée sous une étiquette qui ressemble à un fichier public, puis il la demande à son tour et la lit. Dans la seconde, il fait ranger un contenu de son choix sous l’étiquette d’une page normale, et ce contenu est ensuite servi à tous les visiteurs. Les deux reposent sur la même cause : un détail de la demande influence la réponse du site sans figurer dans l’étiquette du cache.

Le remède tient en une phrase : décider explicitement, pour chaque type de réponse, si elle peut être gardée, par qui, et de quoi elle dépend. Et vérifier, avant la mise en production, qu’une même adresse demandée par deux personnes différentes ne renvoie pas deux fois la réponse de la première.

À lire aussi

Pages voisines

: les deux pages se citent l’une l’autre.