Lista de comprobación de riesgos de seguridad al procesar JSON
En resumen: JSON.parse en sí es seguro; el peligro está en lo que ocurre después: fusiones profundas, asignación por ruta, incrustación en HTML, profundidad y tamaño sin límites.

Conclusión: cierra primero los dos puntos de entrada: toda fusión profunda y toda asignación por ruta deben rechazar proto, prototype y constructor, y el tamaño del cuerpo de la petición y la profundidad de anidamiento necesitan límites duros que se apliquen antes del análisis. Vigilar únicamente JSON.parse deja abiertas de par en par las puertas peligrosas.
Los atacantes no necesitan inventar una vulnerabilidad nueva; solo añaden una clave más al JSON.
1. Contaminación del prototipo: la más común y la más ignorada
1.1 JSON.parse en sí es seguro
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse crea __proto__ como una propiedad propia ordinaria y no reescribe la cadena de prototipos. Así que la afirmación de que "JSON.parse causa contaminación del prototipo" es inexacta.
1.2 La fusión que viene después es lo peligroso
/**
* Fusión profunda insegura: escribe las claves del objeto fuente directamente en el objeto destino.
* @param target Objeto destino
* @param source Objeto fuente (puede venir de JSON del usuario)
* @returns Objeto fusionado
*/
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 — el sitio entero queda contaminado
constructor.prototype es otro punto de entrada del mismo tipo:
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 Cómo arreglarlo
/** Nombres de clave que deben rechazarse */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Fusión profunda segura: rechaza las claves relacionadas con el prototipo y solo trata propiedades propias.
* @param target Objeto destino
* @param source Objeto fuente
* @returns Nuevo objeto fusionado
*/
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;
}
// Usar defineProperty para que un setter no pueda disparar una mutación del prototipo
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
Un enfoque más exhaustivo es usar Object.create(null) como contenedor (no hay cadena de prototipos que contaminar), o simplemente un Map.
Al usar una biblioteca de terceros, confirma que ya trata estas tres claves; muchas versiones antiguas de lodash merge fueron reportadas exactamente por esto.
1.4 La asignación por ruta es igual de peligrosa
// Antipatrón: la ruta viene de la entrada del usuario
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
Cuando __proto__ aparece en pathSegments, esto equivale a la contaminación anterior. Las implementaciones de JSON Pointer / JSON Patch deben validar cada segmento del token.
2. Ataques de agotamiento de recursos
| Ataque | Táctica | Mitigación |
|---|---|---|
| Anidamiento profundo | Cien mil niveles de [[[[...]]]] |
Limitar la profundidad de anidamiento (normalmente 32-64) y preescanear antes de analizar |
| Arrays desmesurados | Un array con decenas de millones de elementos | Limitar el tamaño del cuerpo de la petición y la longitud del array |
| Cadenas extremadamente largas | Una sola cadena de cientos de MB | Limitar la longitud de los campos |
| Explosión de claves duplicadas | Una enorme cantidad de claves duplicadas | Limitar el número de claves de un objeto |
Las dos defensas más eficaces en el servidor son los límites de tamaño del cuerpo de la petición y los límites de profundidad, y ambas deben surtir efecto antes de que ocurra cualquier análisis.
/**
* Comprueba de forma barata la profundidad de anidamiento antes de analizar para evitar desbordamientos de pila durante el análisis.
* @param text Texto JSON
* @param maxDepth Profundidad máxima permitida
* @returns Si la profundidad está dentro del rango permitido
*/
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. Inyección al incrustar en HTML
Escribir JSON directamente en una página es una práctica común, pero introduce tres clases de problemas:
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
El </script> dentro de la cadena cierra el bloque de script antes de tiempo, y el resto se analiza como HTML: un XSS clásico.
Otros dos caracteres se comportan igual: \u2028 (separador de línea) y \u2029 (separador de párrafo) son legales en JSON, pero incrustarlos en el código fuente JS provoca un error de sintaxis.
Solución:
/**
* Serializa JSON de forma segura para un script incrustado.
* @param value Objeto de datos
* @returns Cadena que se puede colocar de forma segura dentro de una etiqueta script
*/
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');
}
Un enfoque más robusto es colocar los datos dentro de <script type="application/json"> (cuyo contenido no se ejecuta) y luego leerlos con JSON.parse(document.getElementById('data').textContent).
4. Secuestro de JSON y JSONP
Los navegadores antiguos permitían que un <script src="https://api.example.com/user"> de origen cruzado ejecutara directamente el JavaScript devuelto,
y un JSON cuyo nivel superior es un array resulta ser también una expresión JS válida, así que un atacante podía leer los datos sobrescribiendo el constructor Array.
Los navegadores modernos ya tienen protecciones, pero seguimos recomendando:
- Usar un objeto en lugar de un array en el nivel superior de la API (
{"data": [...]}); - Enviar
X-Content-Type-Options: nosniffy establecerContent-Type: application/jsonde forma estricta; - Dejar de usar JSONP; usar CORS cuando de verdad se necesite acceso de origen cruzado.
5. Confusión de tipos al deserializar en objetos
Una vez que se habilita la tipificación por defecto en Jackson (enableDefaultTyping), un atacante puede especificar un campo @class para que el servidor instancie una clase arbitraria,
y combinarlo con una cadena de gadgets explotable para lograr ejecución remota de código. Vulnerabilidades de este tipo han aparecido muchas veces (tanto Fastjson como Jackson se han visto afectados).
Principio: el tipo destino de la deserialización debe estar especificado estáticamente por el código del servidor y nunca debe determinarse por el contenido del payload.
6. Denegación de servicio en expresiones regulares y schemas
- El
patternde JSON Schema y elmatch()de JSONPath aceptan expresiones regulares; - Patrones como
(a+)+$retroceden de forma exponencial con entradas largas; - Si la regex viene de los usuarios (reglas de validación personalizadas), debes limitar su longitud, prohibir las referencias inversas y los cuantificadores anidados, o cambiar a un motor lineal como RE2.
7. Logs y enmascarado
Los logs JSON son la forma más fácil de filtrar información sensible:
/** Nombres de campo que deben enmascararse en los logs (coincidencia en minúsculas) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* Enmascara de forma recursiva los campos sensibles de un objeto para la salida de logs.
* @param value Valor original
* @returns Copia enmascarada
*/
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;
}
Ten en cuenta que el enmascarado debe ocurrir antes de que se escriba el log, no limpiando la plataforma de logs después.
8. Lista de comprobación antes de publicar
- [ ] Todas las funciones de fusión profunda rechazan
__proto__/prototype/constructor; - [ ] Los tokens de ruta de JSON Pointer / Patch se validan contra una lista blanca;
- [ ] El tamaño del cuerpo de la petición y la profundidad de anidamiento tienen límites duros, y esos límites se aplican antes del análisis;
- [ ] El JSON incrustado en HTML escapa
<,>,&,U+2028yU+2029; - [ ] Las API usan un objeto en el nivel superior y las respuestas envían
nosniff; - [ ] Los tipos destino de deserialización están especificados estáticamente en el código;
- [ ] Las expresiones regulares controladas por el usuario tienen límites de complejidad;
- [ ] Los campos de log tienen reglas de enmascarado y esas reglas están probadas.
FAQ
¿Cómo compruebo si mi fusión profunda es vulnerable a la contaminación del prototipo?
Pasa JSON.parse('{"__proto__": {"isAdmin": true}}') a la función de fusión y luego comprueba si {}.isAdmin se volvió true; repite con un payload de constructor.prototype. Con una biblioteca de terceros, confirma que rechaza __proto__, prototype y constructor: muchas versiones antiguas de lodash merge fueron reportadas exactamente por esto. La asignación por ruta necesita su propia comprobación, porque un token __proto__ en la ruta contamina de la misma forma.
¿Por qué JSON.parse en sí no es la fuente de la contaminación del prototipo?
Porque crea __proto__ como una propiedad propia ordinaria y nunca reescribe la cadena de prototipos, así que tanto data.polluted como {}.polluted siguen siendo undefined. La contaminación ocurre después: una fusión profunda insegura escribe las claves del objeto fuente directamente en el destino, y constructor.prototype es un punto de entrada del mismo tipo. Tratar JSON.parse como la causa raíz significa arreglar el lugar equivocado.
¿El JSON incrustado en HTML debería escaparse siempre?
Sí. Un </script> dentro de una cadena cierra el bloque de script antes de tiempo y el resto se analiza como HTML: un XSS clásico; <, >, &, U+2028 y U+2029 también necesitan escapado. Una opción más segura es colocar los datos dentro de <script type="application/json">, cuyo contenido no se ejecuta, y leerlos con JSON.parse sobre textContent.