diff --git a/backend/shared/middleware/__tests__/securityHeaders.test.ts b/backend/shared/middleware/__tests__/securityHeaders.test.ts index e3d235ed..054b0616 100644 --- a/backend/shared/middleware/__tests__/securityHeaders.test.ts +++ b/backend/shared/middleware/__tests__/securityHeaders.test.ts @@ -104,4 +104,61 @@ describe('applySecurityHeadersToResponse', () => { applySecurityHeadersToResponse(res, { hstsMaxAge: 100 }); expect(headersOut['Strict-Transport-Security']).toBe('max-age=100; includeSubDomains; preload'); }); + + it('skips all headers when response is already sent', () => { + const setHeader = jest.fn(); + const res = { headersSent: true, setHeader } as any; + + applySecurityHeadersToResponse(res); + expect(setHeader).not.toHaveBeenCalled(); + }); +}); + +describe('Security Header Audit (#1773)', () => { + const REQUIRED_HEADERS = [ + 'Strict-Transport-Security', + 'Permissions-Policy', + 'X-Content-Type-Options', + 'X-Frame-Options', + 'Referrer-Policy', + ] as const; + + it('emits every required security header with default options', () => { + const headers = buildSecurityHeaders({}); + for (const name of REQUIRED_HEADERS) { + expect(headers).toHaveProperty(name); + expect(headers[name]).toBeTruthy(); + } + }); + + it('X-Content-Type-Options is always nosniff regardless of custom options', () => { + const headers = buildSecurityHeaders({ + hstsMaxAge: 3600, + hstsIncludeSubDomains: false, + hstsPreload: false, + permissionsPolicy: { geolocation: ["'self'"] }, + }); + expect(headers['X-Content-Type-Options']).toBe('nosniff'); + }); + + it('Referrer-Policy is always strict-origin-when-cross-origin', () => { + expect(buildSecurityHeaders({})['Referrer-Policy']).toBe('strict-origin-when-cross-origin'); + expect(buildSecurityHeaders({ hstsMaxAge: 0 })['Referrer-Policy']).toBe('strict-origin-when-cross-origin'); + }); + + it('X-Frame-Options is always SAMEORIGIN', () => { + expect(buildSecurityHeaders({})['X-Frame-Options']).toBe('SAMEORIGIN'); + }); + + it('HSTS max-age is at least 1 year (31536000 s) by default for preload eligibility', () => { + const hsts = buildSecurityHeaders({})['Strict-Transport-Security']; + const match = /max-age=(\d+)/.exec(hsts); + expect(match).not.toBeNull(); + expect(Number(match![1])).toBeGreaterThanOrEqual(31_536_000); + }); + + it('payment feature-policy is none by default (no inadvertent Payment Request API exposure)', () => { + const policy = buildSecurityHeaders({})['Permissions-Policy']; + expect(policy).toContain("payment='none'"); + }); }); diff --git a/backend/shared/middleware/securityHeaders.ts b/backend/shared/middleware/securityHeaders.ts index ce8bda56..6681b82d 100644 --- a/backend/shared/middleware/securityHeaders.ts +++ b/backend/shared/middleware/securityHeaders.ts @@ -38,7 +38,7 @@ const DEFAULT_PERMISSIONS_POLICY: Record = { xrSpatialTracking: ["'none'"], }; -function buildHsts(options: SecurityHeadersOptions): string { +export function buildHsts(options: SecurityHeadersOptions): string { const maxAge = options.hstsMaxAge ?? 63_072_000; const includeSubDomains = options.hstsIncludeSubDomains ?? true; const preload = options.hstsPreload ?? true; @@ -48,7 +48,7 @@ function buildHsts(options: SecurityHeadersOptions): string { return parts.join('; '); } -function buildPermissionsPolicy( +export function buildPermissionsPolicy( options: SecurityHeadersOptions, ): string { const policy = { ...DEFAULT_PERMISSIONS_POLICY, ...(options.permissionsPolicy ?? {}) };