wandres.dev
SEGURIDAD · CSP, XSS, cabeceras

Cabeceras de seguridad por middleware

El middleware como el lugar canónico para fijar de una vez las cabeceras de seguridad de todo el sitio: HSTS para forzar HTTPS, X-Frame-Options y frame-ancestors contra el clickjacking, Referrer-Policy para no filtrar la URL de origen, Permissions-Policy para desactivar APIs del navegador, y el matiz de que solo alcanzan a las respuestas servidas bajo demanda.

⏱ 17 min

Un navegador moderno viene armado de defensas —forzar HTTPS, negarse a ser enmarcado, callar la URL de origen, apagar APIs sensibles— pero casi todas están desactivadas hasta que el servidor las pide con una cabecera. Esas cabeceras de seguridad son instrucciones dirigidas al navegador, no a tu código, y comparten un rasgo: pertenecen a todas las respuestas por igual, no a una página concreta. Ese es exactamente el trabajo del middleware —lo que es cierto para toda petición se escribe una vez y en un solo sitio— y por eso es el hogar natural donde declarar, de golpe, la postura de seguridad del sitio entero.

🎯 Al terminar esta lección sabrás
  • Fijar cabeceras de seguridad para todo el sitio desde un único onRequest.
  • Forzar HTTPS con Strict-Transport-Security y entender su compromiso de permanencia.
  • Cerrar el clickjacking con X-Frame-Options y su sucesora frame-ancestors.
  • Recortar fugas y superficie con Referrer-Policy y Permissions-Policy.

El middleware como lugar de las cabeceras transversales

Una cabecera de seguridad no pertenece a una ruta: pertenece a la respuesta, a cualquiera. Repetirla página por página sería frágil y olvidadizo; el sitio correcto es el borde por el que toda respuesta sale, y ese borde es el middleware. El patrón es el ciclo que ya conoces: dejas que next renderice la ruta, recibes la Response y, en el camino de vuelta, le imprimes las cabeceras antes de devolverla.

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
  const response = await next();
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set('X-Frame-Options', 'DENY');
  return response;
});

Escribir esto una vez cubre cada ruta que Astro sirva bajo demanda: no hay página que se olvide de su cabecera porque ninguna las pone, las pone el portero por todas. La X-Content-Type-Options: nosniff del ejemplo es una de las más baratas y valiosas: prohíbe al navegador adivinar el tipo de un recurso —el llamado MIME sniffing— y así evita que un archivo servido como texto acabe ejecutándose como script.

Que todas vivan juntas tiene un valor que trasciende la comodidad: una sola pantalla te muestra la postura completa del sitio. Un revisor abre src/middleware.ts y ve, de un vistazo, qué se declara y qué falta —no ha de rastrear diez layouts para reconstruir la política—. En seguridad, poder mirar de una vez lo que protege el sistema no es un lujo estético: es la diferencia entre una postura que alguien puede auditar y una dispersa que nadie llega a abarcar.

HSTS: forzar HTTPS y no volver atrás

Strict-Transport-Security —HSTS— le ordena al navegador que, durante un plazo, hable con tu dominio solo por HTTPS, aunque el usuario teclee http:// o siga un enlace inseguro. Cierra la ventana del ataque man-in-the-middle que actúa en ese primer instante de texto plano antes del salto a cifrado.

response.headers.set(
  'Strict-Transport-Security',
  'max-age=63072000; includeSubDomains; preload',
);

Cada parte pesa. El max-age en segundos es cuánto recordará el navegador la orden —dos años es lo habitual—; includeSubDomains la extiende a todo subdominio; preload te habilita a inscribir el dominio en la lista que los navegadores traen de fábrica, de modo que la primera visita ya llegue forzada. La contrapartida es un compromiso serio: mientras el max-age viva, no hay vuelta a HTTP para ese dominio, así que no actives preload hasta tener HTTPS sólido en el dominio y todos sus subdominios.

El peso de ese compromiso se entiende mejor pensando en la salida. Retirar un dominio de la lista de preload es lento y depende de los ciclos de publicación de cada navegador: no es un cambio que revierta esta tarde. Por eso la recomendación es escalonar —primero un max-age corto para probar que nada se rompe, luego alargarlo, y solo al final, con confianza ganada, includeSubDomains y preload—. HSTS premia la prudencia: es una promesa difícil de deshacer, así que conviene hacerla cuando de verdad puedas cumplirla.

Clickjacking: X-Frame-Options y frame-ancestors

