Cross-Site Scripting (XSS)
Cómo detectar y prevenir vulnerabilidades Cross-Site Scripting mediante el tratamiento seguro de datos externos y la eliminación de operaciones peligrosas en el navegador.
1. El problema
1.1. ¿Qué es Cross-Site Scripting?
Cross-Site Scripting (XSS) es una vulnerabilidad que permite a un atacante introducir código malicioso en una aplicación web para que se ejecute en el navegador de otros usuarios. Es una de las más persistentes del frontend.
A diferencia de SQL Injection, donde el objetivo es manipular las consultas de una base de datos, en XSS el atacante consigue que el navegador interprete como código algo que debería tratar solo como información.
Hay tres variantes principales:
- Reflected XSS: el contenido malicioso llega en una petición HTTP y aparece enseguida en la respuesta, por ejemplo mediante un enlace de búsqueda manipulado.
- Stored XSS: el contenido se almacena en la aplicación (un comentario, un mensaje) y se ejecuta cuando otros usuarios lo consultan.
- DOM-based XSS: el fallo está en el JavaScript del frontend, que mete datos no confiables en el DOM con operaciones inseguras.
Aquí nos centraremos en la primera y la última. La segunda es la misma historia con una base de datos por medio.
1.2. En código
Veamos un ejemplo sencillo con FastAPI. Un endpoint recibe un término de búsqueda y devuelve una página HTML:
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/search", response_class=HTMLResponse)
def search(q: str = ""):
return f"""
<html>
<body>
<h1>Resultados para: {q}</h1>
</body>
</html>
"""
El problema está en esta línea:
<h1>Resultados para: {q}</h1>
Una f-string mete el parámetro q tal cual en el HTML que recibirá el navegador. Si alguien visita /search?q=<img src=x onerror=alert(1)>, la aplicación devolverá ese <img> como parte de la página: el navegador intentará cargar la imagen, fallará y ejecutará el manejador onerror. Es un Reflected XSS.
Pero no hace falta que el backend genere HTML vulnerable. Imaginemos un frontend que lee la URL:
const params = new URLSearchParams(location.search);
const name = params.get("name") ?? "";
document.querySelector("#welcome").innerHTML = name;
Aquí el culpable es innerHTML, que interpreta la cadena recibida como HTML. Si un atacante manipula name, es el propio frontend quien incorpora los datos peligrosos al DOM: un DOM-based XSS.
Este ejemplo nos da dos conceptos clave para analizar vulnerabilidades: Sources y Sinks. Una Source (fuente) es por donde entran datos que un atacante puede controlar. Una Sink (destino) es la operación donde acaban esos datos y que, según cómo se comporte, puede interpretarlos como código. En nuestro ejemplo:
SOURCE -> location.search -> params.get("name") -> name -> element.innerHTML
Los datos entran por la URL y terminan en una operación capaz de interpretarlos como HTML.
Estas son algunas Sources habituales (la lista no es exhaustiva):
| Source | Procedencia |
|---|---|
location.search | Parámetros de URL |
location.hash | Fragmentos de URL |
document.referrer | Página de origen |
window.name | Nombre de ventana, que puede persistir entre sitios |
input.value | Formularios |
fetch() | Respuestas de APIs REST |
localStorage | Almacenamiento local |
message.data | Mensajes postMessage |
Y estas, las Sinks que conviene vigilar (tampoco es exhaustiva):
| Sink | Comportamiento |
|---|---|
innerHTML | Interpreta HTML |
outerHTML | Interpreta HTML |
insertAdjacentHTML() | Interpreta HTML |
iframe.srcdoc | Interpreta HTML |
document.write() | Introduce HTML en el documento |
eval() | Ejecuta JavaScript |
new Function() | Construye código ejecutable |
setTimeout("...") | Ejecuta una cadena como JavaScript |
href (y src de iframe) | Pueden aceptar URLs javascript: |
1.3. Consecuencias
El código malicioso se ejecuta dentro del contexto de seguridad de la aplicación legítima. Por eso XSS no se limita a robar información: el atacante puede hacer cualquier cosa que pudiera hacer el usuario. Leer lo que muestra la página o lo que alcanza su JavaScript, lanzar operaciones autenticadas aprovechando su sesión, alterar formularios, enlaces o mensajes para engañarlo, plantar un formulario falso que pida la contraseña. Y si la víctima es administradora, el atacante hereda también esos permisos.
Importante: una cookie HttpOnly no puede leerse mediante document.cookie, pero eso no elimina el problema: el script malicioso todavía puede enviar peticiones autenticadas desde el navegador.
1.4. Cómo detectarlo
El primer paso es localizar las Sources y comprobar si sus datos pueden llegar a Sinks peligrosas. Por ejemplo:
// SOURCE
const data = new URLSearchParams(location.search)
.get("message");
// Transformación intermedia
const message = `Mensaje recibido: ${data}`;
// SINK
document.querySelector("#message").innerHTML = message;
Aunque el dato pase por varias funciones, la relación entre la entrada externa y la operación peligrosa sigue ahí. Las herramientas SAST intentan reconstruir ese recorrido con una técnica llamada Taint Analysis (análisis de propagación de datos no confiables). CodeQL y Semgrep detectan determinados flujos peligrosos, y herramientas DAST como Burp Suite u OWASP ZAP ayudan a comprobar si esos flujos son explotables.
En pruebas manuales autorizadas se pueden introducir marcadores reconocibles y ver si aparecen como texto o se interpretan como HTML; después, una carga de prueba inofensiva confirma la ejecución cuando el contexto lo permite. En una revisión de código, mi criterio es simple: ante un innerHTML, insertAdjacentHTML, document.write o eval (o su equivalente en el framework), que explique por qué es seguro quien lo escribe, no quien lo revisa.
Ojo piojo: que no salte ninguna alerta no demuestra que no haya XSS. Una CSP puede haberla bloqueado, o la vulnerabilidad puede estar en otro contexto de ejecución.
1.5. Errores frecuentes
El primero es creer que el frontend filtra algo. Cualquier entrada puede manipularse: formularios, URLs, peticiones HTTP. El segundo, limpiar las entradas a lo bruto. Quitar comillas, etiquetas o caracteres especiales no garantiza seguridad y sí destroza datos legítimos (que se lo pregunten a quien se apellide O'Brien). El tercero, eliminar solo las etiquetas <script>: hay muchísimas otras formas de ejecutar JavaScript, como el onerror de antes. Y el cuarto, pensar que JSON te protege: sus valores pueden acabar en una Sink insegura igual que cualquier otro dato.
Luego están las confianzas mal puestas. En los frameworks: React, Angular y Lit incorporan protecciones, pero también ofrecen mecanismos para saltárselas. En la CSP: es una defensa complementaria, no un sustituto del tratamiento seguro de los datos. Y en las bibliotecas de sanitización, que no son infalibles; a DOMPurify le ha pasado, como veremos.
2. La solución
La mejor estrategia es evitar por diseño que los datos no confiables puedan convertirse en código ejecutable. Según lo que necesitemos mostrar, hay tres caminos.
2.1. Mostrar texto: utilizar Sinks seguras
Si solo necesitamos mostrar texto, no hay motivo para interpretarlo como HTML. La corrección del ejemplo vulnerable es sustituir innerHTML por textContent:
document.querySelector("#welcome").textContent = name;
En un elemento HTML ordinario, textContent inserta el contenido como texto, sin interpretarlo como marcado. Si el usuario escribe:
<img src=x onerror="alert('XSS')">
el navegador mostrará literalmente esa cadena. No creará ninguna imagen ni ejecutará su manejador de eventos.
No hace falta limpiar nada antes. De hecho, un usuario puede necesitar escribir legítimamente algo como:
Me encanta <JavaScript> y las "comillas".
y la aplicación debería mostrarlo sin eliminar ni un carácter.
Los frameworks modernos tienen mecanismos equivalentes. Lit, por ejemplo, trata de forma segura las interpolaciones de texto normales:
render() {
return html`<p>${this.message}</p>`;
}
Lo que hay que evitar es pasar datos externos sin tratar a unsafeHTML de Lit o a dangerouslySetInnerHTML de React. Los nombres ya avisan.
En el backend pasa lo mismo. Si conservamos el ejemplo de FastAPI, podemos escapar el parámetro:
from html import escape
safe_q = escape(q, quote=True)
y usar safe_q dentro del HTML. En una aplicación real es preferible un motor de plantillas con escape automático, como Jinja2.
Eso sí, el escape depende del contexto de salida. escape() sirve para texto dentro de HTML, pero no para todo. Si metes un dato en un atributo href, html.escape no te protege de un javascript:alert(1), porque ahí no hay ningún carácter especial que escapar: lo que toca es validar el protocolo. Y dentro de un bloque <script> o de CSS las reglas son otras.
2.2. Mostrar contenido enriquecido: utilizar datos estructurados
¿Y si necesitamos negritas, cursivas, párrafos o listas? Una opción muy interesante es no recibir HTML, sino una estructura de datos que describa el contenido. Es la idea que hay detrás de editores como Rich Text (Blocks) de Strapi, que guarda el contenido como JSON en lugar de HTML.
En vez de recibir esto:
<p>Hola <strong>mundo</strong></p>
recibiríamos algo así (una versión simplificada para el ejemplo, no el formato real de Strapi):
[
{
"type": "text",
"text": "Hola "
},
{
"type": "bold",
"text": "mundo"
}
]
El frontend interpreta esa estructura y decide qué elementos crear:
const ALLOWED_TAGS = new Map([
["text", "span"],
["bold", "strong"],
["italic", "em"]
]);
function renderContent(container, blocks) {
container.replaceChildren();
if (!Array.isArray(blocks)) return;
for (const block of blocks) {
if (typeof block?.text !== "string") continue;
const tag = ALLOWED_TAGS.get(block.type);
if (!tag) continue;
const element = document.createElement(tag);
element.textContent = block.text;
container.appendChild(element);
}
}
Este código toma varias decisiones a propósito: solo se generan las etiquetas de ALLOWED_TAGS, los tipos desconocidos se descartan, el texto entra siempre por textContent y no hay ni rastro de innerHTML ni de HTML recibido del exterior. Aunque un atacante consiga colar una cadena maliciosa en el JSON, acabará pintada como texto.
El ejemplo es de juguete. En una aplicación real habría que validar el esquema completo, las estructuras anidadas y los atributos permitidos. Si admitimos enlaces, comprobar sus URLs y dejar pasar solo https: (o http: cuando corresponda). Un JSON estructurado no garantiza la ausencia de XSS si el renderer maneja mal esos atributos.
La ventaja es de arquitectura: en lugar de recibir HTML e intentar quitarle lo peligroso, decidimos exactamente qué elementos puede construir nuestra aplicación.
2.3. Cuando necesitamos admitir HTML externo
A veces no hay escapatoria: editores enriquecidos, contenidos de sistemas externos, aplicaciones heredadas. Antes de resignarte, pregúntate si de verdad necesitas HTML. Si la respuesta es sí, toca una biblioteca de sanitización como DOMPurify con una lista restrictiva de etiquetas y atributos:
const clean = DOMPurify.sanitize(externalHtml, {
ALLOWED_TAGS: [
"p", "strong", "em",
"br", "ul", "ol", "li"
],
ALLOWED_ATTR: []
});
// Solo se asigna después de sanitizar; sin ese paso sería la Sink de antes
container.innerHTML = clean;
Conserva el HTML autorizado y elimina el resto según las reglas. Pero no es una garantía absoluta. DOMPurify es una biblioteca reconocida y recomendada por OWASP, y aun así ha tenido vulnerabilidades. El CVE-2026-0540, publicado en marzo de 2026, afectaba a las versiones 3.1.3 a 3.3.1 y 2.5.3 a 2.5.8: la sanitización no contemplaba cinco elementos de texto sin procesar (noscript, xmp, noembed, noframes e iframe), lo que permitía saltarse la limpieza de atributos y provocar XSS si el resultado sanitizado acababa dentro de ellos. Se corrigió en las versiones 3.3.2 y 2.5.9.
Eso no hace insegura a DOMPurify. Es software, y el software tiene defectos. Si sanitizas HTML, mantén la dependencia al día, vigila las vulnerabilidades conocidas con herramientas SCA, usa una lista restrictiva y no transformes el resultado después, porque podrías invalidar la sanitización.
Además, conviene añadir defensa en profundidad. Una Content Security Policy (CSP) que restrinja las fuentes y los mecanismos de ejecución de código, evitando en lo posible el JavaScript inline. Trusted Types, en los navegadores compatibles, para que las asignaciones de cadenas a Sinks peligrosas pasen por políticas controladas. Y cookies HttpOnly y Secure, que reducen determinados riesgos de robo y transporte. Ninguna sustituye a eliminar las operaciones vulnerables.
La clave de XSS: los datos externos deben seguir siendo datos. La mejor defensa no consiste en limpiar HTML peligroso una y otra vez, sino en evitar que nuestra aplicación tenga que interpretarlo.
Para saber más
OWASP. Cross Site Scripting Prevention Cheat Sheet.
PortSwigger Web Security Academy. Cross-Site Scripting (XSS).
GitHub Advisory Database. CVE-2026-0540 — Vulnerabilidad XSS en DOMPurify.