Seguridad web

Cómo conseguimos A+ en cabeceras de seguridad con un sitio estático

La CSP por hashes, las cabeceras que faltan y lo que Cloudflare puede romper: cómo este sitio llega a A+ en securityheaders.com y MDN Observatory.

4 min de lectura

Este sitio tiene A+ en securityheaders.com y en MDN Observatory. No te pedimos que nos creas: puedes comprobarlo ahora mismo con los enlaces de la franja «Verificable» de la página de inicio. Aquí te contamos cómo se consigue y, sobre todo, qué cosas lo rompen sin avisar.

El obstáculo: la CSP

Casi todas las cabeceras de seguridad son una línea de configuración. La difícil es la Content Security Policy (CSP), que le dice al navegador de dónde puede cargar scripts y estilos. Una CSP útil no permite 'unsafe-inline' en script-src, y MDN Observatory resta 20 puntos si lo encuentra. Sin esos puntos no hay A+.

El problema es que casi todos los frameworks insertan scripts en línea. Hay dos formas de autorizarlos sin 'unsafe-inline':

  • Nonce: un valor aleatorio distinto en cada respuesta. Obliga a generar la página en cada visita, así que pierdes el HTML estático y la caché de la CDN.
  • Hash: la huella SHA-256 del contenido exacto de cada script. Se calcula una vez, al construir el sitio, y funciona con páginas estáticas.

Elegimos hashes. Astro los calcula en el build para cada script y estilo que emite, y su adaptador de Node los envía en la cabecera Content-Security-Policy de cada página prerenderizada.

Lo que no hace el framework

Al probarlo encontramos tres huecos que, de no cubrirlos, nos habrían dejado sin A+ o con la página rota.

1. Solo viaja la CSP. La opción staticHeaders del adaptador transporta la CSP, pero no el resto de cabeceras que pongas desde el middleware: en las páginas estáticas no llegan. Por eso servimos el sitio con un servidor propio pequeño (server.mjs) que añade a todas las respuestas:

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
X-Frame-Options: DENY

Vive en el repositorio y se prueba en CI, no en un panel de configuración: así cualquier cambio pasa por una revisión.

2. Los scripts is:inline no reciben hash. El script que evita el parpadeo del tema oscuro tiene que ejecutarse antes de pintar, así que va en línea, y Astro no lo hashea. Lo escribimos en un único archivo, calculamos su hash en la configuración a partir de ese mismo archivo y lo declaramos en la CSP. Los bytes son idénticos por construcción.

// astro.config.mjs: el hash sale del mismo archivo que se inserta en la página
const themeInit = readFileSync('src/scripts/theme-init.js', 'utf8').trim();
const themeInitHash =
  'sha256-' + createHash('sha256').update(themeInit).digest('base64');

export default defineConfig({
  security: { csp: { scriptDirective: { hashes: [themeInitHash] } } },
});

3. Un olvido no avisa. Si mañana alguien añade un script en línea sin hash, el navegador lo bloquea en silencio. Para que eso no llegue a producción, un paso del build recorre todo el HTML generado, calcula el hash de cada <script> y <style> en línea y falla si alguno no está en la CSP de su página.

La política resultante

Empieza por negarlo todo y solo abre lo necesario:

default-src 'none'; base-uri 'none'; form-action 'self';
frame-ancestors 'none'; object-src 'none';
img-src 'self' data:; font-src 'self';
script-src 'self' 'sha256-…'; style-src 'self' 'sha256-…';
connect-src 'self' https://stats.bmstudio.dev;
upgrade-insecure-requests

Cada página lleva solo los hashes de lo que usa. https://stats.bmstudio.dev es nuestra medición de visitas, que corre en nuestro propio servidor.

Lo que lo rompe desde fuera

Una CSP por hashes exige que nadie reescriba el HTML entre tu servidor y el navegador. Las CDN ofrecen «optimizaciones» que hacen justo eso. En Cloudflare mantenemos apagadas tres:

  • Rocket Loader, que reescribe la carga de scripts;
  • Email Obfuscation, que inserta un script para ocultar correos;
  • Web Analytics (RUM), que inyecta su propio script de medición.

La tercera nos pasó de verdad. Una medición semanal de PageSpeed dio 92 en Buenas prácticas, cuando el CI medía 100. Cloudflare estaba insertando su script de analítica, la CSP lo bloqueaba (bien) y el error en la consola restaba puntos. El CI no podía verlo porque prueba el build en local, sin pasar por Cloudflare. Desde entonces medimos producción cada semana: es la alarma de lo que cambia fuera del repositorio.

El resultado, comprobable

El 8 de octubre de 2026, securityheaders.com dio A+ con las seis cabeceras en verde, y MDN Observatory dio A+ con 145/100, superando las 12 pruebas. No hace falta que guardes estas cifras: los enlaces del sitio repiten el análisis cuando quieras.

Lo que te llevas

  • Si tu sitio es estático, usa hashes, no nonces: conservas la caché y la velocidad.
  • Pon las cabeceras en el código, junto a lo que las necesita, y pruébalas en CI.
  • Haz que el build falle si algo en línea queda fuera de la CSP.
  • Revisa qué hace tu CDN con el HTML, y mide producción, no solo tu build.

Si quieres este nivel en tu aplicación, cuéntanos tu proyecto.

¿Tienes un proceso que podría funcionar mejor?

Cuéntanos cómo trabajas hoy. Con eso basta para una primera conversación.

Cuéntanos tu proyecto
Cuéntanos tu proyecto