El clickjacking mete tu página en un <iframe> invisible sobre una trampa, para que la víctima crea que pulsa una cosa cuando en realidad acciona la tuya. La defensa es negar que te enmarquen. La cabecera veterana es X-Frame-Options: DENY —o SAMEORIGIN si tú mismo te embebes—, entendida por todos los navegadores.

response.headers.set('X-Frame-Options', 'DENY');
response.headers.set(
  'Content-Security-Policy',
  "frame-ancestors 'none'",
);

Su sucesora moderna es la directiva frame-ancestors de la CSP, más expresiva porque admite una lista de orígenes concretos que sí pueden enmarcarte. Lo habitual hoy es poner ambas: X-Frame-Options como red para clientes antiguos y frame-ancestors como la política real. Recuerda el matiz de la lección anterior: frame-ancestors solo surte efecto como cabecera HTTP, nunca dentro de una etiqueta meta, así que este es otro motivo para emitir tu CSP desde el middleware cuando dependes de ella.

Elegir entre DENY y SAMEORIGIN es una decisión de producto: DENY prohíbe todo enmarcado, incluso el tuyo, y es lo correcto si nunca embebes tus propias páginas; SAMEORIGIN lo permite solo desde tu mismo origen, útil si tienes un panel que muestra una vista en un <iframe> propio. Con frame-ancestors la elección se afina —enumeras los orígenes exactos que sí pueden enmarcarte— y por eso es la que manda cuando ambas están presentes. La X-Frame-Options queda como la red por debajo, para el cliente que aún no entienda la directiva moderna.

Referrer-Policy y Permissions-Policy: fugas y superficie

Quedan dos cabeceras que recortan riesgo de formas distintas. Referrer-Policy gobierna cuánta información de dónde venías se envía al navegar a otro sitio. Por defecto, el navegador puede filtrar la URL completa —con sus rutas y parámetros, a veces sensibles— a terceros. Un valor prudente es strict-origin-when-cross-origin: manda la URL entera dentro de tu dominio, pero solo el origen a secas cuando el destino es ajeno, y nada al bajar de HTTPS a HTTP.

response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
  'Permissions-Policy',
  'camera=(), microphone=(), geolocation=()',
);

Permissions-Policy es la más proactiva: apaga por adelantado APIs potentes del navegador —cámara, micrófono, geolocalización— que tu sitio no usa, reduciendo lo que un script inyectado podría siquiera intentar. La sintaxis camera=() con la lista vacía significa “nadie, ni yo”: si mañana un XSS logra colarse, ya no puede pedir la cámara porque la propia página la declaró prohibida para todos. Es superficie de ataque que eliminas antes de que nadie la busque.

Hay una asimetría útil entre estas dos últimas: Referrer-Policy protege a tus usuarios de filtrar a dónde iban y de dónde venían, mientras que Permissions-Policy te protege a ti recortando lo que un script —tuyo o inyectado— puede siquiera pedirle al navegador. Fíjalas ambas con criterio de mínimo: manda el mínimo de referente que la analítica legítima necesite y enciende el mínimo de APIs que tus funciones reales usen. Todo lo demás, apagado por defecto, es una puerta que ni tú ni un atacante podréis empujar.

ℹ️
La familia Cross-Origin: aislar tu página de las ajenas

Más allá de las clásicas, hay una familia moderna que aísla tu página a nivel de proceso del navegador. Cross-Origin-Opener-Policy con same-origin rompe la relación entre tu ventana y la que te abrió, cortando ataques que manipulan window.opener. Cross-Origin-Resource-Policy decide quién puede embeber tus recursos, y junto a Cross-Origin-Embedder-Policy habilitan el aislamiento cruzado que ciertas APIs potentes exigen. No las necesita todo sitio, pero saber que existen te da el vocabulario para el día en que una de esas APIs, o un ataque de canal lateral, las vuelva obligatorias.

flowchart TD
REQ[llega una peticion] --> MW[onRequest llama a next]
MW --> R[astro renderiza la ruta]
R --> RESP[response en el camino de vuelta]
RESP --> SET[fija HSTS X-Frame-Options Referrer y Permissions]
SET --> OUT[respuesta blindada al navegador]
OUT --> APPLY[el navegador aplica cada politica]
style SET fill:#f9e2af,color:#11111b
style OUT fill:#a6e3a1,color:#11111b
⚠️
Las cabeceras solo alcanzan lo que Astro sirve bajo demanda

