
← Volver al blog
Cómo XSS llega al DOM, qué APIs del navegador son sumideros y mitigaciones que se mantienen en producción: saneamiento, CSP, cookies y emparejamiento CSRF.
Si una cadena se interpreta como markup o script en lugar de texto inerte, ese contenido se ejecuta como código en el origen de tu página — con los mismos privilegios que tu paquete. Eso es XSS: no un error de estilo, un delegado confundido entre datos e instrucciones. Esta publicación es la columna vertebral técnica — mecanismos de ataque, sumideros, soluciones que escalan, más una demostración ejecutable de frontend-xss-demo (Vite + React en main; en vivo: xss.lucascoliveira.com) y punteros hacia este repositorio de Next.js donde los encabezados y MDX reducen el riesgo predeterminado.
Los scripts inyectados en tu página se ejecutan con el mismo origen que tu paquete. En la práctica, eso a menudo significa:
document.cookie para cookies no marcadas como HttpOnly ( robo de sesión / fijación de encadenamiento).fetch() / XMLHttpRequest a tu API con credenciales ambientales si se envían cookies y CORS lo permite — o exfiltración de tokens almacenados en localStorage / sessionStorage si tu aplicación los puso allí.Entonces, la mitigación no es "usar React" — es nunca asignar bytes controlados por el atacante a un sumidero que parsea HTML o ejecuta script a menos que una canalización revisada los haya reducido a un tipo seguro primero.
XSS almacenado — HTML no confiable (o una carga útil que se convierte en HTML después de la expansión de plantilla) se persiste (DB, caché, índice de búsqueda). Cada usuario que carga ese registro golpea el sumidero. Impacto en el frontend: la primera representación que hace innerHTML = row.body o equivalente sin saneamiento ejecuta la carga útil.
XSS reflejado — La carga útil nunca persiste; rebota del servidor o capa de enrutamiento hacia la respuesta. Clásico: ?q=<script>…</script> reflejado en el HTML sin codificar. Equivalente de SPA: location.search o hash analizado en el lado del cliente y escrito en el DOM sin codificar. La solución es la misma: tratar cadenas derivadas de URL como datos; si debes reflejarlas, codificar para el contexto (ver más abajo).
XSS basado en DOM — La respuesta del servidor es "limpia", pero el script del lado del cliente lee la entrada controlada por el atacante (location, referrer, postMessage, mensajes de WebSocket) y la pasa a un sumidero. Ejemplo: eval("handle" + location.hash.slice(1)) o element.innerHTML = decodeURIComponent(...). El análisis estático de plantillas no es suficiente; necesitas auditar cada ruta desde la entrada no confiable hasta el sumidero.
Estos son los culpables habituales en bases de código React/SPA:
| Sumidero | Riesgo |
|---|---|
element.innerHTML, insertAdjacentHTML | Parsea HTML; cualquier etiqueta/manejador de eventos que permitas puede ejecutar script. |
dangerouslySetInnerHTML | Igual que arriba — React no saneamiento. |
document.write | Igual. |
eval, new Function, setTimeout(string) | Ejecución directa de script. |
URLs javascript: en href / src | Navegación o carga de recursos que se ejecutan como URL de script. |
Manejadores postMessage que eval o establecen HTML a partir de event.data | XSS si origin no se valida o event.data llega a un sumidero sin un contrato seguro — no solo errores de "ventana incorrecta". |
No un sumidero por defecto: textContent, createTextNode, texto de hijo normal de React, atributos que React trata como cadenas cuando no los anulas. Tuberías de Markdown se convierten en sumideros cuando emiten HTML crudo y asignas ese HTML al DOM sin saneamiento.
Nota de demostración (importante): En HTML5, nodos <script> insertados a través de innerHTML / dangerouslySetInnerHTML no se ejecutan — el parser no los ejecuta como podría hacerlo un XSS reflejado clásico. Para ver la ejecución cuando se inyecta HTML, las cargas útiles suelen depender de manejadores de atributos (por ejemplo, onerror en img) o similares. El documento insecure-patterns en el repositorio de demostración explica esto para que puedas probar la aplicación con ejemplos que realmente se activan después de la inserción.
Si la UI solo necesita texto plano — Enlaza texto con textContent, hijos de texto de React o MDX que se compila en componentes sin una canalización de HTML. No se requiere saneador; no estás en el juego de HTML.
Si necesitas texto enriquecido (negrita, listas, enlaces) — Necesitas o un lenguaje de marcado restringido compilado en elementos seguros o saneamiento de HTML con una lista de permitidos (etiquetas + atributos). Codificación (por ejemplo, escape de entidades HTML) es para poner datos en nodos de texto HTML; saneamiento es para cuando debes permitir un subconjunto de HTML. No confundas los dos.
Defensa en profundidad para texto enriquecido en productos reales — Valida/saneamiento al escribir (la API rechaza etiquetas desconocidas, límites de longitud) y saneamiento o renderizado a través de una ruta segura al leer (capa de renderizado). El almacenamiento se puede revertir, corromper o escribir mediante otra versión de servicio.
DOMPurify es un saneador de navegador con un perfil predeterminado; todavía lo configuras para tu producto:
ALLOWED_TAGS / ALLOWED_ATTR — Comienza minimalista (p, br, strong, em, a con href solo si necesitas enlaces). Cada etiqueta extra es una superficie de ataque.ADD_ATTR / FORBID_TAGS — Explícito vence a "permitir casi todo".RETURN_DOM / RETURN_TRUSTED_TYPE — Prefiere nodos DOM o salida de estilo TrustedHTML si te integras con Tipos de Confianza.afterSanitizeAttributes — Elimina valores href que comienzan con javascript: o tipos MIME data: extraños si permites enlaces.En frontend-xss-demo en main, la ruta segura (/secure) ejecuta texto de tareas a través de DOMPurify antes de dangerouslySetInnerHTML, la ruta insegura (/insecure) no — misma UI, política de confianza diferente. Portugués /seguro y /inseguro todavía existen como alias heredados y redireccionan a /secure y /insecure.
CSP reduce lo que puede ejecutarse cuando algo se filtra. En apps/web/next.config.ts este sitio establece un CSP con default-src 'self', object-src 'none' estricto, base-uri 'self', form-action 'self', frame-ancestors 'none', más script-src / style-src con 'unsafe-inline' porque Next.js App Router + MUI sx actualmente necesitan script/estilo en línea en esta configuración — documentado en el código. Nonce- o hash-based script-src eliminaría la amplia concesión de script en línea pero requiere middleware para inyectar nonces por solicitud — vale la pena planificar si envías HTML de usuario con poca frecuencia.
Revisión de la realidad: CSP no reemplaza el saneamiento para HTML de usuario; reduce el radio de explosión (por ejemplo, puede bloquear hosts de script que no permitiste). Los manejadores de eventos en línea (onerror, etc.) no se neutralizan automáticamente solo porque estableces un CSP — 'unsafe-inline' en script-src es común en aplicaciones reales (incluyendo la configuración de Next/MUI de este sitio), y bloquear manejadores suele requerir script-src / script-src-attr explícito (o nonces/hashes), dependiendo del navegador y el nivel de CSP.
XSS puede eludir tokens CSRF si el token es legible desde el DOM o si el script del atacante emite solicitudes con credenciales. Entonces: prioriza las correcciones de XSS; también:
HttpOnly, Secure, SameSite=Lax o Strict donde los flujos lo permiten — reduce la fuga de cookies entre sitios y CSRF clásico.SameSite, tokens anti-CSRF, o encabezados personalizados + política CORS para que sitios aleatorios no puedan POSTEAR solicitudes con credenciales.El trabajo del frontend: no pongas secretos en almacenamiento JS-legible si es evitable; usa fetch con política credentials explícita alineada con tu diseño de API.
apps/web/next.config.ts: encabezados de seguridad en /(.*); Cadena CSP construida en código con script-src específico del entorno (desarrollo unsafe-eval para pilas de React solo donde se necesita).apps/web/app/api/chat/route.ts: JSON parse, verificación vacía, límite MAX_MESSAGE_LENGTH — modelado de abuso, no XSS por sí mismo.apps/web/lib/blog/mdx.tsx: MDX con un mapa de componentes fijo (next-mdx-remote/rsc), no cadenas de HTML crudo del CMS. Modelo de amenaza diferente al de "cuerpo de mensaje con HTML".mainClona frontend-xss-demo, ejecuta npm install y npm run dev → Vite sirve la aplicación en http://localhost:5173.
/insecure con cargas útiles de los documentos; el mismo flujo en /secure muestra salida saneada.docs/xss/: una visión general, tres recorridos de impacto (acciones sin interacción del usuario, phishing interno, secuestro de sesión / almacenamiento), más patrones inseguros y patrones seguros para hacer y no hacer a nivel de código.localStorage (ver 03-session-hijacking) para que puedas ver qué script en la página puede leer; patrones inseguros también sugiere localStorage.getItem('auth_token') en DevTools para inspeccionarlo.Compara /insecure vs /secure en Elementos y Consola: mismos componentes, manejo diferente de la cadena antes de que golpee el DOM.
rg "dangerouslySetInnerHTML|innerHTML|insertAdjacentHTML|eval\\(|new Function" en tu aplicación y dependencias.<img src=x onerror=...>, URLs javascript: — y recuerda que innerHTML no ejecuta <script> como muchas hojas de trucos implican. Para demostraciones de inyección de estilo innerHTML, prefiere onerror en img (o similar); <svg onload> a menudo es no confiable cuando se inserta de esa manera.SameSite, credenciales y estrategia CSRF con backend; asume que XSS y CSRF se encadenan.XSS es control de flujo: datos que cruzan hacia la interpretación. La protección es tipado en el límite: texto plano, componentes estructurados seguros o HTML saneado con una lista de permitidos mínima — más CSP y semántica de cookies que limitan lo que un script errante aún puede hacer. La demostración en main hace que ese límite sea visible: /insecure vs /secure, impactos documentados bajo docs/xss/, y disciplina en cada PR que toca cadenas cerca del DOM.