Saltar al contenido

· · Programación  · 7 min read

Cabeceras de seguridad para un sitio estático: mi vercel.json real

Un sitio sin servidor sigue respondiendo con cabeceras en cada petición. El mío no enviaba casi ninguna de seguridad, hasta que agregué cuatro líneas a vercel.json.

Un sitio sin servidor sigue respondiendo con cabeceras en cada petición. El mío no enviaba casi ninguna de seguridad, hasta que agregué cuatro líneas a vercel.json.

Mi portafolio es un sitio estático hecho con Astro y desplegado en Vercel. No hay servidor, no hay base de datos, no hay sesiones ni login. Durante mucho tiempo lo traté como “no hay nada que asegurar”, y sospecho que casi todos los que tienen un sitio estático hacen lo mismo. Un día corrí curl -I contra mi propio dominio y leí lo que respondía. Casi nada. Este post es mi configuración real de cabeceras de seguridad para un sitio estático: cuatro líneas declarativas en vercel.json, qué hace cada una, qué no hace, y cómo verificar que quedaron activas. Tiempo total: unos diez minutos.

Un sitio estático no tiene superficie de ataque cero

Primero, seamos honestos con el modelo de amenazas, porque los posts de seguridad tienden a exagerar. A un sitio estático no le puedes inyectar SQL. No hay autenticación que saltarse ni sesión que robar. El riesgo real es menor que el de una aplicación con backend, y no tiene sentido fingir lo contrario.

Pero tus páginas siguen ejecutándose en navegadores, y los navegadores toman decisiones de seguridad basadas en las cabeceras de respuesta que envías. Sin ninguna cabecera configurada:

  • Cualquier sitio del mundo puede cargar tus páginas dentro de un iframe y montar un ataque de clickjacking encima (sobre tu formulario de contacto, por ejemplo).
  • El navegador tiene permiso de adivinar el tipo de contenido de un archivo en lugar de respetar lo que el servidor declaró.
  • Cada enlace externo que publicas puede filtrar la URL completa de la página de origen al sitio de destino y a su analítica.
  • Cualquier script en tus páginas, incluidos los de terceros que agregues después, puede pedir acceso a la cámara, al micrófono o a la ubicación del visitante.

Nada de esto es catastrófico para un portafolio. Todo esto se cierra gratis. Ese desbalance es el argumento completo.

La configuración de cabeceras de seguridad para un sitio estático que uso en producción

Este es el vercel.json real que corre en este sitio ahora mismo, no un ejemplo inventado:

{
  "cleanUrls": true,
  "trailingSlash": false,
  "redirects": [{ "source": "/en", "destination": "/", "permanent": false }],
  "headers": [
    {
      "source": "/_astro/(.*)",
      "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
    },
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        {
          "key": "Permissions-Policy",
          "value": "camera=(), microphone=(), geolocation=(), interest-cohort=()"
        }
      ]
    }
  ]
}

Ese es todo el mecanismo. No hay código de servidor ni middleware, porque no hay dónde ejecutarlos. Vercel lee este archivo al desplegar y su edge agrega las cabeceras a toda respuesta que coincida con /(.*), es decir, a todo. Endurecimiento declarativo en un sitio sin runtime. Ese es justamente el atractivo.

X-Content-Type-Options: nosniff

Le dice al navegador que respete el Content-Type declarado y que nunca adivine. Sin esta cabecera, el navegador puede “olfatear” los bytes de una respuesta y decidir, por ejemplo, que algo es un script ejecutable aunque no se haya servido como tal. Esa conducta de adivinar es la base de toda una familia de ataques viejos.

Lo que no hace: no corrige un tipo de contenido mal configurado. Si un archivo se sirve con el tipo equivocado, nosniff hace que falle de forma visible en lugar de funcionar en silencio. Y eso es lo correcto. En un sitio estático donde Vercel ya sirve los tipos correctos, esta cabecera no cuesta nada y cierra el camino de la adivinanza para siempre.

X-Frame-Options: SAMEORIGIN, y por qué no DENY

Controla quién puede meter tus páginas en un iframe. DENY significa nadie, ni siquiera tú. SAMEORIGIN significa solo páginas servidas desde tu propio origen.

Nota honesta: DENY es la opción más estricta, y si estás seguro de que nunca vas a incrustar tus propias páginas en ningún lado, es el mejor default. Yo elegí SAMEORIGIN a propósito, para poder usar iframes con mis propias páginas (vistas previas, demos incrustadas) sin tener que tocar la configuración después. Las dos opciones detienen el ataque que de verdad importa aquí: un sitio de terceros enmarcando el tuyo para engañar a visitantes y hacerlos dar clic en cosas que no ven. La amenaza es el framing de otro origen, y ambos valores lo bloquean.

Referrer-Policy: strict-origin-when-cross-origin

