Prevenir Cross-Site Scripting (XSS)
Cómo sanitizar input de usuario, escapar output y usar Content Security Policy para prevenir ataques XSS en aplicaciones web.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Visión general
Cross-Site Scripting (XSS) es un ataque de inyección donde scripts maliciosos se incrustan en sitios web de confianza. Cuando una víctima visita la página comprometida, el script se ejecuta en su navegador con los mismos privilegios que el sitio legítimo, permitiendo a los atacantes robar cookies de sesión, capturar keystrokes, o realizar acciones en nombre del usuario.
XSS consistentemente aparece en el OWASP Top 10 porque es tanto común como peligroso. Los tres tipos principales son XSS reflejado (URL maliciosa dispara el script), XSS almacenado (script malicioso se guarda en la base de datos y se sirve a todos los usuarios), y XSS basado en DOM (JavaScript client-side escribe datos no confiables a la página sin escapar).
La defensa fundamental es simple pero frecuentemente olvidada: nunca confíes en el input de usuario. Todos los datos de usuarios, APIs o fuentes externas deben ser escapados antes de renderizarse en HTML, JavaScript, CSS o URLs.
Cuándo usarlo
Usa esta receta cuando:
- Renderizas contenido generado por usuarios en páginas web
- Construyes dashboards de admin, sistemas de comentarios o foros
- Manejas query parameters o fragmentos de URL en routing client-side
- Implementas editores de rich text o renderizadores de markdown
- Agregas widgets o embeds de terceros a tu aplicación
- Realizas auditorías de seguridad de código frontend
Solución
Escaping HTML (Server-Side)
import html
user_input = '<script>alert("xss")</script>'
safe_output = html.escape(user_input)
# safe_output: <script>alert("xss")</script>
Escaping automático de React
// React escapa {expresiones} automáticamente — seguro por defecto
function UserProfile({ bio }) {
return <div className="bio">{bio}</div>;
// <script> se convierte en <script> automáticamente
}
// PELIGROSO — solo usar cuando controlas la fuente
function DangerousHtml({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Content Security Policy (HTTP Header)
Content-Security-Policy: default-src 'self';
script-src 'self' https://trusted-cdn.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
Sanitización de HTML (DOMPurify)
import DOMPurify from 'dompurify';
const dirty = '<p>Hello</p><script>alert("xss")</script>';
const clean = DOMPurify.sanitize(dirty);
// clean: <p>Hello</p>
Explicación
- Escaping HTML: Convierte caracteres como
<,>,", y&en entidades HTML para que los navegadores los traten como texto, no como markup. Esta es la defensa más importante y debe aplicarse a todos los datos no confiables. - Escaping automático de React/Vue/Angular: Los frameworks modernos escapan valores interpolados por defecto. Las vulnerabilidades XSS usualmente ocurren cuando desarrolladores bypassan esto con
dangerouslySetInnerHTML,v-html, o escape hatches similares. - Content Security Policy (CSP): Un mecanismo de seguridad del navegador que restringe dónde pueden cargarse scripts, estilos y otros recursos. Incluso si un atacante inyecta un
<script>tag, CSP previene su ejecución si la fuente no está en la lista blanca. - Sanitización de HTML: Cuando necesitas permitir algo de HTML (como
<b>o<a>tags en comentarios), usa un sanitizer para eliminar tags y atributos peligrosos mientras preservas markup seguro.
Variantes
| Defensa | Capa | Efectividad | Mejor para |
|---|---|---|---|
| Escaping de output | Server/Client | Esencial | Todo dato no confiable en HTML |
| Headers CSP | Browser | Fuerte | Defensa en profundidad, bloqueo de scripts inline |
| Sanitización de HTML | Server/Client | Fuerte | Rich text, editores WYSIWYG |
| Cookies HttpOnly | Server | Fuerte | Prevención de robo de cookies de sesión |
Lo que funciona
- Escapa todos los datos no confiables: parámetros de URL, inputs de formularios, campos de base de datos, respuestas de API, uploads de archivos, e incluso headers HTTP pueden ser manipulados por atacantes.
- Usa los defaults del framework: deja que React, Vue o Angular manejen el escaping. Solo usa inserción de HTML raw cuando sea absolutamente necesario y sanitiza el input primero.
- Implementa un CSP estricto: empieza con
default-src 'self'y pon en lista blanca solo los dominios requeridos. Evita'unsafe-inline'y'unsafe-eval'para scripts. - Configura
HttpOnlyySecureen cookies:HttpOnlypreviene que JavaScript lea cookies de sesión, mitigando el impacto de XSS.Secureasegura que las cookies solo se envíen sobre HTTPS. - Valida input, no solo output: rechaza caracteres inesperados en el boundary (ej. solo permite usernames alfanuméricos) para que datos malos nunca entren a tu sistema.
- Audita dependencias: XSS también puede venir de paquetes npm comprometidos o scripts de terceros. Usa
npm audity audita dependencias cargadas desde dominios externos.
Errores comunes
- Usar
innerHTMLcon input de usuario: esta es la causa más común de XSS en JavaScript vanilla. UsatextContenten su lugar para texto plano. - Escapar solo una vez: si escapas datos antes de almacenarlos en la base de datos (
<se convierte en&lt;), corrompes los datos. Escapa en la capa de output, no en la capa de input. - Olvidar URLs y CSS:
javascript:alert(1)en unhrefoexpression()en CSS pueden ejecutar código. Valida URLs con listas blancas y sanitiza CSS. - CSP demasiado permisiva:
script-src 'unsafe-inline' 'unsafe-eval' *desactiva la mayor parte de la protección de CSP. Sé específico con tu policy. - Confiar en validación client-side: los atacantes bypassan checks del frontend por completo. Todo escaping y validación debe ser enforceado server-side.
Preguntas frecuentes
P: ¿Es dangerouslySetInnerHTML de React seguro si escapo el input?
R: Solo si escapas o sanitizas correctamente. Un solo error en tu lógica de escaping expone a tus usuarios. Prefiere librerías de sanitización como DOMPurify sobre escaping manual.
P: ¿Puede CSP prevenir completamente XSS? R: No, pero eleva considerablemente la barrera. Un CSP estricto bloquea scripts inline y scripts externos no autorizados, convirtiendo XSS de una vulnerabilidad crítica en un no-problema en muchos casos.
P: ¿Qué pasa con XSS basado en DOM?
R: El XSS DOM ocurre cuando JavaScript client-side lee de location.hash, document.URL, o localStorage y escribe al DOM sin escapar. Trata todas las fuentes del DOM como no confiables y escapa antes de la inserción.
P: ¿Debería escapar datos antes de almacenarlos en la base de datos? R: No. Almacena los datos raw y escapa en output. Escapar al almacenar significa que tus datos están atados a un formato de output específico (HTML) y los hace inutilizables para APIs JSON, emails, o generación de PDFs.
¿Esta solución está lista para producción?
Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.
¿Cuáles son las características de rendimiento?
El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.
¿Cómo depuro problemas con este enfoque?
Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.
Soluciones Avanzadas
Escaping consciente del contexto (Python)
Diferentes contextos de output requieren diferentes reglas de escaping. HTML body, atributos, JavaScript y URLs cada uno necesita manejo específico:
import html
import urllib.parse
import json
def escape_for_html(text: str) -> str:
"""Escapar para contexto HTML body."""
return html.escape(text, quote=True)
def escape_for_attribute(text: str) -> str:
"""Escapar para contexto de atributo HTML."""
return html.escape(text, quote=True)
def escape_for_javascript(text: str) -> str:
"""Escapar para contexto de string JavaScript."""
# Usar JSON encoding para output seguro de string JS
return json.dumps(text)
def escape_for_url(text: str) -> str:
"""Escapar para contexto de parámetro URL."""
return urllib.parse.quote(text, safe='')
def safe_output(value: str, context: str = "html") -> str:
"""Aplicar escaping apropiado al contexto."""
escapers = {
"html": escape_for_html,
"attribute": escape_for_attribute,
"javascript": escape_for_javascript,
"url": escape_for_url,
}
escaper = escapers.get(context, escape_for_html)
return escaper(value)
# Uso en un template
user_name = '<script>alert("xss")</script>'
user_url = 'javascript:alert(1)'
user_data = '{"key":"value</script><script>alert(1)</script>"}'
# HTML body
print(f'<span>{safe_output(user_name, "html")}</span>')
# <span><script>alert("xss")</script></span>
# Atributo
print(f'<a title="{safe_output(user_name, "attribute")}">link</a>')
# <a title="<script>alert("xss")</script>">link</a>
# JavaScript
print(f'<script>var data = {safe_output(user_data, "javascript")};</script>')
# <script>var data = "{\"key\":\"value</script><script>alert(1)</script>\"}";</script>
# URL (también validar protocolo)
def safe_url(url: str) -> str:
"""Validar protocolo URL y escapar."""
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https', 'mailto', ''):
return '' # Bloquear javascript:, data:, etc.
return escape_for_attribute(url)
print(f'<a href="{safe_url(user_url)}">click</a>')
# <a href="">click</a> (javascript: bloqueado)
DOMPurify con configuración personalizada
Para editores de rich text, configura DOMPurify para permitir tags específicas mientras bloquea las peligrosas:
import DOMPurify from 'dompurify';
// Config personalizada: permitir enlaces y formato, bloquear iframes y scripts
const config = {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li',
'blockquote', 'code', 'pre', 'h1', 'h2', 'h3',
],
ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
};
// Añadir un hook para forzar rel="noopener noreferrer" en todos los enlaces
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && node.getAttribute('href')) {
node.setAttribute('rel', 'noopener noreferrer');
node.setAttribute('target', '_blank');
}
});
const dirty = `
<p>Hello <a href="javascript:alert(1)">click</a></p>
<iframe src="evil.com"></iframe>
<script>alert("xss")</script>
<img src=x onerror="alert(1)">
`;
const clean = DOMPurify.sanitize(dirty, config);
// clean: <p>Hello <a target="_blank" rel="noopener noreferrer">click</a></p>
// (iframe, script, img eliminados; javascript: href removido)
Trusted Types API (Chrome/Edge)
Trusted Types enforce que solo valores sanitizados pueden asignarse a sinks peligrosos como innerHTML:
// Definir una policy que sanitiza antes de insertar
const sanitizerPolicy = trustedTypes.createPolicy('sanitizer', {
createHTML: (input) => DOMPurify.sanitize(input),
});
// Ahora innerHTML solo acepta TrustedHTML, no strings raw
// document.body.innerHTML = userInput; // TypeError en navegadores con TT
document.body.innerHTML = sanitizerPolicy.createHTML(userInput); // OK
// Content-Security-Policy header para enforcear:
// Content-Security-Policy: require-trusted-types-for 'script';
CSP con nonces para scripts inline
Cuando necesitas scripts inline, usa nonces por-request en lugar de unsafe-inline:
import secrets
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route('/')
def index():
# Generar un nonce único por request
nonce = secrets.token_urlsafe(16)
csp = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}'; "
f"style-src 'self' 'nonce-{nonce}'; "
f"img-src 'self' data: https:; "
f"connect-src 'self' https://api.example.com; "
f"object-src 'none'; "
f"base-uri 'self'"
)
response = app.make_response(render_template_string(
'''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style nonce="{{ nonce }}">
body { font-family: sans-serif; }
</style>
</head>
<body>
<h1>Hello</h1>
<script nonce="{{ nonce }}">
console.log("Safe inline script");
</script>
</body>
</html>''',
nonce=nonce
))
response.headers['Content-Security-Policy'] = csp
return response
Renderizado de markdown con sanitización
Cuando renderizas markdown enviado por usuarios, sanitiza el output HTML:
import { marked } from 'marked';
import DOMPurify from 'dompurify';
// Configurar marked para deshabilitar HTML raw
marked.setOptions({
// No permitir pass-through de HTML raw
sanitize: false, // marked deprecó sanitize; usar DOMPurify en su lugar
});
function renderMarkdown(markdownText) {
// Paso 1: Convertir markdown a HTML
const rawHtml = marked.parse(markdownText);
// Paso 2: Sanitizar el output HTML
const cleanHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'code', 'pre', 'a',
'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
ALLOWED_ATTR: ['href', 'title', 'rel', 'target'],
});
return cleanHtml;
}
// Uso
const userInput = `
## Hello <script>alert("xss")</script>
[Click here](javascript:alert(1))
\`\`\`javascript
console.log("safe code block");
\`\`\`
`;
const safe = renderMarkdown(userInput);
// <h2>Hello </h2>
// <p><a>Click here</a></p>
// <pre><code class="language-javascript">console.log("safe code block");</code></pre>
Mejores Prácticas Adicionales
- Usa
textContenten lugar deinnerHTMLpara texto plano. Esta es la prevención XSS más simple en JavaScript vanilla:
// INCORRECTO: vulnerable a XSS
element.innerHTML = userInput;
// CORRECTO: trata el input como texto plano
element.textContent = userInput;
- Configura
X-Content-Type-Options: nosniffen todas las respuestas. Esto previene que los navegadores hagan MIME-sniffing de respuestas como ejecutables:
// Middleware Express
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
Errores Comunes Adicionales
- Confiar en URLs
data:en image src. Las URLsdata:pueden contener HTML o SVG con scripts embebidos. Valida URLs de imágenes contra una lista blanca de protocolos:
function safeImageSrc(url) {
const allowed = /^https?:\/\/|^\/[^/]/;
if (!allowed.test(url)) {
return '/images/placeholder.png';
}
return url;
}
// Bloquear: data:text/html,<script>alert(1)</script>
// Bloquear: javascript:alert(1)
// Permitir: https://cdn.example.com/image.png
// Permitir: /images/avatar.png
- Sanitizar solo en el cliente. Si sanitizas HTML en el navegador pero almacenas el input raw server-side, un atacante puede bypassar el sanitizer del cliente y enviar HTML raw directamente a la API. Siempre sanitiza server-side antes de almacenar:
import bleach
def sanitize_html(content: str) -> str:
"""Sanitización HTML server-side usando bleach."""
return bleach.clean(
content,
tags={'p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre'},
attributes={'a': ['href', 'title', 'rel', 'target']},
protocols=['https', 'http', 'mailto'],
strip=True,
)
# Ruta Flask
@app.route('/api/comment', methods=['POST'])
def post_comment():
raw_content = request.json.get('content', '')
safe_content = sanitize_html(raw_content)
# Almacenar safe_content en base de datos
db.save_comment(safe_content)
return jsonify({'success': True})
Preguntas Frecuentes Adicionales
¿Cómo pruebo vulnerabilidades XSS?
Usa scanners automatizados y testing manual. Inyecta payloads comunes y verifica que sean escapados:
// Payloads de test para probar en campos de input
const xssPayloads = [
'<script>alert(1)</script>',
'"><script>alert(1)</script>',
"';alert(1);//",
'<img src=x onerror=alert(1)>',
'<svg onload=alert(1)>',
'javascript:alert(1)',
'<iframe src=javascript:alert(1)>',
'"><img src=x onerror=alert(1)>',
];
// Test automatizado con Playwright
import { test, expect } from '@playwright/test';
test('campo de comentario escapa XSS', async ({ page }) => {
await page.goto('/posts/1');
for (const payload of xssPayloads) {
await page.fill('[name=comment]', payload);
await page.click('button[type=submit]');
// Verificar que el payload se muestra como texto, no se ejecuta
const bodyText = await page.textContent('body');
expect(bodyText).toContain(payload);
// Verificar que no se disparó un alert dialog
page.on('dialog', dialog => {
throw new Error(`XSS disparado con payload: ${payload}`);
});
}
});
¿Qué es mutation XSS y cómo lo prevengo?
Mutation XSS ocurre cuando el parser HTML del navegador reinterpreta HTML sanitizado de forma diferente a lo que el sanitizer esperaba. Esto puede pasar con vectores mXSS en asignaciones innerHTML. Para prevenirlo:
- Usa DOMPurify, que maneja vectores mXSS
- Evita
innerHTMLpara contenido de usuario — usatextContento auto-escaping del framework - Configura un CSP estricto que bloquee scripts inline incluso si mXSS bypassa la sanitización
¿Debo usar Subresource Integrity (SRI) para scripts de terceros?
Sí. SRI asegura que un script de terceros no ha sido modificado. Si el hash no coincide, el navegador se niega a ejecutarlo:
<script src="https://cdn.example.com/library.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script> Recursos Relacionados
Input Validation
How to validate user input safely using schemas, type checking, and sanitization across Python, JavaScript, and Java.
RecipePrevent SQL Injection Attacks
How to write parameterized queries and use ORMs to eliminate SQL injection vulnerabilities across Python, JavaScript, and Java.
RecipeHandle Errors in APIs
Patterns for consistent, predictable API error handling across multiple languages and frameworks.
RecipeSecure APIs with HTTP Security Headers
How to configure essential security headers like HSTS, CSP, and X-Frame-Options to protect APIs and web applications from common attacks.
RecipeProtect Web Forms Against CSRF Attacks
How to prevent Cross-Site Request Forgery attacks using synchronizer tokens, SameSite cookies, and double-submit cookie patterns.