Liste de vérification des risques de sécurité du traitement JSON
L’essentiel : JSON.parse lui-même est sûr ; le danger est ce qui se passe après : fusion profonde, affectation par chemin, intégration dans du HTML, profondeur et taille illimitées.

L'essentiel : fermez d'abord les deux points d'entrée — chaque fusion profonde et affectation par chemin doit rejeter proto, prototype et constructor, et la taille du corps de requête plus la profondeur d'imbrication ont besoin de limites dures qui s'appliquent avant le parsing. Surveiller JSON.parse lui-même laisse les portes dangereuses grandes ouvertes.
Les attaquants n'ont pas besoin d'inventer une nouvelle vulnérabilité ; ils ajoutent juste une clé de plus au JSON.
1. Pollution de prototype : la plus courante et la plus négligée
1.1 JSON.parse lui-même est sûr
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse crée __proto__ comme une propriété propre ordinaire et ne réécrit pas la chaîne de prototypes. Donc l'affirmation que « JSON.parse provoque une pollution de prototype » est inexacte.
1.2 C'est la fusion qui suit qui est dangereuse
/**
* Unsafe deep merge: writes the source object's keys straight into the target object.
* @param target Target object
* @param source Source object (may come from user JSON)
* @returns Merged object
*/
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] ?? {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true — the entire site is polluted
constructor.prototype est un autre point d'entrée du même type :
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 Comment corriger
/** Key names that must be rejected */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Safe deep merge: rejects prototype-related keys and handles own properties only.
* @param target Target object
* @param source Source object
* @returns New merged object
*/
function safeMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) {
continue;
}
const sourceValue = source[key];
const isPlainObject =
typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue);
if (isPlainObject) {
result[key] = safeMerge(target[key] ?? {}, sourceValue);
continue;
}
// Use defineProperty so a setter cannot trigger prototype mutation
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
Une approche plus complète consiste à utiliser Object.create(null) comme conteneur (il n'y a pas de chaîne de prototypes à polluer), ou simplement une Map.
Lors de l'utilisation d'une bibliothèque prête à l'emploi, confirmez qu'elle gère déjà ces trois clés — de nombreuses versions anciennes de merge de lodash ont été signalées pour exactement cela.
1.4 L'affectation par chemin est tout aussi dangereuse
// Anti-pattern: the path comes from user input
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
Quand __proto__ apparaît dans pathSegments, cela équivaut à la pollution ci-dessus. Les implémentations de JSON Pointer / JSON Patch doivent valider chaque segment de jeton.
2. Attaques par épuisement de ressources
| Attaque | Tactique | Atténuation |
|---|---|---|
| Imbrication profonde | Cent mille niveaux de [[[[...]]]] |
Limiter la profondeur d'imbrication (généralement 32–64) et pré-scanner avant le parsing |
| Tableaux surdimensionnés | Un tableau de dizaines de millions d'éléments | Limiter la taille du corps de requête et la longueur des tableaux |
| Chaînes extrêmement longues | Une seule chaîne de centaines de Mo | Limiter la longueur des champs |
| Explosion de clés dupliquées | Un nombre énorme de clés dupliquées | Limiter le nombre de clés d'objet |
Les deux défenses les plus efficaces côté serveur sont les limites de taille du corps de requête et les limites de profondeur, et les deux doivent prendre effet avant tout parsing.
/**
* Cheaply checks nesting depth before parsing to prevent stack overflow during parsing.
* @param text JSON text
* @param maxDepth Maximum allowed depth
* @returns Whether the depth is within the allowed range
*/
function isDepthAcceptable(text: string, maxDepth = 64): boolean {
let currentDepth = 0;
let isInsideString = false;
let isEscaped = false;
for (const character of text) {
if (isInsideString) {
if (isEscaped) {
isEscaped = false;
} else if (character === '\\') {
isEscaped = true;
} else if (character === '"') {
isInsideString = false;
}
continue;
}
if (character === '"') {
isInsideString = true;
} else if (character === '{' || character === '[') {
currentDepth += 1;
if (currentDepth > maxDepth) {
return false;
}
} else if (character === '}' || character === ']') {
currentDepth -= 1;
}
}
return true;
}
3. Injection lors de l'intégration dans du HTML
Écrire directement du JSON dans une page est une pratique courante, mais elle introduit trois classes de problèmes :
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
Le </script> à l'intérieur de la chaîne ferme le bloc de script trop tôt, et le reste est parsé comme du HTML — un XSS classique.
Deux autres caractères se comportent de la même manière : \u2028 (séparateur de ligne) et \u2029 (séparateur de paragraphe) sont légaux en JSON, mais les incorporer dans du code source JS déclenche une erreur de syntaxe.
Correction :
/**
* Serializes JSON safely for an inline script.
* @param value Data object
* @returns String that can be safely placed inside a script tag
*/
function serializeForInlineScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
Une approche plus robuste consiste à placer les données dans <script type="application/json"> (dont le contenu n'est pas exécuté), puis à les lire avec JSON.parse(document.getElementById('data').textContent).
4. Détournement JSON et JSONP
Les anciens navigateurs permettaient à un <script src="https://api.example.com/user"> inter-origine d'exécuter directement le JavaScript renvoyé,
et un JSON dont le niveau supérieur est un tableau se trouve aussi être une expression JS valide, donc un attaquant pouvait lire les données en surchargeant le constructeur Array.
Les navigateurs modernes ont déjà des protections, mais nous recommandons quand même :
- Utiliser un objet plutôt qu'un tableau au niveau supérieur de l'API (
{"data": [...]}) ; - Envoyer
X-Content-Type-Options: nosniffet définir strictementContent-Type: application/json; - Arrêter JSONP ; utilisez CORS lorsque l'accès inter-origine est réellement nécessaire.
5. Confusion de types lors de la désérialisation en objets
Une fois la typisation par défaut activée dans Jackson (enableDefaultTyping), un attaquant peut spécifier un champ @class pour faire instancier au serveur une classe arbitraire,
et le combiner avec une chaîne de gadgets exploitable pour obtenir une exécution de code à distance. Des vulnérabilités de ce type sont apparues plusieurs fois (Fastjson et Jackson ont tous deux été touchés).
Principe : le type cible de la désérialisation doit être spécifié statiquement par le code serveur et ne doit jamais être déterminé par le contenu de la charge utile.
6. Déni de service dans les expressions régulières et les schémas
- Le
patternde JSON Schema etmatch()de JSONPath acceptent tous deux des expressions régulières ; - Des motifs comme
(a+)+$font un retour arrière exponentiel sur de longues entrées ; - Si l'expression régulière vient des utilisateurs (règles de validation personnalisées), vous devez limiter sa longueur, interdire les références arrière et les quantificateurs imbriqués, ou passer à un moteur linéaire comme RE2.
7. Journalisation et masquage
Les journaux JSON sont la façon la plus facile de faire fuiter des informations sensibles :
/** Field names that must be masked in logs (matched in lowercase) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* Recursively masks sensitive fields in an object for log output.
* @param value Original value
* @returns Masked copy
*/
function maskSensitive(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => maskSensitive(item));
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => {
if (SENSITIVE_KEYS.has(entryKey.toLowerCase())) {
return [entryKey, '***'];
}
return [entryKey, maskSensitive(entryValue)];
}),
);
}
return value;
}
Notez que le masquage doit se produire avant l'écriture du journal, pas en nettoyant la plateforme de journalisation après coup.
8. Liste de vérification avant lancement
- [ ] Toutes les fonctions de fusion profonde rejettent
__proto__/prototype/constructor; - [ ] Les jetons de chemin de JSON Pointer / Patch sont validés contre une liste blanche ;
- [ ] La taille du corps de requête et la profondeur d'imbrication ont des limites dures, et ces limites s'appliquent avant le parsing ;
- [ ] Le JSON intégré dans du HTML échappe
<,>,&,U+2028etU+2029; - [ ] Les API utilisent un objet au niveau supérieur et les réponses envoient
nosniff; - [ ] Les types cibles de désérialisation sont spécifiés statiquement dans le code ;
- [ ] Les expressions régulières contrôlées par l'utilisateur ont des limites de complexité ;
- [ ] Les champs de journal ont des règles de masquage et ces règles sont testées.
FAQ
Comment vérifier si ma fusion profonde est vulnérable à la pollution de prototype ?
Passez JSON.parse('{"__proto__": {"isAdmin": true}}') dans la fonction de fusion, puis vérifiez si {}.isAdmin est devenu true ; répétez avec une charge utile constructor.prototype. Avec une bibliothèque tierce, confirmez qu'elle rejette __proto__, prototype et constructor — de nombreuses versions anciennes de merge de lodash ont été signalées pour exactement cela. L'affectation par chemin a besoin de sa propre vérification, car un jeton __proto__ dans le chemin pollue de la même manière.
Pourquoi JSON.parse n'est-il pas lui-même la source de la pollution de prototype ?
Il crée __proto__ comme une propriété propre ordinaire et ne réécrit jamais la chaîne de prototypes, donc data.polluted et {}.polluted restent undefined. La pollution se produit après : une fusion profonde non sûre écrit les clés de l'objet source directement dans la cible, et constructor.prototype est un point d'entrée du même type. Traiter JSON.parse comme la cause racine revient à corriger le mauvais endroit.
Faut-il toujours échapper le JSON intégré dans du HTML ?
Oui. Un </script> dans une chaîne ferme le bloc de script trop tôt et le reste est parsé comme du HTML — un XSS classique ; <, >, &, U+2028 et U+2029 doivent aussi être échappés. Une option plus sûre est de placer les données dans <script type="application/json">, dont le contenu n'est pas exécuté, et de les lire avec JSON.parse sur textContent.