Cuando un visitante hace clic en un enlace de tu sitio, el navegador le dice al destino de dónde viene. Esta política dice: envía la URL completa en navegación dentro del mismo origen, envía solo el origen (https://heroweb.dev, sin ruta) hacia otros orígenes, y no envíes nada si el destino baja a HTTP.

Los navegadores modernos ya usan esto como default, entonces ¿para qué declararlo? Dos razones. Los navegadores viejos tenían defaults más filtradores, y la cabecera explícita fija el comportamiento en todos. Y además documenta la intención: cualquiera que lea la configuración sabe que la fuga de referrer se consideró, no que se olvidó. En un blog, las rutas y los query strings son casi inofensivos, pero no hay razón para regalárselos a cada sitio externo que enlazo.

Permissions-Policy: apagar lo que nunca uso

camera=(), microphone=(), geolocation=() define una lista de permitidos vacía para cada una de esas APIs del navegador. Ningún código que corra en mis páginas puede pedirlas. Ni el mío, ni, más importante, un script de terceros que agregue dentro de seis meses y olvide auditar. Ahí está el valor real: es una barrera contra mi yo del futuro, no contra mi código actual.

La rara del grupo es interest-cohort=(). Con esa el sitio se excluye de Google FLoC, el experimento de rastreo publicitario por cohortes. FLoC ya murió (su sucesor es la Topics API), así que hoy es más que nada una exclusión heredada, pero mantenerla no cuesta nada y fija una postura: mis visitantes no son insumo para segmentación de anuncios.

¿Y dónde está Strict-Transport-Security?

No está en el archivo, y es a propósito, no un descuido. Vercel agrega la cabecera HSTS automáticamente en el edge de la plataforma para dominios personalizados. Corre curl -I contra el sitio y vas a ver strict-transport-security en la respuesta aunque mi configuración nunca la mencione. Duplicarla a mano sería agregar una línea que luego tendría que mantener consistente con lo que la plataforma ya envía. Si tu hosting no la inyecta por ti, agrégala tú; en Vercel ya viene resuelta.

Ya que estás ahí: caché inmutable para los assets con hash

El bloque de /_astro/(.*) no es una cabecera de seguridad, pero vive en el mismo archivo y son dos líneas, así que llévate la ganancia. Astro pone una huella en los nombres de sus archivos de build: el nombre incluye un hash del contenido. Si el contenido cambia, la URL cambia. Por eso una URL dada nunca puede servir bytes distintos, y eso hace que max-age=31536000, immutable (caché de un año, sin revalidar) sea totalmente seguro. Los visitantes recurrentes dejan de volver a descargar tu CSS y tu JS.

Cómo verificar que de verdad quedó

Una configuración sin verificar es una configuración que esperas que funcione. Dos comprobaciones:

curl -I https://heroweb.dev

Lee la respuesta y confirma que las cuatro cabeceras están presentes (más la HSTS que agrega la plataforma). Después pasa el sitio por securityheaders.com, que califica el conjunto de cabeceras y te dice exactamente qué falta.

Lo que estas cabeceras no hacen

Son defensa en profundidad, no un campo de fuerza. No van a proteger una API key que embarcaste por accidente en un bundle del cliente, y no van a detener a un script de terceros comprometido haciendo daño dentro de tu página. Ese segundo problema es territorio de Content-Security-Policy, y todavía no he publicado una CSP: hacerla bien en un sitio con scripts inline toma trabajo real, y una CSP descuidada rompe tu propio sitio sin detener nada. Está en la lista.

Lo que sí publiqué son diez minutos de copiar y pegar que cierran clickjacking, MIME sniffing, fuga de referrer y acceso no deseado a APIs del navegador en todas las páginas. Este tipo de pendiente chico es el que se queda en el backlog para siempre, y es exactamente la clase de tarea que le dejo a un agente de IA que programa de noche como parte de mi stack de desarrollo con IA. Como sea que lo resuelvas, resuélvelo. Es el endurecimiento más barato que tu sitio estático va a recibir.

¿Te sirvió este artículo?

Recibe nuevos artículos sobre desarrollo web, IA y SEO directo en tu correo. Sin spam, cancela cuando quieras.

Sin spam. Al suscribirte aceptas la política de privacidad .

Volver al blog

Related Posts

View All Posts »
Migración de Astro 5 a 6: los 3 cambios que me rompieron el build

Migración de Astro 5 a 6: los 3 cambios que me rompieron el build

La migración de Astro 5 a 6 se veía como un bump de seguridad de diez minutos. En vez de eso choqué con tres builds rotos, uno tras otro — todos en código del theme que nunca escribí. Aquí están los errores exactos, los fixes, y por qué un sitio con theme se rompe donde tu propio código no se rompería.

Cómo reduje 88% el peso de las imágenes de mi sitio (Astro + Sharp y WebP)

Cómo reduje 88% el peso de las imágenes de mi sitio (Astro + Sharp y WebP)

Un sitio estático en Astro con 15KB de JavaScript estaba enviando 3.4MB de imágenes. Estos son los tres modos de fallo que se colaron por el pipeline de imágenes — la regla de que el original siempre se publica, la captura que el optimizador nunca tocó y el material de plantilla que nadie referenciaba — y las 15 líneas de Sharp que lo arreglaron todo.

Las mejores maneras de aprender a programar: Guía práctica

Las mejores maneras de aprender a programar: Guía práctica

Descubre los mejores métodos para aprender a programar, incluyendo cursos en línea, bootcamps de programación, plataformas interactivas y recursos impulsados por la comunidad, diseñados para ayudarte a tener éxito en el mundo tecnológico en evolución.

Cómo Valido un Post de Astro Antes de Publicarlo

Cómo Valido un Post de Astro Antes de Publicarlo

Publicar un post estático en Astro está a un merge de estar en vivo — justo por eso los bugs silenciosos son peligrosos. Un lang incorrecto, un canonical que da 404, un link entre idiomas que se filtró: nada de eso se ve en tu editor, solo en el HTML compilado. Aquí está el checklist que corro sobre dist/ antes de cada post, scripts incluidos.