Seguridad del frontend: XSS, límites de confianza y una demostración que puedes ejecutar

← Volver al blog

20 de abril de 20268 min read

Seguridad del frontend: XSS, límites de confianza y una demostración que puedes ejecutar

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.

seguridadfrontendreact

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.

Mismo origen: qué gana realmente el atacante

Los scripts inyectados en tu página se ejecutan con el mismo origen que tu paquete. En la práctica, eso a menudo significa:

  • Visibilidad de 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í.
  • Lecturas de DOM de PII no secreta renderizada en la página, y engaño de UI (formularios falsos, superposiciones) para phishing dentro del cromo de la aplicación.

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, reflejado y basado en DOM (mecánicas)

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.

Sinks: APIs que convierten cadenas en ejecución o HTML

Estos son los culpables habituales en bases de código React/SPA:

SinkRiesgo
element.innerHTML, insertAdjacentHTMLParsea HTML; cualquier etiqueta/manejador de eventos que permitas puede ejecutar script.
dangerouslySetInnerHTMLIgual que arriba — React no saneamiento.
document.writeIgual.
eval, new Function, setTimeout(string)Ejecución directa de script.
URLs de javascript: en href / srcNavegación o carga de recursos que se ejecutan como URL de script.
Manejadores postMessage que eval o establecen HTML a partir de event.dataXSS 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.

Mitigación 1: codificación específica del contexto vs saneamiento

  • 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.

Mitigación 2: DOMPurify (y cómo usarlo seriamente)

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.
  • Engancha en 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.

Mitigación 3: Content-Security-Policy (límites, no un reemplazo)

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.

Mitigación 4: cookies y CSRF (par con XSS)

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:

  • Cookies de sesión: HttpOnly, Secure, SameSite=Lax o Strict donde los flujos permiten — reduce la fuga de cookies entre sitios y CSRF clásico.
  • Endpoints de mutación: empareja con cookies 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.

Este codebase (concreto)

  • Encabezados / CSPapps/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).
  • API de chatapps/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.
  • Blogapps/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".

Comparación ejecutable: qué ofrece main

Clona playgrounds, ejecuta npm install y npm run devVite sirve la aplicación en http://localhost:5173.

  • Sitio en vivo: playgrounds.lucascoliveira.com — misma demostración que el repositorio; usa esto si prefieres no ejecutarlo localmente.
  • UI de tareas (solo en memoria) — Agrega elementos en /insecure con cargas útiles de la documentación; el mismo flujo en /secure muestra salida saneada.
  • Documentación / internos — Comienza en el README del repositorio, luego verifica payloadPresets.ts y simulateXssImpact.ts para los casos concretos y mapeo de "impacto".
  • Token falso — La aplicación almacena un token de demostración en 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.

Lista de verificación (nivel de implementación)

  1. Inventario de sinksrg "dangerouslySetInnerHTML|innerHTML|insertAdjacentHTML|eval\\(|new Function" en tu aplicación y dependencias.
  2. Texto enriquecido — Saneador de lista de允许 en cada ruta a HTML; prueba unitaria con cargas útiles como <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.
  3. Parámetros de URL → DOM — Nunca asignes búsqueda/hash a HTML; si debes mostrar, texto o codificar para contexto.
  4. Markdown — Saneamiento después de la conversión completa de MD a HTML; prohíbe HTML crudo en MD si el producto lo permite.
  5. CSP — Ajusta incrementalmente; usa Report-Only en staging si es necesario.
  6. Cookies / API — Alinea SameSite, credenciales y estrategia CSRF con backend; asume que XSS y CSRF se encadenan.

Conclusión

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.