Saltar al contenido
AIM Desarrollo y Automatizaciones

CSP estricta en Astro 7, sin unsafe-inline ni unsafe-eval

Hashes en vez de unsafe-inline, Zod sin eval, estilos en clases, fuentes como archivos, Prism en lugar de Shiki y una prueba que falla con cualquier violación.

Publicado el
Tiempo de lectura
6 min de lectura
  • Seguridad
  • Astro
  • CSP
  • Rendimiento

Una Content Security Policy (CSP) le dice al navegador qué puede cargar y ejecutar una página. Bien hecha, convierte muchos ataques de inyección en un aviso en la consola en vez de un script ajeno corriendo en tu sitio. Mal hecha, termina con 'unsafe-inline' y 'unsafe-eval', que dejan pasar justo lo que querías frenar.

Este sitio corre con una CSP estricta, sin ninguna de las dos (DEC-019). Te cuento qué hizo falta, porque escribir la política fue lo fácil: lo que costó fue todo lo que la rompía.

La política: una meta con hashes

Astro 7 trae CSP nativa: escribe la política como una etiqueta <meta> en cada página y le agrega los hashes de los scripts y estilos que él mismo genera. Mi parte está en astro.config.ts y en src/lib/csp.ts:

security: {
  csp: {
    algorithm: 'SHA-256',
    // The Lab's APIs on Render: the API Playground and the data migrator's Python engine.
    directives: cspDirectives({
      connectTo: [process.env.PUBLIC_PLAYGROUND_API_URL, process.env.PUBLIC_PYLAB_API_URL],
      analytics,
    }),
    scriptDirective: { resources: cspScriptResources({ analytics }), hashes: [cspHash(BOOT_SCRIPT)] },
    styleDirective: { resources: CSP_STYLE_RESOURCES, hashes: [cspHash(VIEW_TRANSITION_STYLE)] },
  },
},

cspDirectives arma el resto: default-src 'self', imágenes del propio sitio (y data:), fuentes y medios solo del propio sitio, object-src 'none', y base-uri y form-action limitados al sitio. Los orígenes externos son solo los necesarios: Turnstile de Cloudflare (script e iframe), las dos APIs del Lab que viven en Render (el API Playground y el motor de Python del migrador) y, solo en los builds de producción, Cloudflare Web Analytics.

Una limitación de la <meta>: no admite frame-ancestors. Ese caso lo cubre X-Frame-Options: DENY en public/_headers.

El script inicial, permitido por su hash

Antes de pintar, cada página corre un script corto que aplica el tema y la moneda que guardaste. Es un script en línea, así que la CSP lo bloquearía, salvo que permita exactamente ese texto. Por eso BOOT_SCRIPT es un string plano en src/lib/boot.ts: Base.astro lo escribe en la página y astro.config.ts calcula su hash con cspHash. Los dos ven los mismos bytes; si edito el script, el hash cambia en el siguiente build, sin nada más que actualizar.

Va de primero en el <body>, no en el <head>: así la <meta> de la CSP, que Astro agrega al final del <head>, ya lo cubre.

El único estilo en línea sigue la misma idea: VIEW_TRANSITION_STYLE (src/lib/view-transition.ts) activa las transiciones entre páginas y style-src lo permite por su hash. Ese sí va en el <head>: Chrome decide si la página nueva entra en la transición apenas empieza el <body>, y si la regla vivía en la hoja de estilos y esta aún no había llegado, saltaba la transición con un error en la consola.

Zod sin eval

Aquí vino la primera sorpresa. Zod 4, la librería con la que valido formularios y respuestas de la API en el navegador, usa new Function para validar más rápido. Sin 'unsafe-eval', la CSP lo bloquea y lo reporta como violación. Zod tiene un modo jitless que no lo usa, pero hay que activarlo antes de que se construya cualquier esquema, y los esquemas se construyen apenas carga @aim/contracts, dentro del código de las islas.

La solución fue el mismo script inicial. Su primera línea:

globalThis.__zod_globalConfig = { ...globalThis.__zod_globalConfig, jitless: true }

