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