Qué es el cross-site scripting
El cross-site scripting (XSS) es una vulnerabilidad en la que un atacante consigue que una página de tu sitio incluya JavaScript que él escribió. El navegador no puede distinguir ese script del tuyo: llega desde tu origen, así que se ejecuta con todo lo que a tu origen se le permite hacer, incluidas las sesiones de tus usuarios.
La causa raíz es siempre la misma. Un texto que controla otra persona, como un comentario, un término de búsqueda, un nombre visible o un fragmento de URL, se inserta en una página de una forma que deja que el navegador lo lea como marcado o código en lugar de como texto plano. Un comentario que debería aparecer como los caracteres literales <script> se convierte en cambio en un elemento script.
El nombre es histórico y algo confuso: el ataque clásico involucraba un segundo sitio, pero hoy la mayoría del XSS es simplemente datos no fiables ejecutados dentro de tu propia página. OWASP lo clasifica bajo la categoría de inyección del OWASP Top 10, junto a su primo del lado del servidor, la inyección SQL. La diferencia es el intérprete. La inyección SQL engaña a tu base de datos; XSS engaña a los navegadores de tus usuarios.
XSS es un fallo de la aplicación, no del navegador ni de la red. HTTPS no lo evita, un firewall no lo evita del todo, y solo el código que construye la página puede eliminarlo.
Los tres tipos: almacenado, reflejado y basado en el DOM
El XSS almacenado (también llamado persistente) es el más dañino. La entrada maliciosa queda guardada por el servidor, en un comentario, un campo de perfil, una reseña de producto o un ticket de soporte, y luego se sirve a todos los visitantes que abren esa página, a menudo incluidos los administradores que leen el back office. Nadie tiene que hacer clic en un enlace especial.
El XSS reflejado no se guarda. El servidor toma algo de la petición, normalmente un parámetro de consulta, y lo escribe directamente en la respuesta: una página de búsqueda que imprime "Resultados para ...", o una página de error que repite el valor incorrecto. El atacante tiene que conseguir que la víctima abra un enlace manipulado, por correo, chat u otro sitio.
El XSS basado en el DOM ocurre por completo en el navegador. Tu propio JavaScript lee un valor que controla el atacante, como location.hash, location.search, datos de postMessage o document.referrer, y lo escribe en un sink peligroso: innerHTML, document.write, eval, setTimeout con una cadena, o una URL javascript: en un href. El servidor puede no ver nunca el payload, ya que todo lo que va después de # no se envía con la petición.
Los tres ejemplos de manual de abajo muestran la forma de cada fallo. En todos los casos, la solución es la misma idea: tratar el valor como texto.
Patrones vulnerables mínimos (no los lances a producción)
<!-- Stored: a saved comment printed without escaping (PHP) -->
<p><?= $comment->body ?></p>
<!-- body saved as: <script>alert(document.domain)</script> -->
<!-- Reflected: the search term echoed from the URL -->
<h1>Results for <?= $_GET['q'] ?></h1>
<!-- link: /search?q=<script>alert(document.domain)</script> -->
// DOM-based: client code writes the URL fragment as HTML
document.getElementById('greeting').innerHTML =
decodeURIComponent(location.hash.slice(1));
// link: /welcome#<img src=x onerror=alert(document.domain)>
Qué puede hacer un atacante con esto
alert(1) es lo que usan los testers para demostrar el fallo; no es lo que ejecutan los atacantes. Una vez que su script se ejecuta en tu página, actúa como el usuario conectado, dentro de tu origen.
Robo de sesión. Si la cookie de sesión se puede leer desde JavaScript, el script la envía al atacante, que entonces inicia sesión como la víctima. Los tokens guardados en localStorage o sessionStorage siempre se pueden leer desde script, que es el argumento principal contra guardar ahí tokens de larga duración.
Acciones como el usuario. Incluso cuando la cookie es HttpOnly, el script puede llamar a tu API con fetch; el navegador adjunta la cookie por sí mismo. Puede leer tokens CSRF de la página, cambiar el correo de la cuenta, crear un usuario administrador, publicar mensajes o hacer pedidos. Las reglas de mismo origen, CORS y las cookies SameSite no detienen esto, porque la petición viene de tu propio sitio.
Leer lo que ve el usuario. Datos personales, mensajes, facturas, cualquier cosa en la página o alcanzable a través de tu API.
Phishing y registro de teclas en tu dominio. El script puede redibujar la página como un formulario de login o registrar lo que el usuario escribe en formularios reales. La barra de direcciones muestra tu dominio y un certificado válido, así que el usuario no tiene motivo para dudar.
Vandalismo y propagación. El XSS almacenado puede cambiar lo que ve cada visitante, o copiarse a sí mismo en el perfil de cada víctima. El gusano Samy de 2005 en MySpace se propagó a más de un millón de perfiles en menos de un día de esta forma.
Lo grave que resulta un XSS depende de quién vea la página. Un fallo en una pantalla solo para administradores al que se llega mediante entrada almacenada, como el asunto de un ticket de soporte, suele ser peor que uno en la página de inicio pública.
La solución: codificación de salida sensible al contexto
La defensa principal es codificar los datos no fiables en el momento en que los escribes en la página, de la forma que exige el contexto que la rodea. Validar la entrada ayuda (un código postal debería parecer un código postal), pero no puede ser el control principal, porque el mismo valor puede ser seguro en un contexto y peligroso en otro.
Cuerpo HTML: escapa &, <, >, " y ' como entidades, de modo que <script> se muestre en lugar de analizarse.
Atributo HTML: pon siempre los valores de atributo entre comillas, y escapa los mismos caracteres. Un atributo sin comillas se puede romper con un solo espacio.
URL en href o src: codificar no basta, porque javascript:alert(1) no contiene nada que escapar. Analiza la URL y permite solo https: y http: (y mailto: si lo necesitas); codifica los valores de consulta con encodeURIComponent.
Dentro de un bloque <script>: no concatenes cadenas dentro de JavaScript. Serializa los datos como JSON con una función que también escape <, o ponlos en un atributo data- y léelos con element.dataset.
Estilos: evita por completo poner datos de usuario en CSS.
En el cliente: prefiere las APIs seguras. textContent, setAttribute en atributos inofensivos y createElement nunca analizan HTML; innerHTML, outerHTML, insertAdjacentHTML y document.write sí lo hacen.
Escribir datos de usuario de forma segura en el navegador
// Text, never markup
el.textContent = userName;
// Links: allow only http(s)
function safeUrl(value) {
try {
const url = new URL(value, location.origin);
return ['https:', 'http:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#';
}
}
link.href = safeUrl(profile.website);
// Data for scripts: a data attribute, not string concatenation
// <div id="app" data-user="{{ user_json }}"> (escaped by the template)
const user = JSON.parse(document.getElementById('app').dataset.user);
Autoescape de los frameworks, y sus vías de escape
Los frameworks modernos ya codifican para el contexto HTML por defecto. {{ }} en Blade, Twig, Jinja, las plantillas de Django, Vue y Angular, <%= %> en Rails ERB y {value} en React JSX, todos escapan lo que imprimen. Esa es la razón principal por la que XSS es más raro de lo que era en las páginas PHP hechas a mano, y la razón para seguir usando la forma por defecto del framework para imprimir valores.
Casi todo XSS real en una base de código moderna vive en una vía de escape, una función que desactiva deliberadamente el escapado:
- React dangerouslySetInnerHTML, Vue v-html, Svelte {@html}, Angular bypassSecurityTrustHtml.
- Laravel Blade {!! $value !!}, Jinja |safe y Markup(), Django mark_safe y {% autoescape off %}, Rails raw y html_safe.
- Escrituras directas en el DOM desde código de componente: ref.current.innerHTML = ..., jQuery .html(), $(userInput).
Los otros huecos son contextos que el autoescapador no entiende. Una plantilla que escapa HTML sigue dejando pasar javascript: en href={url}, sigue permitiendo que un valor de usuario dentro de un <script> en línea o un manejador de evento rompa el código, y nunca protege una cadena que pasas a eval.
Una regla práctica: haz que las vías de escape sean raras, buscables y revisadas. Una búsqueda en el código de la lista anterior encuentra la mayor parte de tu exposición, y una regla de linter (por ejemplo, ESLint react/no-danger, o una regla de Semgrep para {!!) evita que entren nuevas sin que nadie se dé cuenta.
Cuando tienes que aceptar HTML: usa un saneador de verdad
Algunas funciones necesitan HTML de usuario: un editor de texto enriquecido, el cuerpo de un CMS, un post de foro con formato, una vista previa de correo. Escapar destruiría el formato, así que la respuesta es sanear: analizar el HTML y conservar solo una lista de permitidos de elementos y atributos seguros.
Usa una librería mantenida construida para esto, nunca una expresión regular o una lista negra de etiquetas "malas". Los navegadores analizan el HTML de formas sorprendentes, y todos los filtros escritos a mano que eliminan <script> se han saltado con un atributo de manejador de evento, un elemento SVG, un anidamiento raro o un truco de codificación. Las opciones establecidas son DOMPurify en el navegador y en Node.js con jsdom, HTML Purifier para PHP, nh3 (la librería Rust ammonia) para Python, y el OWASP Java HTML Sanitizer para Java.
Tres detalles marcan la diferencia. Sanea con una lista de permitidos tan pequeña como lo necesite la función. Sanea en la salida, o otra vez en la salida, para que un cambio posterior en la librería o en tu lista de permitidos proteja los datos antiguos. Y no modifiques el HTML después de sanearlo: volver a analizarlo, el reemplazo de cadenas o insertarlo en otro contexto puede convertir un resultado seguro en uno inseguro.
Para Markdown, el HTML resultante necesita el mismo tratamiento: la mayoría de los motores de Markdown permiten HTML en crudo por defecto.
Limitar el daño: HttpOnly, SameSite y Content-Security-Policy
Da por hecho que al final se te escapará un XSS, y haz que valga menos.
Cookies. Marca las cookies de sesión como HttpOnly, así document.cookie no puede leerlas, más Secure y SameSite=Lax o Strict. Eso detiene el escenario de robo de cookie. No detiene que el script actúe como el usuario mientras la página está abierta, así que es control de daños, no una solución. Mantén los tokens de larga duración fuera de localStorage por la misma razón.
Content-Security-Policy. Una CSP le dice al navegador qué scripts pueden ejecutarse. La política que detiene el XSS es una estricta: un nonce aleatorio generado de nuevo en cada respuesta, puesto tanto en la cabecera como en cada etiqueta <script> legítima, más 'strict-dynamic', object-src 'none' y base-uri 'none'. Un <script> inyectado no tiene un nonce válido, y los manejadores de evento en línea como onerror= quedan bloqueados, así que la mayoría de los fallos XSS se convierten en un error de consola y un informe. Una política que solo lista dominios y mantiene 'unsafe-inline' ofrece poca protección contra XSS.
Despliégala primero como Content-Security-Policy-Report-Only, arregla lo que muestren los informes, y luego hazla cumplir. La guía de cabeceras de seguridad HTTP cubre el despliegue en modo report-only, las otras cabeceras que van a su lado, y por qué X-XSS-Protection ya no debería enviarse.
Una trampa de caché: un nonce tiene que ser impredecible. Si una CDN o una caché de página sirve el mismo HTML a todo el mundo, cada visitante recibe el mismo nonce, y un atacante puede leerlo de la página. Para páginas cacheadas, usa hashes de script ('sha256-...') en su lugar, o mantén fuera de la caché el HTML que lleva un nonce.
Donde los navegadores lo admiten, require-trusted-types-for 'script' va más allá: sinks del DOM como innerHTML rechazan cadenas simples, así que el XSS basado en el DOM tiene que pasar por código que tú escribiste.
Una política estricta basada en nonce (nonce nuevo en cada respuesta)
Content-Security-Policy: script-src 'nonce-R4nd0mPerResponse' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="R4nd0mPerResponse" src="/js/app.js"></script>
Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax
Probar tu propia aplicación contra XSS
Prueba solo sistemas que posees o para los que tienes autorización. Para tu propia aplicación, un marcador inofensivo encuentra la mayoría de los fallos sin ningún código de ataque.
Rastrea cada entrada hasta cada salida. Pon un marcador único con caracteres significativos para HTML, por ejemplo xss7"'<b>bold</b>, en cada campo, parámetro de consulta, cabecera que muestre tu aplicación y nombre de archivo que aceptes. Luego visita cada página donde aparezca ese valor, incluidas pantallas de administración, correos, exportaciones y notificaciones. Si la palabra aparece en negrita, o el código fuente de la página muestra un <b> sin escapar o una comilla que cierra un atributo, la salida no está codificada.
Revisa el DOM, no solo el código fuente. Para los fallos basados en el DOM, pon el marcador en location.hash y en la cadena de consulta e inspecciona el DOM en vivo en las herramientas de desarrollador: ver el código fuente solo muestra lo que envió el servidor.
Busca en el código. Haz grep de las vías de escape listadas arriba y de innerHTML, insertAdjacentHTML, document.write, eval y new Function. Cada coincidencia debería desaparecer o tener un comentario corto explicando por qué la entrada es segura.
Usa herramientas. OWASP ZAP y Burp Suite rastrean y prueban entradas automáticamente y encuentran los casos reflejados obvios; el análisis estático como Semgrep o CodeQL sigue flujos de datos que un rastreador no puede ver. Ninguno sustituye el rastreo manual para el XSS almacenado en pantallas de back office.
Vigila los informes de CSP. Una vez que una política report-only está activa, las violaciones de scripts en línea inesperados son un aviso temprano gratuito.
grep -rnE 'dangerouslySetInnerHTML|v-html|\{@html|bypassSecurityTrust|innerHTML|insertAdjacentHTML|document\.write' src/
grep -rnE '\{!!|\|safe|mark_safe|html_safe|raw\(' resources/ templates/ app/
Dónde encaja un WAF, y qué hace el WAF de CDN.com.tr
Un firewall de aplicaciones web inspecciona las peticiones y bloquea las que parecen ataques. Para XSS eso es útil: las etiquetas script y los manejadores de evento en una cadena de consulta o un cuerpo de formulario son reconocibles, así que los intentos reflejados y los escáneres automatizados se detienen antes de llegar a tu aplicación, y un payload almacenado enviado por un formulario normal a menudo se rechaza al entrar.
Es una capa, no la solución. Un WAF ve peticiones, no tus páginas, así que no puede saber cómo se va a usar un valor más adelante. El XSS basado en el DOM que vive en el fragmento de la URL nunca llega a él, la entrada que llega a través de una importación, una API que llamas o un canal que el WAF no inspecciona se le escapa, y los atacantes ajustan los payloads para evitar los patrones. La guía del OWASP Top 10 mapea en detalle lo que un firewall puede y no puede atrapar. Codifica la salida, sanea el HTML y fija una CSP, haya o no un WAF delante.
En CDN.com.tr el WAF es ModSecurity con el OWASP Core Rule Set, que se ejecuta en el edge y se activa para la cuenta desde la página de reglas de entrega. La familia 941 del Core Rule Set son sus reglas de cross-site scripting. Un visitante bloqueado recibe una página 403 de marca propia con un ID de referencia, y la página de Logs del WAF lista los eventos bloqueados con la categoría de ataque, el país, la IP y la regla, buscable por ese ID en los últimos 30 días y exportable a CSV o XLSX; cdnctl waf logs devuelve la misma lista desde la línea de comandos.
El falso positivo habitual para las reglas de XSS es un envío de HTML legítimo, como un editor que guarda contenido con formato o un ejemplo de código. Las excepciones por rutas individuales todavía no están disponibles en el panel: si el WAF bloquea una petición legítima, envía su ID de referencia a soporte en lugar de desactivar el WAF.
Las cabeceras también se pueden añadir en el edge. Las cabeceras de un solo valor van en los Encabezados personalizados de una regla de entrega, una por línea, y se aplican a respuestas 2xx y 3xx, incluidos los aciertos de caché. Una Content-Security-Policy completa no puede ir ahí, porque el panel y el edge rechazan ; en una línea de cabecera; una sola directiva como frame-ancestors 'self' sí funciona, y un nonce de todas formas hay que generarlo por respuesta, así que la política completa pertenece a tu aplicación.
cdnctl waf logs --account $ACCOUNT_UUID --range 1d
cdnctl waf show $REFERENCE_ID --account $ACCOUNT_UUID
Preguntas frecuentes sobre XSS
¿Cuál es la diferencia entre XSS almacenado, reflejado y basado en el DOM?
Dónde vive el payload. El XSS almacenado se guarda en el servidor y se sirve a todo el que abre la página. El XSS reflejado vuelve desde la misma petición, así que la víctima tiene que abrir un enlace manipulado. El XSS basado en el DOM lo crea tu propio JavaScript del lado del cliente al escribir en la página un valor controlado por el atacante, a menudo desde la URL; el servidor puede no verlo nunca.
¿HTTPS o un certificado válido protegen contra XSS?
No. TLS protege los datos en tránsito. Un payload de XSS lo entrega tu propio servidor o tu propio script a través de la misma conexión cifrada, y el candado hace que un formulario de login falso en tu dominio parezca más fiable, no menos.
¿Es suficiente HttpOnly para detener XSS?
No. Detiene que el script lea la cookie de sesión, lo que elimina un resultado. El script todavía puede enviar peticiones como el usuario, porque el navegador adjunta la cookie por él, y todavía puede leer y cambiar la página. HttpOnly es control de daños; la codificación de salida es la solución.
¿Puede un WAF detener el cross-site scripting?
Detiene muchos intentos reflejados y automatizados, porque los payloads de script en las peticiones son reconocibles. No puede ver el XSS basado en el DOM en el fragmento de la URL, no sabe cómo va a usar tu aplicación un valor almacenado, y se puede evadir con un payload a medida. Trátalo como una capa delante de un código correcto.
¿Debería seguir enviando X-XSS-Protection?
No. El filtro que controlaba se ha eliminado de los navegadores actuales, y en los antiguos se podía abusar de él. No envíes nada o envía X-XSS-Protection: 0, y usa una Content-Security-Policy en su lugar.
¿React o Vue hacen inmune mi aplicación al XSS?
Hacen seguro el caso común al escapar lo que imprimes. Sigues expuesto a través de dangerouslySetInnerHTML y v-html, de URLs de usuario en href que empiezan por javascript:, de escrituras directas en el DOM, y de HTML renderizado en el servidor que el framework no controla.
¿Qué es el self-XSS?
Un script que engañan a la víctima para que pegue en la consola de su propio navegador. No necesita ningún fallo en tu sitio, por eso los navegadores avisan cuando pegas algo en las herramientas de desarrollador. Es ingeniería social, no una vulnerabilidad en tu código.