StackPractices
beginner Por Mathias Paulenko

Escapar Entidades HTML

Cómo escapar entidades HTML para prevenir ataques XSS en Python, Java y JavaScript.

Temas: security

Visión General

El escaping de entidades HTML convierte caracteres con significado especial en HTML (<, >, &, ", ') en sus referencias de entidad correspondientes (&lt;, &gt;, &amp;, &quot;, &#x27;). Sin escaping, datos no confiables pueden inyectar markup o scripts, resultando en cross-site scripting (XSS). Esta recipe cubre escaping de HTML en Python, JavaScript y Java.

Cuándo Usar

Usa este recurso cuando:

  • Renderices contenido generado por usuarios dentro de templates HTML
  • Construyas strings HTML en vivo a partir de datos externos (APIs, bases de datos, archivos)
  • Generes emails HTML que incluyan nombres o direcciones de destinatarios
  • Embebas datos JSON dentro de tags <script> de forma segura

Solución

Python

# html.escape (Python 3.2+)
import html

user_input = '<script>alert("xss")</script>'
safe = html.escape(user_input)
print(safe)
# Output: '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
# MarkupSafe para templates Jinja2 (escaping automático)
# pip install markupsafe
from markupsafe import Markup, escape

def render_comment(text):
    return Markup('<p>{}</p>').format(escape(text))

JavaScript

// Mapa manual de entidades para escaping liviano
function escapeHtml(text) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;'
  };
  return text.replace(/[&<>"']/g, char => map[char]);
}

const userInput = '<img src=x onerror=alert(1)>';
console.log(escapeHtml(userInput));
// Output: '&lt;img src=x onerror=alert(1)&gt;'
// Usando DOM API en entornos de browser
function escapeHtmlDom(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

Java

// Apache Commons Text StringEscapeUtils
// Maven: org.apache.commons:commons-text
import org.apache.commons.text.StringEscapeUtils;

public class HtmlEscaper {
    public static String escape(String input) {
        return StringEscapeUtils.escapeHtml4(input);
    }
}
// OWASP Java Encoder
// Maven: org.owasp.encoder:encoder
import org.owasp.encoder.Encode;

public class SafeHtml {
    public static String escapeForBody(String input) {
        return Encode.forHtml(input);
    }
    public static String escapeForAttribute(String input) {
        return Encode.forHtmlAttribute(input);
    }
}

Explicación

El escaping de HTML es un encoding específico por contexto. En el body de un elemento HTML, < debe convertirse en &lt; para que el browser lo trate como texto literal, no como inicio de un tag. Dentro de un atributo HTML delimitado por comillas dobles, " debe convertirse en &quot; para evitar que el atributo se cierre prematuramente. Dentro de un bloque <script>, se necesita encoding adicional de JavaScript porque </script> puede terminar el contexto de script incluso si está HTML-escaped.

El html.escape de Python cubre los cinco caracteres críticos. MarkupSafe es el motor detrás del auto-escaping de Jinja2 y está probado en batalla. En JavaScript, el reemplazo manual con regex es suficiente para la mayoría de los casos; el enfoque de DOM API es más seguro pero solo funciona en browsers. El StringEscapeUtils de Java maneja entidades HTML4 comprehensivamente, mientras que OWASP Encoder proporciona control fino por contexto.

Variantes

TecnologíaLibrería / EnfoqueContextoNotas
Pythonhtml.escapeHTML bodyStdlib, cubre < > & " '
Pythonmarkupsafe.escapeTemplatesUsado por Jinja2, auto-escapa por defecto
JavaScriptRegex manualHTML bodyLiviano, sin dependencias
JavaScriptDOM textContentHTML bodySolo browser, maneja todas las entidades
JavaStringEscapeUtils.escapeHtml4HTML bodyApache Commons, cubre muchas entidades
JavaEncode.forHtmlHTML body + atributosOWASP, variantes específicas por contexto

Lo que funciona

  • Escapa en el punto de renderizado, no en almacenamiento: Datos escapados en una base de datos hacen que búsqueda y display sean inconsistentes
  • Usa motores de templates con auto-escaping: Jinja2, Django templates, React JSX y Vue templates escapan por defecto
  • El contexto importa: HTML body, atributo HTML, CSS, JavaScript y contextos de URL requieren reglas de encoding diferentes
  • Evita innerHTML con strings crudos: Usa textContent o template literals con funciones de escaping
  • Audita componentes de terceros: Librerías que bypassan escaping (ej. dangerouslySetInnerHTML en React) deben revisarse cuidadosamente

Errores Comunes

  • Escapar demasiado temprano: Sanitizar al input y almacenar texto escapado rompe búsqueda full-text y ordenamiento
  • Doble escaping: &lt; renderizado de nuevo se convierte en &amp;lt;, mostrando &lt; literal a los usuarios
  • Encoding de contexto equivocado: Strings HTML-escaped son inseguros dentro de contextos JavaScript sin encoding JS adicional
  • Usar innerHTML para texto de usuario: Incluso si la fuente es “confiable”, innerHTML es innecesario y riesgoso; prefiere textContent
  • Ignorar contexto de atributo: href="{{ userUrl }}" necesita URL encoding, no solo HTML encoding

Soluciones Avanzadas

Escaping específico por contexto para script

Embeber datos JSON dentro de tags <script> requiere más que HTML escaping. La secuencia </script> termina el bloque de script independientemente del encoding de entidades HTML:

function safeJsonInScript(data) {
  // 1. Stringificar el JSON
  let json = JSON.stringify(data);

  // 2. Escapar el forward slash en secuencias </script>
  json = json.replace(/</g, '\\u003c');

  // 3. También escapar <!-- para prevenir inyección de comentarios HTML
  json = json.replace(/-->/g, '--\\u003e');

  return json;
}

// Uso en un template server-rendered
const userData = { name: 'John</script><script>alert(1)</script>', role: 'admin' };
const safeJson = safeJsonInScript(userData);
// Output: {"name":"John\\u003c/script>\\u003cscript>alert(1)\\u003c/script>","role":"admin"}
import json
import re

def safe_json_for_script(data):
    """Serializar JSON seguro para embeber en tags <script>."""
    json_str = json.dumps(data)
    # Escapar <, >, y separadores de línea para prevenir breakout del contexto de script
    json_str = json_str.replace('<', '\\u003c')
    json_str = json_str.replace('>', '\\u003e')
    json_str = json_str.replace('\u2028', '\\u2028')  # Separador de línea
    json_str = json_str.replace('\u2029', '\\u2029')  # Separador de párrafo
    return json_str

# Uso en Flask/Jinja2
@app.route('/dashboard')
def dashboard():
    user_data = {'name': 'Alice', 'permissions': ['read', 'write']}
    return render_template('dashboard.html',
                           safe_data=safe_json_for_script(user_data))

Escaping para contexto de URL

Las URLs en atributos href y src necesitan URL encoding, no solo HTML escaping. Usando URIs javascript:, los atacantes pueden ejecutar scripts:

from urllib.parse import quote, urlparse

def safe_url(url):
    """Validar y sanitizar URLs para atributos href."""
    # Rechazar schemes javascript: y data:
    parsed = urlparse(url)
    if parsed.scheme not in ('http', 'https', 'mailto', 'tel', ''):
        return ''  # Rechazar schemes peligrosos

    # Re-encodear la URL de forma segura
    return quote(url, safe=':/?&=%#')

# Uso
user_url = 'javascript:alert(1)'
print(safe_url(user_url))  # Output: '' (rechazada)

user_url2 = 'https://example.com/path?q=test'
print(safe_url(user_url2))  # Output: 'https://example.com/path?q=test'
function safeUrl(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    const allowedProtocols = ['http:', 'https:', 'mailto:', 'tel:'];
    if (!allowedProtocols.includes(parsed.protocol)) {
      return '';
    }
    return parsed.href;
  } catch {
    return '';
  }
}

// Uso en DOM
const link = document.createElement('a');
link.href = safeUrl(userInput);
if (link.href) {
  document.body.appendChild(link);
}

Escaping para contexto CSS

Inyectar datos de usuario en CSS requiere su propio encoding. Datos sin escapar pueden romper el contexto CSS e inyectar markup:

import org.owasp.encoder.Encode;

public class SafeCss {
    // Para contexto de string CSS
    public static String forCssString(String input) {
        return Encode.forCssString(input);
    }

    // Para contexto de URL CSS
    public static String forCssUrl(String input) {
        return Encode.forCssUrl(input);
    }
}

// Uso: <div style="color: {{userColor}}">
String safeColor = SafeCss.forCssString(userInput);
// Escapa backslash, comillas, angle brackets, y newlines

Escaping de HTML en templates de Go

package main

import (
    "html"
    "html/template"
    "net/http"
)

func renderTemplate(w http.ResponseWriter, r *http.Request) {
    tmpl, err := template.New("page").Parse(`
        <h1>{{.Title}}</h1>
        <p>{{.Content}}</p>
        <a href="{{.URL}}">Link</a>
    `)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    data := struct {
        Title   string
        Content string
        URL     string
    }{
        Title:   "<script>alert(1)</script>",
        Content: "User <b>comment</b> & more",
        URL:     "https://example.com",
    }

    // html/template auto-escapa por contexto
    tmpl.Execute(w, data)
}

// Escaping manual con html.EscapeString
func manualEscape(s string) string {
    return html.EscapeString(s)
}

Wrapper seguro para dangerouslySetInnerHTML en React

Cuando debes renderizar HTML en React, envuélvelo con sanitización:

import DOMPurify from 'dompurify';

function SafeHtml({ html, ...props }) {
  const cleanHtml = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
    ALLOW_DATA_ATTR: false,
  });

  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} {...props} />;
}

