Cada charla de seguridad web termina en la misma diapositiva: siempre escapa la entrada del usuario antes de insertarla en HTML. La razón son las entidades HTML — el mecanismo que convierte <script> en <script> y evita que el comentario de un usuario se convierta en un ataque XSS.
Pero escapar es solo la mitad de la historia. Desescapar — convertir & de nuevo en & — es igualmente importante y igualmente peligroso cuando se hace mal. Esta publicación cubre ambas direcciones, los caracteres que importan, y los errores que llevan a vulnerabilidades reales.
Los cinco caracteres que importan
HTML tiene cinco caracteres que son estructuralmente significativos — delimitan etiquetas, atributos y entidades. Si alguno de estos aparece en contenido de usuario y no se escapa, el navegador los interpreta como marcado HTML:
| Carácter | Entidad | Por qué importa |
|---|---|---|
< |
< |
Inicia una etiqueta. <script> es una etiqueta. |
> |
> |
Termina una etiqueta. |
& |
& |
Inicia una entidad. < es una entidad. |
" |
" |
Delimita valores de atributos. |
' |
' |
Delimita atributos entre comillas simples. |
Si estás insertando texto en el contenido de un elemento HTML, necesitas escapar <, > y &. Si estás insertando texto en un valor de atributo, también necesitas escapar " (o ' si el atributo usa comillas simples).
Cómo funcionan las entidades
Una entidad HTML es una secuencia especial que el navegador interpreta como un solo carácter:
- Entidades nombradas:
&,<,>,", — fáciles de leer, conjunto limitado. - Entidades numéricas:
<(decimal),<(hex) — cualquier carácter Unicode por punto de código. - Entidades nombradas para símbolos:
©(©),€(€),—(—) — nombres de conveniencia.
La entidad & es especial porque es el escape para & mismo. Si escapas & primero, luego escapas < y >, no haces doble escape. El orden importa:
Correcto: & → & → &lt; (correcto)
Incorrecto: < → < → &lt; (doble escape)
Siempre escapa & primero. Luego los otros caracteres. Así no harás doble escape.
Dónde se requiere escape
Contenido generado por usuario. Comentarios, publicaciones en foros, reseñas, mensajes — cualquier texto que provenga de un usuario y aparezca en una página debe escaparse. Un comentario que contiene <img src=x onerror=alert(1)> es un ataque XSS si no escapas < y >.
Datos en atributos. Si pones datos de usuario en un atributo href, src, alt o title, escapa los caracteres de entidad. Un valor como foo" onclick="alert(1) sale del atributo e inyecta un manejador de eventos.
Interpolación de cadenas JavaScript. Si estás construyendo HTML dentro de JavaScript:
// PELIGROSO: interpolación sin escapar
element.innerHTML = `<p>${userComment}</p>`;
// SEGURO: interpolación escapada
element.innerHTML = `<p>${escapeHtml(userComment)}</p>`;
innerHTML interpreta la cadena como HTML. Si userComment contiene marcado, se ejecuta. Siempre escapa antes de interpolare en HTML.
Renderizado del lado del servidor. Los motores de plantilla (Handlebars, Jinja, EJS, Razor) generalmente escapan automáticamente por defecto. Pero si usas un filtro “safe” o “raw” (como eliple triple de Handlebars {{{ o |safe de Django), estás desactivando el escape. Solo hazlo cuando controlas el contenido.
Cuándo desescapar
Desescapar invierte el proceso: < → <, & → &. Se necesita cuando:
- Mostrando datos codificados. Si una API devuelve HTML escapado (algunas lo hacen para prevenir el renderizado), necesitas desescapar antes de mostrar.
- Procesando datos de formulario. Los envíos de formularios pueden contener valores codificados en URL o codificados como entidades.
- Analizando entrada de usuario. Si un usuario pega texto codificado en HTML y quieres mostrarlo tal cual, desescapar primero — luego re-escapar si lo insertas de nuevo en HTML.
El peligro: desescapar solo cuando vas a mostrar, nunca cuando vas a almacenar. Almacena la forma original escapada. Desescapar en el momento del renderizado.
XSS: a un carácter escapado de distancia
El cross-site scripting (XSS) ocurre cuando la entrada del usuario sin escapar alcanza el analizador HTML del navegador. Las variantes más comunes:
XSS almacenado. Un usuario envía <script>steal(document.cookie)</script> como comentario. El servidor lo almacena sin escapar. Cada visitante que ve el comentario ejecuta el script.
XSS reflejado. Una URL contiene ?q=<script>alert(1)</script>. El servidor refleja el parámetro q en la página sin escapar. Cualquiera que haga clic en el enlace ejecuta el script.
XSS basado en DOM. JavaScript lee location.hash y lo inserta en el DOM vía innerHTML. El hash contiene marcado. El navegador lo renderiza.
Los tres se previenen con la misma cosa: escapa <, >, &, " y ' antes de insertar contenido en HTML.
La función de escape
Cada lenguaje tiene una. El patrón es el mismo:
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
El orden importa: & primero, luego los otros. Si escapas < antes que &, obtienes &lt; en lugar de <.
Para sistemas de alto rendimiento, usa una biblioteca. DOMPurify sanitiza HTML (permitiendo etiquetas seguras, eliminando las peligrosas). El DOMParser incorporado de la plataforma web y textContent también manejan escape implícitamente.
Errores comunes
- Escapar
&después de<— causa doble escape:&lt;en lugar de<. - Olvidar el contexto de atributo — escapar
<y>pero no"permite la inyección de atributos. - Usar
innerHTMLsin escapar — el vector XSS más común. - Confiar en respuestas del servidor — las APIs que devuelven HTML “sanitizado” pueden no sanitizar todo. Escapa en el cliente también.
- Desescapar demasiado pronto — desescapar en el momento de almacenar significa que el payload sin procesar queda en tu lista de base de datos, listo para ejecutarse si el renderizado olvida re-escapar.
Pruébalo
Si tienes texto codificado en HTML que necesitas decodificar, o texto sin procesar que necesitas escapar para inserción segura en HTML, usa una herramienta basada en navegador para que la conversión permanezca local.