
← Volver al blog
Cómo XSS llega al DOM, qué APIs del navegador son puntos de fuga 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 —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 — mecánicas de ataque, puntos de fuga, soluciones que escalan, más una demostración ejecutable playgrounds (Vite + React en main; en vivo: playgrounds.lucascoliveira.com) y punteros hacia este repo 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 (secuestro de sesión / encadenamiento de fijación).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 atacante a un sink 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 sink. Impacto en el frontend: la primera renderizació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 entrada controlada por atacante (location, referrer, postMessage, mensajes WebSocket) y la pasa a un sink. 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 entrada no confiable hasta sink.
Estos son los culpables habituales en bases de código React/SPA:
| Sink | 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 de 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 sink sin un contrato seguro — no solo errores de "ventana incorrecta". |
No un sink por defecto: textContent, createTextNode, texto de hijo normal de React, atributos que React trata como cadenas cuando no bypassas su escape. Las canalizaciones de Markdown se convierten en sinks 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 un XSS reflejado clásico podría. Para ver ejecución cuando se inyecta HTML, las cargas útiles suelen depender de manejadores de atributos (por ejemplo, onerror en img) o similares. La demostración de XSS mantiene cargas útiles basadas en preestablecimientos (ver payloadPresets.ts) para que puedas probar casos que realmente se disparan 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允许 (etiquetas + atributos). Codificación (por ejemplo, escape de entidad 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 (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 por 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 mínimo (p, br, strong, em, a con href solo si necesitas enlaces). Cada etiqueta extra es superficie de ataque.ADD_ATTR / FORBID_TAGS — Explícito vence "permitir casi todo".RETURN_DOM / RETURN_TRUSTED_TYPE — Prefiere nodos DOM o salida de estilo TrustedHTML si te integras con Tipos Confiables.afterSanitizeAttributes — Elimina valores href que comienzan con javascript: o tipos MIME data: extraños si permites enlaces.En playgrounds en main, la demostración de XSS contrasta una ruta de renderizado insegura con una ruta saneada (lista de允许 de DOMPurify) — misma UI, política de confianza diferente.
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/inline en esta configuración — documentado en código. Nonce- o hash-based script-src eliminaría la amplia allowance de script en línea pero requiere middleware para inyectar nonces por solicitud — vale la pena planificar si envías HTML de usuario raramente.
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 tomar script-src / script-src-attr explícito (o nonces/hashes), dependiendo del navegador y 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 soluciones XSS; también:
HttpOnly, Secure, SameSite=Lax o Strict donde los flujos 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 emitir solicitudes autenticadas.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 de CMS. Modelo de amenaza diferente al de "cuerpo de mensaje con HTML".mainClona playgrounds, ejecuta npm install y npm run dev → Vite sirve la aplicación en http://localhost:5173.
/insecure con cargas útiles de la documentación; el mismo flujo en /secure muestra salida saneada.payloadPresets.ts y simulateXssImpact.ts para los casos concretos y mapeo de "impacto".localStorage (ver 03-session-hijacking) para que puedas ver qué script en la página puede leer; insecure-patterns 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 de 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 interpretación. La protección es tipado en el límite: texto plano, componentes estructurados seguros o HTML saneado con una lista de允许 mínima — más CSP y semántica de cookies que limitan lo que un script errante aún puede hacer. La demostración hace que ese límite sea visible: una ruta de renderizado insegura vs una saneada, más cargas útiles basadas en preestablecimientos y comportamiento de "impacto" mapeado en el repositorio.