// Uso
function Comment({ text }) {
  // Si text es texto plano, usa children (auto-escaped)
  // Si text contiene HTML de una fuente confiable, usa SafeHtml
  return <SafeHtml html={text} />;
}

Preguntas frecuentes

¿Cuál es la diferencia entre HTML escaping y HTML sanitization?

Escaping transforma cada carácter especial en una referencia de entidad, preservando el texto original pero haciéndolo inerte. Sanitization remueve o altera markup peligroso (ej. eliminando tags <script>) mientras preserva HTML seguro como <b>. Escapa cuando no necesites HTML; sanitiza cuando aceptes un subconjunto de HTML.

¿Necesito escapar datos dentro de respuestas JSON?

No. Las respuestas JSON no son contextos HTML. Escapa JSON solo cuando lo embebas dentro de una página HTML, como en un tag <script> o un atributo HTML. En esos casos, escapa el string JSON para el contexto HTML, y si está dentro de <script>, evita también secuencias </script>.

¿Debo escapar comillas simples (') o solo comillas dobles (")?

Escapa ambas. En atributos HTML, las comillas simples pueden delimitar atributos (attr='value'), así que comillas simples sin escapar rompen el atributo. OWASP Encoder escapa ambas por defecto. El html.escape de Python escapa comillas simples cuando quote=True (por defecto desde Python 3.8).