XSS y set:html: escapar, sanear y confiar
El escape automático de las expresiones como primera defensa de Astro contra la inyección de HTML, set:html como la renuncia explícita a esa red y por tanto toda tu superficie de XSS, la diferencia irreductible entre escapar y sanear, y la regla de oro de confiar solo en contenido propio o previamente saneado antes de inyectarlo crudo en la página.
El cross-site scripting es la herida más antigua y persistente de la web: un atacante consigue que su JavaScript corra dentro de la página de otro, con la sesión, las cookies y los permisos de la víctima. Casi siempre nace del mismo error elemental —tratar como marcado algo que era un dato— y por eso la primera defensa no es una librería ni una cabecera, sino una disciplina sobre la frontera entre lo que se pinta como texto y lo que se interpreta como HTML. Astro toma partido por defecto en esa frontera: escapa. Y solo te deja cruzarla si escribes, con nombre y letra, la directiva que apaga la red.
- Entender el escape automático de las expresiones
{}como la defensa por defecto de Astro. - Reconocer
set:htmlcomo la renuncia explícita a ese escape y como toda tu superficie de XSS. - Distinguir escapar de sanear y saber cuál es la herramienta correcta en cada caso.
- Adoptar la regla de confiar solo en contenido propio o saneado antes de inyectarlo crudo.
El escape por defecto: la primera muralla
Cuando escribes {valor} en una plantilla de Astro, el resultado se escapa. Los caracteres con significado en HTML —el menor-que, el mayor-que, el ampersand, las comillas— se sustituyen por sus entidades, de modo que una cadena como <img onerror=alert(1)> se pinta como texto visible y no como una etiqueta que el navegador ejecute. Esa conversión es automática, silenciosa y total: no hay expresión interpolada en una plantilla .astro que se libre de ella.
---
const comentario = '<img src=x onerror="robarSesion()">';
---
<p>{comentario}</p>
<!-- el navegador recibe texto escapado, nunca una etiqueta viva -->
Conviene medir lo que esto significa mirando el pasado. Durante años, apilar HTML fue concatenar cadenas: un echo que volcaba sin filtro lo que llegaba en la URL, un template que interpolaba en crudo. En ese mundo el XSS no era un fallo excepcional, era el comportamiento por defecto, y cada dato sin escapar una bomba armada. Astro invierte ese default: en su plantilla, todo dato es texto inerte salvo que declares lo contrario. La seguridad deja de depender de que el programador se acuerde de escapar en cada punto, y pasa a ser la posición de reposo del sistema.
Conviene un matiz que separa a quien entiende el escape de quien solo lo invoca: escapar es sensible al contexto. Un mismo dato exige transformaciones distintas según dónde aterrice —dentro del texto de un elemento, dentro del valor de un atributo, dentro de una URL, dentro de un bloque de JavaScript—, y lo que es inofensivo en un contexto puede ser fatal en otro. La interpolación con {} en el cuerpo de la plantilla cubre los contextos de texto y de atributo, que son los habituales; los casos que se salen de ahí —una URL que acaba en un href, un valor que se cuela dentro de un <script>— piden cuidado aparte, y son justo los puntos donde el escape estándar no basta por sí solo.
set:html: apagar la red a propósito
Existe exactamente una forma de pedirle a Astro que no escape: la directiva set:html. Le entregas una cadena y la inyecta tal cual, interpretando sus etiquetas como marcado real. Es la única puerta por la que entra HTML sin filtrar, y por eso mismo es un punto que hay que tratar con reverencia.
---
const articulo = '<strong>Novedad</strong> del blog';
---
<div set:html={articulo} />
Cada set:html de tu base de código es un lugar donde la muralla se abre. Su gran virtud es ser visible: un grep -r "set:html" te devuelve en segundos el inventario completo de tu superficie de inyección, sin rincones ocultos. Trata cada aparición como una promesa firmada de que esa cadena es de confianza o ha sido saneada; si no puedes garantizar ninguna de las dos cosas, no uses la directiva. La existencia de una única vía auditable es, en sí misma, una decisión de diseño a favor de la seguridad.
Hay una variante del riesgo que set:html no agota y conviene nombrar: los contextos de atributo peligroso. Una URL controlada por el usuario que termina en un href puede portar el esquema javascript:, y un atributo de evento como onclick inyectado ejecuta código sin necesidad siquiera de una etiqueta <script>. Por eso, cuando compongas atributos a partir de datos ajenos —sobre todo enlaces— valida el esquema de la URL y admite solo http, https o rutas relativas: el escape de texto no mira dentro de un href, y ahí vive una puerta que se cierra aparte.
Escapar no es sanear
Aquí vive la confusión que hunde a muchos equipos. Escapar —lo que hace la llave— neutraliza todo el HTML convirtiéndolo en texto: seguro, pero destruye el marcado legítimo, así que no sirve cuando de verdad quieres pintar el <strong> o el enlace de un artículo. Sanear es la operación distinta que necesitas antes de un set:html: recibe HTML potencialmente hostil y devuelve HTML limpio, eliminando solo lo peligroso —scripts, manejadores onerror, URLs javascript:— y conservando lo inofensivo.
import sanitizeHtml from 'sanitize-html';
const limpio = sanitizeHtml(sucio, {
allowedTags: ['b', 'i', 'strong', 'em', 'a', 'p', 'ul', 'ol', 'li'],
allowedAttributes: { a: ['href'] },
});
La clave de un buen saneador es que trabaja por lista de permitidos, no de prohibidos: en vez de intentar enumerar todo lo malo —una carrera perdida contra la imaginación del atacante— declara lo poco que es bueno y descarta el resto por omisión. Y hay una cuestión de cuándo: lo ideal es sanear en el momento de la ingesta —cuando el contenido entra a tu almacén— para guardar ya lo limpio, no en cada render; así el coste se paga una vez y nunca sirves algo sin filtrar por un descuido en la capa de vista.
El instante correcto para sanear es cuando el contenido entra a tu sistema, no cuando sale. Si limpias al guardar, almacenas ya una versión de confianza y la sirves mil veces sin volver a pensarlo; si delegas el saneo a la capa de vista, basta una plantilla nueva que olvide el paso para servir veneno. Y sanear una vez por escritura, en lugar de una vez por cada lectura, es además más barato. La excepción es cuando el dato crudo debe conservarse por otras razones: entonces guarda el original, pero deriva y cachea aparte una copia saneada para pintar, y no pintes jamás el crudo.
Confiar solo en lo propio
La regla que ordena todo lo anterior es de confianza, no de técnica. El terreno legítimo de set:html es el contenido que tú controlas: el Markdown que escribiste y que las colecciones de contenido compilan a HTML seguro con .render(), o el HTML de un CMS que saneaste al guardarlo. El terreno prohibido es todo lo que llega de fuera sin pasar por tu filtro: un parámetro de la URL, un campo de formulario, un comentario de usuario, la respuesta de una API de terceros.
---
import { getEntry, render } from 'astro:content';
const entrada = await getEntry('blog', 'seguridad');
const { Content } = await render(entrada);
---
<article><Content /></article>
Un error recurrente merece señalarse: la respuesta de una API de terceros no es contenido propio, por reputado que sea el servicio. Un endpoint ajeno puede devolver HTML manipulado, un campo con etiquetas, un dato que alguien inyectó aguas arriba; si lo pintas con set:html confiando en la marca del proveedor, heredas su superficie de ataque como si fuera tuya. La procedencia se mide por quién controla el contenido, no por quién lo sirve: si no lo escribiste tú ni lo saneaste tú, es ajeno y va tratado como tal.
Esa distinción tiene nombres clásicos. El XSS reflejado rebota un dato de la petición directo a la respuesta —el clásico buscador que pinta tu término sin escapar—. El XSS almacenado es peor: el payload se guarda en la base de datos y se sirve a cada visitante, convirtiendo un solo envío en un ataque persistente. Contra ambos, la plantilla de Astro te cubre por defecto; el único punto donde bajas la guardia es donde escribes set:html, y ahí la pregunta no es técnica sino de procedencia: ¿de dónde salió esta cadena, y quién responde por ella?
flowchart TD
SRC[contenido a pintar] --> Q{de donde viene}
Q -->|lo escribi yo| TRUST[contenido propio]
Q -->|usuario o tercero| UNSAFE[contenido ajeno]
TRUST --> SH[set:html directo]
UNSAFE --> SAN[pasar por un saneador]
SAN --> SH
UNSAFE --> ESC[o interpolar con llaves y escapar]
style SH fill:#f9e2af,color:#11111b
style ESC fill:#a6e3a1,color:#11111b
style UNSAFE fill:#f38ba8,color:#11111bLa mejor propiedad de set:html no es lo que hace, sino que se puede buscar. Cada apagado del escape lleva su nombre escrito, de modo que auditar tu exposición a la inyección es un grep. Convierte esa búsqueda en un ritual: antes de cada despliegue, lista tus set:html y verifica que cada cadena que alimentan es propia o pasó por un saneador. Un set:html sobre un dato sin procedencia clara es un incidente esperando fecha.
Cuando renderizas Markdown de tus colecciones con render y el componente Content, no necesitas set:html ni saneas nada: Astro compila ese Markdown —que escribiste tú— a HTML de confianza. El peligro no está en tu contenido editorial, sino en el HTML de origen desconocido. Reserva la desconfianza para lo que no controlas y no la malgastes en tu propio material.
El escape por defecto protege lo que Astro pinta en el servidor, pero no alcanza al DOM que tu JavaScript de cliente construye después. Dentro de una isla, asignar a innerHTML, llamar a insertAdjacentHTML o a document.write con un dato que venga de la URL, de location.hash o de un fetch reabre el XSS del lado del navegador —el llamado DOM-based—, y ninguna defensa del servidor lo ve. La regla se traslada intacta al cliente: para meter texto usa textContent, no innerHTML; y si de verdad necesitas HTML dinámico en el navegador, sanéalo allí con la misma desconfianza. El escape de la plantilla es una muralla del servidor; el cliente tiene sus propias puertas.
Escape por defecto
Toda expresion con llaves en una plantilla astro se escapa sola. El texto hostil se pinta inerte.
set:html
La unica puerta a HTML crudo. Grep-eable, auditable, y toda tu superficie de inyeccion.
Sanear vs escapar
Escapar mata todo el marcado. Sanear borra solo lo peligroso y conserva lo legitimo por lista de permitidos.
Procedencia
La pregunta antes de un set:html no es como, sino de donde: propio o saneado, nunca ajeno en crudo.
Para desarmar de raíz el cross-site scripting conviene verlo no como un bug de librería sino como un error ontológico: la confusión entre dos categorías que jamás deberían mezclarse, los datos y el código. Una cadena que un usuario escribe es un dato: una secuencia de caracteres sin más autoridad que la de ser mostrada. El HTML es código: un lenguaje que el navegador ejecuta, que puede abrir conexiones, leer cookies y actuar en nombre de quien mira. El XSS ocurre en el instante exacto en que un sistema toma algo de la primera categoría y lo entrega a un intérprete de la segunda, borrando la línea que las separaba. Y esa línea es, siempre, una frontera de interpolación: el punto donde un valor se inserta en un texto que luego alguien va a interpretar. El mismo fenómeno reaparece disfrazado en la inyección SQL —un dato que cruza a un intérprete de consultas—, en la inyección de comandos de shell, en la de plantillas: en todos, un valor de una categoría se cuela en el intérprete de otra. La solución profunda es siempre la misma y tiene un nombre —escapar en la frontera—: en el punto de interpolación, transformar el dato para que el intérprete de destino lo lea como dato inerte y no como instrucción. Astro incorpora esa disciplina en el tejido de su plantilla: la frontera entre tu dato y el HTML del navegador está escapada por defecto, así que el caso seguro es gratis y el peligroso cuesta escribir una directiva. Lo que esta lección enseña no es una función que llamar, sino una forma de mirar tu código: cada vez que un valor va a ser interpretado por algo —un navegador, una base de datos, un shell—, hay una frontera, y en esa frontera el dato tiene que quedar sellado como dato. set:html es el nombre que Astro le da al acto de romper ese sello a conciencia; sanear, el peaje que pagas cuando de verdad necesitas cruzar. Quien interioriza que datos y código son categorías distintas, y que mezclarlas es el pecado original de la inyección, ya no necesita memorizar vulnerabilidades: las reconoce todas como la misma, mirando dónde un texto se convierte en instrucción.
- Interpola con
{}una cadena que contenga<img src=x onerror=...>y confirma en el HTML servido que llega escapada, como texto y no como etiqueta. - Cámbiala a
set:htmly observa la diferencia: la etiqueta ahora se interpreta. Anota por qué esto sería un desastre con contenido de usuario. - Instala un saneador por lista de permitidos, pásale esa misma cadena y verifica que
set:htmlrecibe ya una versión limpia sin el manejadoronerror. - Recorre tu proyecto con un
grepdeset:htmly, por cada aparición, escribe en un comentario de dónde viene la cadena y quién garantiza que es de confianza.