Cross-Site Scripting (XSS)
Qué es Cross-Site Scripting
El Cross-Site Scripting (XSS, CWE-79) es una vulnerabilidad que permite a un atacante inyectar y ejecutar código JavaScript malicioso en el navegador de otra persona. A diferencia de la inyección SQL, que ataca al servidor, el XSS ataca a los usuarios de la aplicación. El navegador de la víctima ejecuta el script del atacante como si fuera parte legítima del sitio, con todos los privilegios de ese origen: acceso al DOM, a las cookies no protegidas, al almacenamiento local y a cualquier API que la sesión del usuario tenga habilitada.
La causa raíz es la misma familia que la inyección: la aplicación toma datos no confiables del usuario y los coloca en una página HTML sin tratarlos adecuadamente. Cuando el navegador interpreta esa página, no puede distinguir entre el HTML legítimo escrito por el desarrollador y el código malicioso inyectado; para el motor de renderizado, ambos son simplemente marcado. El problema aparece siempre que un dato cruza una frontera de contexto —de texto plano a HTML, a un atributo, a un bloque <script> o a una URL— sin la transformación que ese contexto exige. El XSS forma parte de la categoría de inyección del OWASP Top 10 y sigue siendo una de las vulnerabilidades más comunes y persistentes en la web.
XSS Reflejado
El XSS reflejado es la variante más simple. Ocurre cuando los datos enviados en una request, normalmente a través de un parámetro de URL o de un formulario, se devuelven inmediatamente en la respuesta sin codificar. Por ejemplo, una página de búsqueda que muestra "No se encontraron resultados para: [término]" y coloca el término directamente en el HTML.
El atacante construye una URL especialmente diseñada que contiene el script y la envía a la víctima por correo, mensajería o un enlace en otro sitio. Cuando la víctima hace clic, el servidor refleja el contenido en la respuesta y el navegador lo ejecuta. Como el ataque vive en un enlace y no se almacena, requiere ingeniería social para que la víctima active la URL, pero sigue siendo muy peligroso porque se ejecuta en el contexto del sitio confiable, heredando su origen y sus permisos.
XSS Almacenado y Basado en DOM
El XSS almacenado (persistente) es el más grave. Aquí el código malicioso se guarda en el servidor —por ejemplo en un comentario, un perfil de usuario o un mensaje de foro— y se sirve a todos los que vean ese contenido. Una sola inyección puede afectar a miles de usuarios sin necesidad de ingeniería social, ya que el script se entrega automáticamente cada vez que se carga la página comprometida.
El XSS basado en DOM es distinto: la vulnerabilidad vive enteramente en el código JavaScript del lado del cliente. Ocurre cuando un script de la página toma datos de una fuente controlable por el atacante (un source, como location.hash o location.search) y los escribe en un sink peligroso del DOM, como innerHTML, document.write o eval. En este caso el servidor puede no ver nunca la carga maliciosa —el fragmento tras # ni siquiera se envía al servidor—, lo que dificulta su detección con herramientas tradicionales del lado del servidor.
El Impacto del XSS
Una vez que un atacante puede ejecutar JavaScript en el navegador de la víctima, las posibilidades son amplias:
- Robo de sesión: leer las cookies de sesión (si no están protegidas con
HttpOnly) o los tokens del almacenamiento local para secuestrar la cuenta. - Keylogging: registrar las pulsaciones de teclado para capturar credenciales, tarjetas o mensajes.
- Phishing en la página: modificar el DOM para insertar formularios de login falsos que parecen legítimos porque viven en el dominio real.
- Acciones en nombre de la víctima: emitir requests autenticadas usando los permisos del usuario, saltándose muchas defensas anti-CSRF.
- Compromiso de administradores: un XSS dirigido a un panel de administración puede escalar hasta el control total de la aplicación.
El XSS también puede usarse para propagar gusanos web autorreplicantes: un script almacenado que, al ejecutarse en la sesión de una víctima, publica una copia de sí mismo en su perfil, infectando a cada usuario que lo visita. Incidentes históricos en redes sociales demostraron que este patrón puede alcanzar cientos de miles de cuentas en horas. El impacto, por tanto, va desde el robo de datos individuales hasta el compromiso masivo de una plataforma.
Corrección: XSS Basado en DOM
El punto de partida es un patrón muy habitual: tomar un valor de la URL y volcarlo en el DOM con innerHTML. Al asignar una cadena a innerHTML, el navegador la interpreta como HTML y ejecutará cualquier marcado activo que contenga.
// VULNERABLE: userInput proviene de la URL y se interpreta como HTML
const userInput = new URLSearchParams(location.search).get("q");
document.getElementById("output").innerHTML = userInput;
La corrección es no tratar el dato como HTML. Cuando solo necesitamos mostrar texto, textContent lo inserta como cadena literal: el navegador nunca lo interpreta como marcado.
// SEGURO: textContent trata el valor como texto, no como HTML
const userInput = new URLSearchParams(location.search).get("q");
document.getElementById("output").textContent = userInput;
Si el requisito real es renderizar HTML enviado por el usuario (por ejemplo, un editor de texto enriquecido), no basta con codificar: hay que sanitizar con una librería vetada que elimine el marcado peligroso antes de insertarlo.
// SEGURO: sanitizar HTML de confianza limitada con DOMPurify
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(userInput);
document.getElementById("output").innerHTML = clean;
Corrección: XSS Reflejado en el Servidor
En el servidor, el error clásico es concatenar la entrada del usuario dentro del HTML de la respuesta. El valor cruza la frontera hacia un contexto HTML sin ninguna transformación.
// VULNERABLE: input concatenado directamente en el HTML de respuesta
app.get("/search", (req, res) => {
const term = req.query.q;
res.send(`<h1>Resultados para: ${term}</h1>`);
});
La solución es aplicar codificación de salida contextual o, mejor aún, delegar en un motor de plantillas con autoescape (Nunjucks, EJS con escape, Handlebars, etc.), que codifica las variables según el contexto por defecto.
// SEGURO: motor de plantillas con autoescape mediante res.render
app.get("/search", (req, res) => {
res.render("search", { term: req.query.q });
});
// En la plantilla, {{ term }} se codifica automáticamente para contexto HTML
Los frameworks de front modernos ayudan mucho: React y Angular codifican por defecto los valores que se interpolan en el JSX o en las plantillas. El riesgo reaparece cuando se usan las vías de escape deliberadas, como dangerouslySetInnerHTML en React o [innerHTML]/bypassSecurityTrust* en Angular. Esas APIs deben reservarse para contenido ya sanitizado con DOMPurify.
Prevención en Profundidad
Ninguna medida aislada es suficiente; combina varias capas:
- Codificación de salida contextual: codifica según el contexto exacto donde cae el dato —HTML, atributo HTML, JavaScript, URL y CSS—, porque cada uno tiene un conjunto distinto de caracteres peligrosos. Un valor seguro dentro de un
<p>puede ser explotable dentro de unhrefo de un bloque<script>. - Frameworks que autoescapan: prefiere React, Angular, Vue o motores de plantilla con escape por defecto, y trata cada uso de
dangerouslySetInnerHTML/innerHTMLcomo una excepción que exige justificación. - Sanitización con librería vetada: cuando debas aceptar HTML del usuario, pásalo por DOMPurify u otra librería mantenida; nunca escribas tu propio filtro con expresiones regulares.
- Cookies
HttpOnly: marca las cookies de sesión comoHttpOnly(ySecure+SameSite) para que un XSS no pueda leerlas desde JavaScript, reduciendo el impacto del robo de sesión. - Content-Security-Policy (CSP): es una cabecera HTTP que indica al navegador desde qué orígenes puede cargar y ejecutar recursos, bloqueando la ejecución de scripts inyectados inline. Una CSP restrictiva actúa como red de seguridad aunque exista un fallo de codificación:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Evita 'unsafe-inline' y 'unsafe-eval' en script-src; para los scripts inline que sí necesites, usa un nonce por request en lugar de abrir la política.
Herramientas de Detección
Verifica tus defensas con pruebas activas y automatizadas:
- Burp Suite y OWASP ZAP: proxies de interceptación que ayudan a identificar puntos donde la entrada se refleja sin codificar y a comprobar el comportamiento de la CSP.
- Escáneres DAST (Dynamic Application Security Testing): recorren la aplicación en ejecución en busca de puntos de inyección reflejados y almacenados; intégralos en el pipeline de CI para detectar regresiones.
- Análisis estático y linters: reglas que marcan usos de
innerHTML,dangerouslySetInnerHTMLoevalen el código, para revisarlos antes del merge.
Checklist de Prevención
- [ ] Codificar toda salida no confiable según su contexto (HTML, atributo, JS, URL, CSS).
- [ ] Usar frameworks o motores de plantilla con autoescape habilitado por defecto.
- [ ] Preferir
textContentsobreinnerHTMLcuando solo se muestra texto. - [ ] Sanitizar con DOMPurify cualquier HTML de origen no confiable; nunca filtrar con regex propias.
- [ ] Tratar
dangerouslySetInnerHTMLeinnerHTMLcomo excepciones justificadas y revisadas. - [ ] Marcar las cookies de sesión como
HttpOnly,SecureySameSite. - [ ] Desplegar una Content-Security-Policy restrictiva, sin
'unsafe-inline'ni'unsafe-eval'. - [ ] Validar la entrada en el servidor como capa adicional, no como única defensa.
- [ ] Probar con Burp Suite, OWASP ZAP y escáneres DAST en CI.