El middleware imprime cabeceras sobre la Response que produce next, y eso solo existe de verdad en rutas renderizadas bajo demanda. Una página prerenderizada horneada en el build y servida por un CDN no pasa por tu onRequest en cada visita: la sirve la infraestructura, no tu código. Para esas rutas estáticas, las cabeceras de seguridad se configuran en el host o el CDN —un archivo de reglas, la consola del proveedor—. No des por blindado un sitio estático solo porque tu middleware fija las cabeceras: comprueba con las herramientas del navegador que llegan de verdad en las rutas que un CDN sirve.

💡
Verifica, no supongas: mide las cabeceras que de verdad llegan

Es fácil creer que una cabecera protege cuando en realidad no sale, o sale con un valor equivocado. No lo supongas: ábrelo. La pestaña de red de las herramientas del navegador te muestra las cabeceras reales de cada respuesta, y hay escáneres públicos que puntúan tu postura y explican qué falta. Convierte esa revisión en parte del despliegue: una cabecera que crees puesta pero que un proxy intermedio despoja no protege absolutamente nada, y solo mirándola de verdad lo descubres.

🚪

Un solo portero

El middleware fija las cabeceras de todo el sitio de una vez. Ninguna ruta se olvida porque ninguna las pone.

🔒

HSTS

Fuerza HTTPS durante el max-age. Con preload, desde la primera visita. Compromiso serio: no hay vuelta atras.

🖼️

Anti-enmarcado

X-Frame-Options DENY para clientes viejos y frame-ancestors none como politica real contra el clickjacking.

📡

Fuga y superficie

Referrer-Policy calla la URL de origen. Permissions-Policy apaga camara, micro y geo que no usas.

Una cabecera de seguridad es un contrato declarativo que delega la defensa en el navegador

Merece la pena detenerse en la naturaleza peculiar de estas cabeceras, porque no se parecen a casi nada más de lo que escribes. No son código que ejecutas: son instrucciones que le entregas a un ejecutor ajeno y poderosísimo —el navegador de cada visitante— para que aplique, en su territorio y no en el tuyo, una política que solo él está en posición de hacer cumplir. Tú no puedes impedir que otra web meta la tuya en un <iframe>; el navegador de la víctima sí, si se lo pides con frame-ancestors. Tú no puedes forzar que el próximo tecleo de http:// salte a cifrado; el navegador sí, si HSTS se lo ordenó antes. Hay aquí un patrón profundo de la seguridad web: buena parte de la defensa no vive en el servidor sino en el cliente, y el servidor solo puede activarla declarando su intención. Por eso estas cabeceras son declarativas y no imperativas: no describen cómo protegerse paso a paso, sino qué política debe regir, y dejan la ejecución a quien tiene la potestad de aplicarla. Es la misma forma de un contrato: una parte enuncia los términos, la otra los cumple. Y de ahí se sigue el rasgo que más cuesta interiorizar —su fragilidad silenciosa—: como la fuerza vive en el otro extremo, una cabecera mal escrita, ausente, ignorada por ser demasiado vieja la directiva o despojada por un proxy intermedio, no falla de forma ruidosa; simplemente no protege, y todo parece funcionar igual. No hay excepción, no hay pantalla roja: hay una defensa que creías activa y que nunca se declaró. Esto invierte la intuición del programador, acostumbrado a que el código roto se queje. Aquí el código no está roto: está mudo, y el silencio se confunde con el éxito. Que el lugar canónico para estas cabeceras sea el middleware no es casualidad, entonces: es la consecuencia de que sean transversales por naturaleza —ciertas para toda respuesta— y de que su corrección se juegue en un solo carácter que conviene tener bajo un único par de ojos. Centralizarlas en el borde por el que todo sale es la única forma de poder mirar de una vez la postura completa del sitio, y de convertir un silencio peligroso en algo que un ser humano puede leer, revisar y verificar. La seguridad que se delega en otro solo es tan buena como la claridad con que se declara la intención; el middleware es donde esa intención se escribe legible.

⚔️ Blinda el sitio desde un solo archivo
  1. En src/middleware.ts, fija en el camino de vuelta X-Content-Type-Options, Referrer-Policy y X-Frame-Options, y comprueba en la pestaña de red del navegador que llegan en cada respuesta bajo demanda.
  2. Añade Strict-Transport-Security con un max-age y includeSubDomains, y razona por escrito por qué no activarías aún preload en un proyecto nuevo.
  3. Emite una CSP con frame-ancestors 'none' desde el middleware y verifica que sí surte efecto como cabecera, a diferencia de dentro de una etiqueta meta.
  4. Declara una Permissions-Policy que apague camera, microphone y geolocation, y confirma con un escáner de cabeceras que tu puntuación de seguridad sube.