diff --git a/src/__mocks__/astro-middleware.ts b/src/__mocks__/astro-middleware.ts new file mode 100644 index 0000000..afa1536 --- /dev/null +++ b/src/__mocks__/astro-middleware.ts @@ -0,0 +1,4 @@ +/** Minimal stub for astro:middleware used in Vitest only. */ +export function defineMiddleware(fn: unknown) { + return fn; +} diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 0000000..5cea4db --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { buildSecurityHeaders } from './middleware'; + +describe('buildSecurityHeaders', () => { + const headers = buildSecurityHeaders(); + + it('sets a strict Content-Security-Policy', () => { + const csp = headers['Content-Security-Policy']; + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("form-action 'none'"); + expect(csp).toContain("base-uri 'self'"); + expect(csp).toContain("object-src 'none'"); + }); + + it('denies framing', () => { + expect(headers['X-Frame-Options']).toBe('DENY'); + }); + + it('disables MIME sniffing', () => { + expect(headers['X-Content-Type-Options']).toBe('nosniff'); + }); + + it('sets a strict referrer policy', () => { + expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin'); + }); + + it('disables sensitive browser features', () => { + const pp = headers['Permissions-Policy']; + expect(pp).toContain('geolocation=()'); + expect(pp).toContain('microphone=()'); + expect(pp).toContain('camera=()'); + expect(pp).toContain('payment=()'); + }); + + it('sets HSTS with long max-age, subdomains, and preload', () => { + const hsts = headers['Strict-Transport-Security']; + expect(hsts).toContain('max-age=31536000'); + expect(hsts).toContain('includeSubDomains'); + expect(hsts).toContain('preload'); + }); + + it('does not allow inline scripts', () => { + expect(headers['Content-Security-Policy']).not.toContain("'unsafe-inline' 'nonce-"); + const scriptDirective = headers['Content-Security-Policy'] + .split(';') + .map(s => s.trim()) + .find(s => s.startsWith('script-src')) ?? ''; + expect(scriptDirective).not.toContain("'unsafe-inline'"); + }); +}); diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..f390a45 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,51 @@ +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:", + "style-src 'self' 'unsafe-inline'", + "font-src 'self'", + "script-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'none'", + "object-src 'none'", + ].join('; '); + + const permissionsPolicy = [ + 'geolocation=()', + 'microphone=()', + 'camera=()', + 'payment=()', + 'usb=()', + 'fullscreen=(self)', + ].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; +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7eeb3f8..157cceb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ + resolve: { + alias: { + 'astro:middleware': path.resolve('./src/__mocks__/astro-middleware.ts'), + }, + }, test: { environment: 'node', include: ['src/**/*.test.ts'],