Barazo AppView backend barazo.forum

fix(security): remove unsafe-inline from CSP, scope permissive policy to /docs (#64)

The global CSP allowed 'unsafe-inline' in scriptSrc and styleSrc plus
cdn.jsdelivr.net, which defeated XSS protection for all routes. These
were only needed by the Scalar API docs UI.

- Move Scalar registration into a scoped Fastify plugin (docsPlugin)
that overrides CSP via an onRequest hook for /docs routes only
- Strip 'unsafe-inline' and CDN allowlisting from the global CSP
- Add baseUri, formAction, and frameAncestors directives
- Add 14 unit tests verifying strict vs permissive CSP per route scope

authored by

Guido X Jansen and committed by
GitHub
7a4bb9cc fa73ac60

+206 -9
+35 -9
src/app.ts
··· 113 113 const firehose = new FirehoseService(db, app.log, env) 114 114 app.decorate('firehose', firehose) 115 115 116 - // Security headers 116 + // Security headers -- strict CSP for all routes (no unsafe-inline). 117 + // The /docs scope overrides this with a permissive CSP for Scalar UI. 117 118 await app.register(helmet, { 118 119 contentSecurityPolicy: { 119 120 directives: { 120 121 defaultSrc: ["'self'"], 121 - scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], 122 - styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], 122 + scriptSrc: ["'self'"], 123 + styleSrc: ["'self'"], 123 124 imgSrc: ["'self'", 'data:', 'https:'], 124 125 connectSrc: ["'self'"], 125 - fontSrc: ["'self'", 'https://cdn.jsdelivr.net'], 126 + fontSrc: ["'self'"], 126 127 objectSrc: ["'none'"], 127 128 frameSrc: ["'none'"], 129 + baseUri: ["'self'"], 130 + formAction: ["'self'"], 131 + frameAncestors: ["'none'"], 128 132 }, 129 133 }, 130 134 hsts: { ··· 241 245 }, 242 246 }) 243 247 244 - await app.register(scalarApiReference, { 245 - routePrefix: '/docs', 246 - configuration: { 247 - theme: 'kepler', 248 - }, 248 + // Scalar API docs UI requires inline scripts/styles and CDN assets. 249 + // Register in a scoped plugin to override the strict global CSP. 250 + await app.register(async function docsPlugin(scope) { 251 + const docsCsp = [ 252 + "default-src 'self'", 253 + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", 254 + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", 255 + "img-src 'self' data: https:", 256 + "connect-src 'self'", 257 + "font-src 'self' https://cdn.jsdelivr.net", 258 + "object-src 'none'", 259 + "frame-src 'none'", 260 + "base-uri 'self'", 261 + "form-action 'self'", 262 + "frame-ancestors 'none'", 263 + ].join('; ') 264 + 265 + scope.addHook('onRequest', async (_request, reply) => { 266 + reply.header('content-security-policy', docsCsp) 267 + }) 268 + 269 + await scope.register(scalarApiReference, { 270 + routePrefix: '/docs', 271 + configuration: { 272 + theme: 'kepler', 273 + }, 274 + }) 249 275 }) 250 276 251 277 // Routes
+171
tests/unit/security/csp.test.ts
··· 1 + import { describe, it, expect, beforeAll, afterAll } from 'vitest' 2 + import Fastify from 'fastify' 3 + import type { FastifyInstance } from 'fastify' 4 + import 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 + */ 13 + describe('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 + })