CSRF contre les APIs JSON en exploitant les différences d'analyse de Content-Type ou les mauvaises configurations CORS permettant la soumission JSON cross-origin.
TL;DR
text/plain contourne le preflight CORSenctype="text/plain" dans les formulaires HTML délivre des corps JSON valides — aucun JavaScript ni en-têtes personnalisés requisapplication/x-www-form-urlencoded, multipart/form-dataContent-Type: application/json côté serveur — rejeter text/plain et les types encodés en formulaire sur les endpoints APILe CSRF JSON (CWE-352) attaque les APIs REST et GraphQL qui utilisent application/json mais n'imposent pas strictement le Content-Type sur les requêtes entrantes. L'hypothèse répandue est que les APIs JSON sont immunisées contre le CSRF parce que application/json déclenche une requête OPTIONS de preflight CORS, que le navigateur exige que le serveur approuve avant d'envoyer la requête réelle. Sans approbation du serveur, le navigateur bloque le POST cross-origin.
L'hypothèse s'effondre parce que les preflights CORS sont déclenchés par l'en-tête Content-Type, pas par le format du corps. Un formulaire HTML avec enctype="text/plain" envoie Content-Type: text/plain — un type CORS simple qui ne nécessite aucun preflight. Si l'attaquant construit les champs de saisie du formulaire de sorte que le corps POST assemblé soit du JSON valide, le serveur reçoit un payload JSON bien formé sans preflight CORS et sans blocage imposé par le navigateur. La réussite de l'attaque dépend entièrement du fait que le serveur valide ou non le Content-Type avant d'analyser le corps.
Ce n'est pas un risque théorique. HackerOne #245346 le démontre contre l'API WakaTime en production. CVE-2022-41919 (Fastify) et CVE-2024-4994 (GitLab) le documentent dans des frameworks majeurs. CVE-2025-68604 (WPGraphQL, publié mai 2026) montre que la vulnérabilité persiste dans des logiciels activement maintenus des années après la publication de la technique.
Le contournement text/plain fonctionne parce que les formulaires HTML supportent exactement trois valeurs enctype : application/x-www-form-urlencoded (défaut), multipart/form-data et text/plain. Les trois sont des types de requête simples CORS ne nécessitant aucun preflight. Le corps assemblé par le navigateur depuis un formulaire text/plain est :
<nom_input>= <valeur_input>L'attaquant conçoit le nom et la valeur pour produire du JSON valide quand ils sont concaténés :
La construction du corps JSON text/plain :
<!-- L'astuce du formulaire : nom + "=" + valeur = corps JSON valide -->
<form method="POST" action="https://api.target.com/v1/virement"
enctype="text/plain">
<!--
Le navigateur assemble le corps : {"vers":"attaquant","montant":5000,"x":"="}
nom = '{"vers":"attaquant","montant":5000,"x":"'
valeur = '"}'
résultat = nom + "=" + valeur = {"vers":"attaquant","montant":5000,"x":"="}
La plupart des parseurs JSON ignorent la clé parasite finale "x": "="
-->
<input name='{"vers":"attaquant","montant":5000,"x":"' value='"}'>
</form>
<script>document.forms[0].submit();</script>Le corps POST assemblé :
{"vers":"attaquant","montant":5000,"x":"="}C'est du JSON valide. La clé parasite finale "x":"=" est une clé de décharge inoffensive que la plupart des parseurs ignorent. L'attaque requiert : (1) le cookie de session a SameSite=None ou pas d'attribut SameSite, (2) le serveur analyse le corps JSON indépendamment du Content-Type, et (3) aucun en-tête personnalisé (ex. X-CSRF-Token) n'est requis.
Trois chemins d'attaque CSRF JSON :
# Chemin 1 : soumission de formulaire text/plain (le plus fiable)
# - Aucun preflight (text/plain est simple)
# - Le corps est du JSON valide
# - Le serveur ignore le Content-Type et analyse le JSON
# Chemin 2 : corps application/x-www-form-urlencoded analysé comme JSON
# (Express bodyParser.urlencoded + route express-json-body)
# Corps POST : email=attaquant%40evil.com
# Si serveur fait : body = JSON.parse(req.body.toString()) -> échoue
# Si serveur fait : req.body["email"] -> réussit via bodyParser.urlencoded
# Chemin 3 : mauvaise configuration CORS permet application/json
# Si Access-Control-Allow-Origin: * ET Access-Control-Allow-Credentials: true
# (invalide selon la spec mais certains frameworks le permettent)
# fetch('/api/virement', {method:'POST', body: JSON.stringify({...}), credentials:'include'})| Variante | Mécanisme | Conditions | CVE/Référence |
|---|---|---|---|
Corps de formulaire text/plain | Le formulaire délivre un corps en forme JSON | SameSite=None + serveur ignore le Content-Type | H1 #245346, CVE-2024-24816 |
| Mutation GraphQL GET | Balise img pointant vers /graphql?query=mutation{...} | Le serveur accepte les mutations via GET | H1 #1122408 (GitLab, 3 370 $) |
| GraphQL encodé en formulaire | Champ name="query" avec valeur de mutation | Apollo < 4 accepte urlencoded | CVE-2024-4994, CVE-2025-68604 |
| Content-Type Fastify | Mauvais Content-Type accepté par le framework | Fastify ≤ 4.10.1 bug d'analyse | CVE-2022-41919 |
| CORS wildcard + credentials | Access-Control-Allow-Origin: * + cookies | Le serveur configure mal CORS | CORS CSRF générique |
Coercition multipart/form-data | Données multipart analysées comme champs JSON | Le framework auto-analyse multipart | Spécifique API, aucun preflight |
Payloads CSRF GraphQL :
<!-- Mutation GraphQL GET — zéro clic, aucun JavaScript, aucun preflight -->
<!-- Les cookies SameSite=Lax ne sont PAS envoyés pour <img> sous-ressource -->
<!-- Utiliser pour SameSite=None ou cookies sans attribut -->
<img src="https://api.target.com/graphql?query=mutation%7BdeleteAccount(id:123)%7Bstatus%7D%7D">
<!-- Mutation GraphQL encodée en formulaire — POST formulaire, aucun preflight -->
<form action="https://api.target.com/graphql" method="POST">
<input name="query" value='mutation{updateEmail(email:"evil@attaquant.com"){id}}'>
</form>
<script>document.forms[0].submit();</script>CVE-2024-4994 — GitLab CE/EE GraphQL (CVSS 8.1, Élevé, juin 2024) : CSRF sur /api/graphql permettait à des attaquants non authentifiés d'exécuter des mutations GraphQL arbitraires au nom de victimes authentifiées. L'endpoint GraphQL de GitLab acceptait des mutations via des Content-Types ne déclenchant pas de preflight CORS — spécifiquement application/x-www-form-urlencoded et multipart/form-data. Impact élevé sur la confidentialité et l'intégrité (C:H/I:H). Versions affectées : GitLab 16.1.0–16.11.4, 17.0.0–17.0.2, 17.1.0. HackerOne #1122408 (prime de 3 370 $) a documenté le vecteur de mutation via requête GET sur le même endpoint.
CVE-2022-41919 — Fastify Content-Type Parsing (CVSS 4.2, Modéré) : Fastify 4.x ≤ 4.10.1 et 3.x ≤ 3.29.3 validait incorrectement le Content-Type sur les requêtes entrantes. Les attaquants envoyaient des requêtes application/x-www-form-urlencoded, multipart/form-data ou text/plain qui contournaient le preflight CORS et invoquaient des endpoints API JSON uniquement sans validation de token. Découvert par Ry0taK (HackerOne). Corrigé dans Fastify 4.10.2 et 3.29.4.
CVE-2025-68604 — WPGraphQL (CVSS 5.4, mai 2026) : WPGraphQL ≤ 2.5.3 permettait aux attaquants de créer une page distante qui amenait le navigateur authentifié de la victime à soumettre des requêtes vers l'endpoint GraphQL WordPress, exécutant des mutations : création de comptes utilisateurs non autorisés, modification de contenu, changement d'options de plugin, élévation de privilèges. Corrigé dans WPGraphQL 2.5.4+.
HackerOne #245346 — WakaTime CSRF JSON (500 $) : Le contournement par enctype text/plain contre l'endpoint POST /heartbeats de WakaTime. Le serveur analysait le corps text/plain comme JSON sans vérifier le Content-Type, l'acceptait sans token CSRF et enregistrait les données de heartbeat. C'est une démonstration canonique du schéma d'attaque de corps JSON text/plain contre un outil de productivité pour développeurs largement utilisé.
CVE-2024-24816 — CKEditor 5 Upload Adapter (CVSS 6.1) : CSRF via Content-Type text/plain sur l'endpoint d'adaptateur d'upload de CKEditor. Le serveur lisait le corps brut et l'analysait comme JSON indépendamment du Content-Type, et aucun preflight CORS n'était déclenché. Les uploads de fichiers contrôlés par l'attaquant s'exécutaient dans le contexte de la victime.
Content-Type: application/json en Content-Type: text/plain. Renvoyer la requête. Si le serveur répond avec succès (2xx), il accepte les corps text/plain.Origin: https://evil.com à la requête. Vérifier la réponse pour Access-Control-Allow-Origin: * ou Access-Control-Allow-Origin: https://evil.com. Combiné avec l'étape 2, cela confirme l'exploitabilité cross-origin.SameSite du cookie de session. Si SameSite=None ou absent, le cookie est envoyé en cross-origin et l'attaque est entièrement exploitable.?query=mutation{...}). Si le serveur retourne 200 avec des données, les mutations GET sont activées et directement exploitables par CSRF via balise <img> (sans preflight).application/x-www-form-urlencoded : soumettre query=mutation{...} comme champ de formulaire standard. De nombreuses implémentations GraphQL acceptent ce format.text/plain ciblant l'opération modifiant l'état la plus impactante et vérifier qu'il s'exécute dans un navigateur avec une session victime.La règle de scan actif 20012 d'OWASP ZAP identifie certains scénarios de CSRF JSON. Le scanner CSRF de Burp Suite Pro ne teste pas automatiquement les dégradations de Content-Type — les tests manuels en Repeater sont requis. BreachVex sonde chaque endpoint POST/PUT/DELETE dans le périmètre avec un Content-Type text/plain et Origin: https://evil.com, confirmant l'exploitabilité en vérifiant à la fois le code de statut HTTP et les en-têtes de réponse CORS contre l'origine hostile.
La cause racine est que le serveur accepte et analyse les corps indépendamment du Content-Type. Imposer une validation stricte du Content-Type avant toute analyse du corps.
# FastAPI — imposition stricte du Content-Type
from fastapi import Request, HTTPException
async def enforce_json_content_type(request: Request):
content_type = request.headers.get("Content-Type", "")
if request.method not in ("GET", "HEAD", "OPTIONS"):
if not content_type.startswith("application/json"):
raise HTTPException(
status_code=415,
detail="Type de média non supporté. L'API requiert application/json."
)// Express.js — rejeter les Content-Types non JSON sur les routes API
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const ct = req.headers['content-type'] || '';
if (!ct.includes('application/json')) {
return res.status(415).json({ error: "L'API requiert Content-Type: application/json" });
}
}
next();
});// Apollo Server 3.7+ — imposer l'en-tête de prévention CSRF (Apollo-Require-Preflight)
// Cela fait rejeter les requêtes sans preflight au niveau de la couche GraphQL
const server = new ApolloServer({
typeDefs,
resolvers,
csrfPrevention: true, // Vrai par défaut dans Apollo Server 3.7+
// csrfPrevention envoie 400 pour les requêtes sans en-tête non simple
});Pour les architectures sans état où l'état de session est indisponible côté serveur, utiliser un schéma de double-submit signé HMAC :
import hmac, hashlib, secrets, base64
SECRET_KEY = os.environ["CSRF_SECRET_KEY"]
def generate_csrf_cookie(session_id: str) -> str:
nonce = secrets.token_urlsafe(16)
mac = hmac.new(SECRET_KEY.encode(), f"{session_id}:{nonce}".encode(), hashlib.sha256)
return base64.urlsafe_b64encode(mac.digest() + nonce.encode()).decode()
def validate_csrf_double_submit(session_id: str, cookie_val: str, header_val: str) -> bool:
if not cookie_val or not header_val or cookie_val != header_val:
return False
# Vérifier HMAC — prévient l'injection de cookie par sous-domaine
try:
raw = base64.urlsafe_b64decode(header_val)
mac_bytes, nonce = raw[:32], raw[32:].decode()
expected = hmac.new(SECRET_KEY.encode(), f"{session_id}:{nonce}".encode(), hashlib.sha256)
return hmac.compare_digest(expected.digest(), mac_bytes)
except Exception:
return FalseUn double cookie soumis naïf sans liaison HMAC est vulnérable à l'injection par sous-domaine. Si un attaquant contrôle sub.target.com, il peut définir un cookie avec domain=.target.com correspondant à la valeur du paramètre qu'il soumet. Seuls les tokens liés par HMAC préviennent cette chaîne d'attaque.
Non. On suppose souvent que les APIs JSON sont immunisées contre le CSRF parce que application/json déclenche un preflight CORS. Cependant, les attaquants contournent cela en utilisant l'astuce enctype text/plain : un formulaire HTML avec enctype='text/plain' envoie un POST avec un corps en forme JSON sans déclencher de preflight CORS. Si le serveur lit le corps brut comme JSON indépendamment du Content-Type, l'attaque réussit.
Un formulaire HTML avec enctype='text/plain' envoie Content-Type: text/plain — une requête simple CORS ne nécessitant aucun preflight. En construisant les champs name et value du formulaire de sorte que le corps assemblé soit du JSON valide (ex. name='{"email":"x@evil.com","x":"' value='"}'), l'attaquant délivre un corps JSON sans déclencher le preflight CORS qui bloquerait application/json.
CVE-2022-41919 (Fastify, CVSS 4.2) — une validation incorrecte du Content-Type permettait aux attaquants d'envoyer des requêtes application/x-www-form-urlencoded ou text/plain pour invoquer des endpoints API JSON uniquement, contournant le preflight CORS. CVE-2024-4994 (GitLab GraphQL, CVSS 8.1) — CSRF sur l'endpoint /api/graphql exécutant des mutations GraphQL arbitraires via des Content-Types ne déclenchant pas de preflight CORS.
Le CSRF GraphQL exploite le fait que l'endpoint unique /graphql de GraphQL accepte des mutations via des requêtes GET (chaîne de requête) et via application/x-www-form-urlencoded ou multipart/form-data — toutes des requêtes simples CORS. Un attaquant soumet des mutations via un formulaire HTML standard ou une balise <img> pointant vers une URL de mutation GET, contournant entièrement le preflight CORS.
Apollo Server moderne (v3.7+ / Apollo Router) active la prévention CSRF par défaut via l'exigence d'en-tête Apollo-Require-Preflight. Les versions anciennes et les configurations personnalisées peuvent ne pas le faire. CVE-2025-68604 (WPGraphQL ≤ 2.5.3, CVSS 5.4) démontre un endpoint GraphQL encore vulnérable au CSRF en 2026.
Intercepter une requête d'API JSON dans Burp. Dans Repeater, changer Content-Type de application/json en text/plain et renvoyer. Si le serveur répond avec 200/201/204, il accepte text/plain et analyse probablement le corps comme JSON indépendamment du Content-Type. Ensuite, créer un formulaire HTML PoC avec enctype='text/plain' pour confirmer l'exploitabilité cross-origin dans un navigateur.
HackerOne #245346 est un rapport CSRF JSON public contre l'endpoint POST /heartbeats de WakaTime. L'attaquant a envoyé une requête POST avec enctype='text/plain' et un corps en forme JSON. Le serveur de WakaTime analysait le corps text/plain comme JSON, l'acceptait sans token CSRF et enregistrait le heartbeat — démontrant que des outils de développement largement déployés étaient vulnérables à ce contournement.
BreachVex identifie les cookies avec SameSite=None ou absent, trouve les endpoints POST/PUT/DELETE dans le périmètre, renvoie avec Content-Type: text/plain et Origin: https://evil.com, et confirme si la réponse est 200/201/204 avec Access-Control-Allow-Origin reflétant evil.com ou *. Les endpoints retournant du succès sans token CSRF sont rapportés comme des findings CSRF JSON.