Cabeceras de seguridad explicadas: CSP, HSTS y las demás
Guía práctica de CSP con nonces y strict-dynamic, HSTS y preload, frame-ancestors, Referrer-Policy, Permissions-Policy y COOP, con una configuración de Next.js.
· 7 min de lectura · Lina Source LLC
Las cabeceras de seguridad son instrucciones que tu servidor le da al navegador: ejecuta solo estos scripts, háblame solo por HTTPS, no dejes que otros sitios pongan esta página en un frame. No arreglan los bugs de tu código, pero limitan lo que un atacante puede hacer con uno. Un agujero de cross-site scripting detrás de una Content Security Policy estricta es un problema mucho más pequeño que el mismo agujero sin ella.
La mayoría de estas cabeceras son una línea de configuración. La excepción es la CSP, que requiere planificación. Esta guía cubre qué hace cada cabecera, valores razonables para una aplicación web típica y cómo desplegarlas sin romper producción.
Content-Security-Policy
La CSP le dice al navegador qué orígenes de scripts, estilos, imágenes, frames y conexiones están permitidos. Su función principal es impedir que se ejecuten los scripts inyectados. Una allowlist de dominios parece el enfoque obvio, pero tiene un largo historial de bypasses: cualquier CDN permitida que aloje contenido de usuarios o versiones antiguas de librerías se puede usar para cargar script controlado por el atacante.
Nonces y strict-dynamic
El enfoque que resiste es una política basada en nonces. El servidor genera un valor aleatorio por respuesta, lo pone en la cabecera CSP y lo añade como atributo nonce a cada etiqueta script que renderiza. Las etiquetas script inyectadas no conocen el nonce y quedan bloqueadas. Añadir 'strict-dynamic' permite que los scripts cargados por un script de confianza carguen más scripts, lo que mantiene funcionando a los bundlers y a los gestores de etiquetas sin tener que listar cada dominio.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic';
style-src 'self' 'nonce-4AEemGb0xJptoIGFP3Nd';
img-src 'self' data: https:;
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
upgrade-insecure-requestsLa cabecera se envía en una sola línea; aquí está partida para que se lea mejor. Unas cuantas directivas de esa política hacen más de lo que parece. object-src 'none' bloquea el contenido de plugins heredados. base-uri 'none' impide que una etiqueta base inyectada redirija las URLs relativas de los scripts. form-action 'self' evita que unos formularios inyectados envíen credenciales a otro sitio. El nonce debe ser impredecible y nuevo en cada respuesta, lo que significa que las páginas que lo usan no se pueden servir desde una caché estática.
Despliégala con Report-Only
Una CSP estricta desplegada a ciegas romperá algo: un snippet de analítica, un manejador de eventos inline, un widget de terceros. Publica primero la política como Content-Security-Policy-Report-Only. El navegador no aplica nada, pero reporta cada violación al endpoint indicado en la directiva report-to o report-uri. Recoge informes durante un tiempo, corrige o permite lo que sea legítimo, y después cambia el nombre de la cabecera para que se aplique. Mantén el reporte activo después de aplicarla, porque las violaciones nuevas son o regresiones o ataques. Espera ruido en los informes por parte de las extensiones del navegador, que inyectan sus propios scripts en las páginas; fíltralas por origen antes de decidir qué permitir.
Strict-Transport-Security
HSTS le dice al navegador que use HTTPS para tu dominio durante un periodo determinado, aunque el usuario escriba http:// o haga clic en un enlace antiguo. Eso cierra la ventana en la que un atacante de red podría interceptar la primera petición HTTP en claro y quitar la redirección a HTTPS. Los navegadores solo respetan la cabecera cuando llega por HTTPS, y además se niegan a dejar que los usuarios continúen pese a un error de certificado en un dominio con HSTS, que es justo el objetivo pero también el riesgo si un certificado caduca.
Empieza con un max-age corto, como un día, confirma que nada se rompe y después súbelo a uno o dos años. includeSubDomains extiende la regla a todos los subdominios, así que verifica antes que todos sirven HTTPS válido, incluidos los sitios de marketing antiguos y las herramientas internas en el mismo dominio.
La directiva preload, junto con el envío a la lista de precarga de los navegadores, incrusta tu dominio en los navegadores para que incluso la primerísima visita use HTTPS. Requiere un max-age de al menos un año e includeSubDomains. Trátalo como una puerta de sentido único: salir de la lista es posible, pero tarda mucho en llegar a los usuarios, y mientras tanto cualquier subdominio que no pueda servir HTTPS se vuelve inalcanzable.
Las cabeceras de una línea
- X-Content-Type-Options: nosniff. Impide que los navegadores adivinen un tipo de contenido distinto del que declaraste, lo que evita que un archivo subido y servido como texto se ejecute como script.
- frame-ancestors (en la CSP) y X-Frame-Options: DENY. Controlan quién puede incrustar tus páginas en un frame, que es la defensa contra el clickjacking. Los navegadores modernos usan frame-ancestors e ignoran X-Frame-Options cuando están las dos; mantén X-Frame-Options para los clientes antiguos. Usa 'self' o SAMEORIGIN si pones tus propias páginas en frames.
- Referrer-Policy: strict-origin-when-cross-origin. Envía solo tu origen, no la ruta completa ni la query string, a otros sitios. Así los tokens e ID que van en las URLs no se filtran a terceros. Usa no-referrer en las páginas especialmente sensibles.
- Permissions-Policy. Desactiva las funciones del navegador que no usas, como camera=(), microphone=(), geolocation=() y payment=(), para que el código inyectado o incrustado no pueda solicitarlas.
- Cross-Origin-Opener-Policy: same-origin. Coloca tu página en su propio grupo de contextos de navegación, de modo que una ventana abierta por otro sitio no pueda mantener una referencia a ella. Usa same-origin-allow-popups si dependes de popups de OAuth o de pago.
- Cross-Origin-Resource-Policy: same-origin o same-site. Le dice a los navegadores que no permitan a otros orígenes cargar tus respuestas como imágenes, scripts u otros subrecursos. Usa same-site si sirves los assets desde un subdominio hermano, y cross-origin para los assets pensados para incrustarse en otros sitios.
Puedes prescindir de X-XSS-Protection. El filtro que controlaba se ha eliminado de los navegadores modernos, y la CSP es su sustituta. Si un escáner insiste, ponla a 0. Del mismo modo, evita las cabeceras que solo aportan información al atacante: X-Powered-By y los banners Server detallados revelan gratis tu framework y su versión. En Next.js, pon poweredByHeader: false en la configuración para eliminar la primera.
Una configuración de Next.js
Las cabeceras estáticas van en next.config.ts, donde la función headers() las aplica a todas las rutas. Los valores de abajo son un punto de partida razonable para una aplicación que no se incrusta a sí misma en frames, no usa la cámara ni la ubicación y sirve sus propios assets. Ajusta cada uno a lo que hace realmente tu aplicación en lugar de copiarlos a ciegas.
// next.config.ts
import type { NextConfig } from "next";
const securityHeaders = [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains",
},
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];
const nextConfig: NextConfig = {
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;La CSP necesita un nonce nuevo por petición, así que se configura en el middleware. Next.js lee el nonce de la cabecera Content-Security-Policy de la petición y lo aplica a los scripts propios del framework durante el renderizado. Puedes leerlo en un server component con headers().get("x-nonce") para tus propias etiquetas script.
// middleware.ts (en Next.js 16 el archivo es proxy.ts y la función es proxy)
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' blob: data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join("; ");
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
return response;
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};Las páginas renderizadas con un nonce deben renderizarse de forma dinámica, ya que una página estática reutilizaría el mismo nonce para todos los visitantes. Durante el despliegue, cambia el nombre de la cabecera en los dos sitios a Content-Security-Policy-Report-Only y añade un endpoint de reporte. En desarrollo, React puede necesitar 'unsafe-eval' en script-src para sus funciones de depuración; añádelo solo cuando NODE_ENV sea development.
Cómo verificarlo
- Ejecuta curl -sI https://tu-dominio.example/ contra la página de inicio, una página dinámica, una ruta de API y un asset estático, y lee las cabeceras de cada una. Las cabeceras puestas solo en las páginas HTML son un hueco habitual.
- Abre las herramientas de desarrollo del navegador. Las violaciones de CSP aparecen en la consola con la directiva y el recurso bloqueado, y la pestaña de red muestra las cabeceras que se sirvieron realmente.
- Comprueba también detrás de tu CDN o proxy inverso, además de en el origen. Los proxies a veces eliminan, duplican o sobrescriben cabeceras, y dos cabeceras CSP en conflicto se aplican las dos, así que la política efectiva es más estricta que cualquiera de ellas.
- Usa un verificador público como Mozilla HTTP Observatory para una segunda opinión rápida, y el CSP Evaluator de Google para detectar directivas CSP débiles.
- Añade un test en CI que pida las rutas clave y compruebe que las cabeceras están presentes, para que una refactorización de la configuración no pueda eliminarlas en silencio.
Las cabeceras son configuración, que es justo por lo que se degradan con el tiempo: un route handler nuevo que construye su propia respuesta, una regla del proxy que sobrescribe los valores por defecto, una CSP relajada con 'unsafe-inline' para desbloquear una release. Revísalas como si fueran código. CodeAuditAgent lee la configuración de un repositorio público o de un fragmento pegado junto con el resto del código, y reporta las cabeceras ausentes o debilitadas con su severidad, la configuración citada y una corrección sugerida.
Un orden de trabajo razonable: las cabeceras de una línea hoy, HSTS con un max-age corto esta semana y una CSP basada en nonces en modo Report-Only en cuanto puedas recoger los informes.