Cómo ocurre el XSS a través de JSON
En resumen: JSON nunca se ejecuta por sí solo. El XSS ocurre en el momento en que lo tratas como HTML: concatenación de cadenas en el servidor, innerHTML en el frontend o incrustar JSON directamente en una etiqueta script.

En resumen: JSON nunca se ejecuta por sí solo: cierra primero las tres vías de salida: escapa en el servidor según el contexto, usa textContent en lugar de innerHTML y sustituye < por \u003c al incrustar JSON.
{"name": "<img src=x onerror=alert(1)>"} son datos válidos. Que sea peligroso depende enteramente de dónde lo pongas.
Entrada uno: construcción de cadenas en el servidor
res.send(`<div>Hello, ${data.name}</div>`);
El navegador analiza las etiquetas dentro de data.name. Aquí JSON es solo el contenedor; la culpa es escapar para el contexto equivocado.
Corrección: usa el escape predeterminado de un motor de plantillas (<%= %> en EJS, {{ }} en Vue y Django). Evita <%- %>, v-html y |safe para cualquier cosa controlada por el usuario.
Entrada dos: innerHTML
el.innerHTML = user.bio; // parses markup
el.textContent = user.bio; // inserts text
{user.bio} de React y {{ user.bio }} de Vue crean nodos de texto y son seguros por defecto. dangerouslySetInnerHTML y v-html son renuncias explícitas.
Si realmente necesitas texto enriquecido, sanéalo con una lista de permitidos (DOMPurify y similares) en lugar de confiar en el backend.
Entrada tres: incrustar en una etiqueta script
<script>
window.__DATA__ = {"name": "x</script><script>alert(1)</script>"};
</script>
Un </script> dentro de los datos cierra el bloque antes de tiempo y todo lo que va después se ejecuta como un nuevo script. Este es el caso que más se pasa por alto.
JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
Sigue siendo JSON válido, idéntico después de analizarlo, pero ya no puede cerrar la etiqueta. Next.js y Nuxt hacen lo mismo para __NEXT_DATA__ y __NUXT__.
Dos vecinos que conviene conocer
JSON hijacking: los navegadores antiguos ejecutaban JSON cargado mediante <script src>; redefinir Array filtraba el contenido. Los navegadores modernos lo corrigieron, y Content-Type: application/json más X-Content-Type-Options: nosniff cierran el resto.
Restos de JSONP: un nombre de callback controlado por el usuario es ejecución de código arbitrario por diseño. No lo uses en sistemas nuevos.
Lista de comprobación
- Cada lugar donde un campo JSON llega a HTML se escapa para su contexto (HTML, atributo, JS, URL)
- Ningún
innerHTML,v-htmlodangerouslySetInnerHTMLconsume cadenas de API - El JSON incrustado escapa
<,>y& - El texto enriquecido pasa por saneamiento con lista de permitidos, no por filtrado de lista de bloqueo
- Las respuestas llevan
Content-Type: application/jsonynosniff
FAQ
¿Por qué un JSON válido aún puede desencadenar XSS?
El JSON solo es texto y nunca se ejecuta; que sea válido solo significa que se analiza. El peligro aparece cuando se escribe en un contexto HTML o de script: el navegador analiza las etiquetas, o un </script> cierra el bloque antes de tiempo y el resto se ejecuta como código. La culpa es la falta de escape adaptado al contexto en la salida, no el formato de datos.
¿Debería usar textContent o innerHTML para renderizar campos de API?
Prefiere textContent: inserta el valor como texto plano, así que las etiquetas se muestran literalmente. innerHTML analiza el marcado, así que un campo de API que contenga <img onerror=...> se ejecuta. {user.bio} de React y {{ user.bio }} de Vue crean nodos de texto por defecto; dangerouslySetInnerHTML y v-html son renuncias explícitas.
¿Cómo debo escapar el JSON incrustado en una etiqueta <script>?
Sí. Un </script> dentro de los datos cierra el bloque antes de tiempo y todo lo que va después se ejecuta como un nuevo script. Después de serializar, sustituye <, > y & por \u003c, \u003e y \u0026: el resultado sigue siendo JSON válido, se analiza de forma idéntica y ya no puede cerrar la etiqueta.