Barazo AppView backend barazo.forum
at main 171 lines 6.5 kB view raw
1import { describe, it, expect, beforeAll, afterAll } from 'vitest' 2import Fastify from 'fastify' 3import type { FastifyInstance } from 'fastify' 4import helmet from '@fastify/helmet' 5 6/** 7 * Tests for Content Security Policy configuration. 8 * 9 * API routes get a strict CSP (no 'unsafe-inline', no CDN allowlisting). 10 * The /docs scope gets a permissive CSP for the Scalar API reference UI, 11 * which requires inline scripts/styles and CDN assets. 12 */ 13describe('Content Security Policy', () => { 14 let app: FastifyInstance 15 16 // Permissive CSP for docs scope (must match app.ts DOCS_CSP) 17 const docsCsp = [ 18 "default-src 'self'", 19 "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", 20 "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", 21 "img-src 'self' data: https:", 22 "connect-src 'self'", 23 "font-src 'self' https://cdn.jsdelivr.net", 24 "object-src 'none'", 25 "frame-src 'none'", 26 "base-uri 'self'", 27 "form-action 'self'", 28 "frame-ancestors 'none'", 29 ].join('; ') 30 31 beforeAll(async () => { 32 app = Fastify({ logger: false }) 33 34 // Strict global CSP (mirrors app.ts helmet config) 35 await app.register(helmet, { 36 contentSecurityPolicy: { 37 directives: { 38 defaultSrc: ["'self'"], 39 scriptSrc: ["'self'"], 40 styleSrc: ["'self'"], 41 imgSrc: ["'self'", 'data:', 'https:'], 42 connectSrc: ["'self'"], 43 fontSrc: ["'self'"], 44 objectSrc: ["'none'"], 45 frameSrc: ["'none'"], 46 baseUri: ["'self'"], 47 formAction: ["'self'"], 48 frameAncestors: ["'none'"], 49 }, 50 }, 51 }) 52 53 // Simulate an API route 54 app.get('/api/test', () => ({ ok: true })) 55 56 // Simulate a non-API, non-docs route 57 app.get('/health', () => ({ status: 'ok' })) 58 59 // Docs scope with permissive CSP override (mirrors app.ts docsPlugin) 60 await app.register(function docsScope(scope, _opts, done) { 61 scope.addHook('onRequest', (_request, reply, hookDone) => { 62 reply.header('content-security-policy', docsCsp) 63 hookDone() 64 }) 65 scope.get('/docs', (_request, reply) => { 66 return reply.type('text/html').send('<html><body>docs</body></html>') 67 }) 68 done() 69 }) 70 71 await app.ready() 72 }) 73 74 afterAll(async () => { 75 await app.close() 76 }) 77 78 describe('API routes (strict CSP)', () => { 79 it('does not include unsafe-inline in script-src', async () => { 80 const response = await app.inject({ method: 'GET', url: '/api/test' }) 81 const csp = response.headers['content-security-policy'] as string 82 expect(csp).toBeDefined() 83 expect(csp).not.toContain('unsafe-inline') 84 }) 85 86 it('does not allow cdn.jsdelivr.net', async () => { 87 const response = await app.inject({ method: 'GET', url: '/api/test' }) 88 const csp = response.headers['content-security-policy'] as string 89 expect(csp).not.toContain('cdn.jsdelivr.net') 90 }) 91 92 it('restricts script-src to self only', async () => { 93 const response = await app.inject({ method: 'GET', url: '/api/test' }) 94 const csp = response.headers['content-security-policy'] as string 95 expect(csp).toContain("script-src 'self'") 96 }) 97 98 it('restricts style-src to self only', async () => { 99 const response = await app.inject({ method: 'GET', url: '/api/test' }) 100 const csp = response.headers['content-security-policy'] as string 101 expect(csp).toContain("style-src 'self'") 102 }) 103 }) 104 105 describe('docs routes (permissive CSP for Scalar)', () => { 106 it('allows unsafe-inline for scripts', async () => { 107 const response = await app.inject({ method: 'GET', url: '/docs' }) 108 const csp = response.headers['content-security-policy'] as string 109 expect(csp).toContain("'unsafe-inline'") 110 expect(csp).toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net") 111 }) 112 113 it('allows unsafe-inline for styles', async () => { 114 const response = await app.inject({ method: 'GET', url: '/docs' }) 115 const csp = response.headers['content-security-policy'] as string 116 expect(csp).toContain("style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net") 117 }) 118 119 it('allows cdn.jsdelivr.net for fonts', async () => { 120 const response = await app.inject({ method: 'GET', url: '/docs' }) 121 const csp = response.headers['content-security-policy'] as string 122 expect(csp).toContain("font-src 'self' https://cdn.jsdelivr.net") 123 }) 124 }) 125 126 describe('protective directives on all routes', () => { 127 it('sets base-uri on API routes', async () => { 128 const response = await app.inject({ method: 'GET', url: '/api/test' }) 129 const csp = response.headers['content-security-policy'] as string 130 expect(csp).toContain("base-uri 'self'") 131 }) 132 133 it('sets form-action on API routes', async () => { 134 const response = await app.inject({ method: 'GET', url: '/api/test' }) 135 const csp = response.headers['content-security-policy'] as string 136 expect(csp).toContain("form-action 'self'") 137 }) 138 139 it('sets frame-ancestors to none on API routes', async () => { 140 const response = await app.inject({ method: 'GET', url: '/api/test' }) 141 const csp = response.headers['content-security-policy'] as string 142 expect(csp).toContain("frame-ancestors 'none'") 143 }) 144 145 it('sets base-uri on docs routes', async () => { 146 const response = await app.inject({ method: 'GET', url: '/docs' }) 147 const csp = response.headers['content-security-policy'] as string 148 expect(csp).toContain("base-uri 'self'") 149 }) 150 151 it('sets form-action on docs routes', async () => { 152 const response = await app.inject({ method: 'GET', url: '/docs' }) 153 const csp = response.headers['content-security-policy'] as string 154 expect(csp).toContain("form-action 'self'") 155 }) 156 157 it('sets frame-ancestors to none on docs routes', async () => { 158 const response = await app.inject({ method: 'GET', url: '/docs' }) 159 const csp = response.headers['content-security-policy'] as string 160 expect(csp).toContain("frame-ancestors 'none'") 161 }) 162 163 it('applies strict CSP to non-API routes outside docs scope', async () => { 164 const response = await app.inject({ method: 'GET', url: '/health' }) 165 const csp = response.headers['content-security-policy'] as string 166 expect(csp).toBeDefined() 167 expect(csp).not.toContain('unsafe-inline') 168 expect(csp).not.toContain('cdn.jsdelivr.net') 169 }) 170 }) 171})