Zod lee ese objeto compartido al cargar (es el mismo que devuelve z.config()), así que cuando llega la primera isla ya está en modo jitless. Como depender de un detalle interno es frágil, boot.test.ts comprueba que config() devuelva justo ese objeto: si una actualización de Zod lo renombra, falla la prueba antes de que la CSP empiece a reportar eval.

Nada de style=

La política tampoco tiene 'unsafe-inline' en style-src, así que un atributo style="…" en el HTML se bloquea. En un sitio con islas de Preact esto pica más de lo que parece, porque lo natural es escribir un style para el ancho de una barra o la posición de algo.

En las islas cambié esos estilos por clases, caso por caso:

  • El diagrama del Auth Lab ubica cada actor con clases estáticas de Tailwind por columna, en vez de calcular la posición:

    // Positions are static class names, never inline style attributes: the CSP (src/lib/csp.ts) blocks those.
    const COL_START = ['col-start-1', 'col-start-2', 'col-start-3', 'col-start-4']
  • La barra del puntaje de la Automatización en vivo se dibuja con atributos de SVG: el ancho es el width de un <rect>, no un estilo.

  • Los tres puntos que laten mientras el asistente escribe se desfasan con clases como [animation-delay:150ms].

Fuentes como archivos

Otra que no vi venir: Vite convierte en data: URI los archivos de menos de 4 KB, y algunas fuentes pequeñas (el cirílico extendido) quedaban por debajo. La política dice font-src 'self', así que esas fuentes incrustadas se bloqueaban. La regla quedó así en astro.config.ts:

build: { assetsInlineLimit: (file: string) => (/\.woff2?$/.test(file) ? false : undefined) },

Las fuentes siempre son archivos; todo lo demás conserva el límite por defecto de Vite.

Prism en lugar de Shiki

Astro resalta el código con Shiki por defecto, y Shiki colorea cada token con un atributo style. Con esta CSP, eso es una violación por cada palabra resaltada. Prism, en cambio, solo agrega clases:

markdown: { syntaxHighlight: 'prism' },

Los colores salen de una hoja de estilos del sitio, con una versión para el tema claro y otra para el oscuro, cada una con buen contraste. Los bloques de código de este artículo se ven así por eso.

La prueba que no deja pasar nada

Una política estricta sirve mientras nadie la rompa sin darse cuenta. Por eso e2e/csp.e2e.ts abre las páginas del build con Playwright, escucha el evento securitypolicyviolation y los mensajes de la consola, y falla con cualquier violación:

for (const path of pages) {
  test(`${path} has no CSP violations`, async ({ page }) => {
    const violations = await watchCsp(page)
    const response = await page.goto(path)
    expect(response?.ok()).toBe(true)
    await policy(page)
    await wakeIslands(page)
    if (TURNSTILE_PAGES.has(path)) await expect(page.getByText('Verificación simulada')).toBeVisible()
    expect(violations).toEqual([])
  })
}

Las páginas salen del propio build, en los dos idiomas, así que una página nueva queda cubierta sin tocar ninguna lista; de las que comparten plantilla, como los artículos de este blog, revisa una. Antes de mirar, despierta todas las islas, porque una isla puede cargar su código tarde. Además recorre los momentos en que una página carga más código: el formulario del cotizador con su Turnstile, el migrador procesando el archivo de ejemplo, cada paso del diagrama del Auth Lab y el cambio de tema, que vuelve a dibujar Turnstile. El Turnstile de las pruebas es uno de mentira servido en la dirección real de Cloudflare, para que la CSP juzgue la misma URL que en producción. La página 404 también pasa por ahí.

¿Y el rendimiento?

No se nota. La política es una <meta> con hashes, sin peticiones extra. La auditoría con Lighthouse (local, mediana de 3 corridas) dio entre 98 y 100 en las cuatro categorías, en móvil y en escritorio; el detalle está en docs/RENDIMIENTO.md.

Para revisarlo tú

Y si quieres ver la política de este sitio sin abrir las herramientas del navegador, Seguridad en acción la lee en vivo junto con las demás cabeceras de seguridad.