import { defineMiddleware } from 'astro:middleware'; /** * Emits the site-wide security header set. Astro does not attach these to * statically-built responses at runtime on a shared host — but `preview` uses * them locally, and when Cloudflare Transform Rules are configured (per * docs/superpowers/specs/2026-04-24-cameleer-website-design.md §5.3) the * edge re-emits the same set for the prod origin. Having both is defense * in depth. */ export function buildSecurityHeaders(): Record { const csp = [ "default-src 'self'", "img-src 'self' data:", // Astro's scoped-style system injects inline style attributes at build time; // 'unsafe-inline' here is required until we migrate to a hash/nonce strategy. "style-src 'self' 'unsafe-inline'", "font-src 'self'", "script-src 'self'", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", // No forms on this marketing site today (all auth redirects go to auth.cameleer.io // as plain navigations). If a future form is added, relax to 'self' or an allow-list. "form-action 'none'", "object-src 'none'", ].join('; '); // Must match .htaccess and the Cloudflare Transform Rule in OPERATOR-CHECKLIST.md. const permissionsPolicy = [ 'geolocation=()', 'microphone=()', 'camera=()', 'payment=()', 'usb=()', ].join(', '); return { 'Content-Security-Policy': csp, 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': permissionsPolicy, 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', }; } export const onRequest = defineMiddleware(async (_context, next) => { const response = await next(); const headers = buildSecurityHeaders(); for (const [name, value] of Object.entries(headers)) { response.headers.set(name, value); } return response; });