Add security-headers middleware with strict CSP (TDD)
Exports buildSecurityHeaders() (pure, testable) and wires it into the Astro onRequest middleware. Adds astro:middleware alias in vitest config so the unit tests run outside Astro's build context. 14 tests pass (7 existing + 7 new). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
4
src/__mocks__/astro-middleware.ts
Normal file
4
src/__mocks__/astro-middleware.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** Minimal stub for astro:middleware used in Vitest only. */
|
||||||
|
export function defineMiddleware(fn: unknown) {
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
52
src/middleware.test.ts
Normal file
52
src/middleware.test.ts
Normal file
@@ -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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
51
src/middleware.ts
Normal file
51
src/middleware.ts
Normal file
@@ -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<string, string> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'astro:middleware': path.resolve('./src/__mocks__/astro-middleware.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts'],
|
||||||
|
|||||||
Reference in New Issue
Block a user