JSON Toolkit

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.

Intermedio~4 min de lecturaPublicado el 2026-10-30Actualizado el 2026-10-30
Tres formas en que JSON se convierte en XSS y cómo prevenirlas

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-html o dangerouslySetInnerHTML consume 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/json y nosniff

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.

Lectura adicional