|
| 1 | +import { defineNuxtModule } from 'nuxt/kit' |
| 2 | +import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers' |
| 3 | +import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' |
| 4 | + |
| 5 | +/** |
| 6 | + * Adds Content-Security-Policy and security headers to all HTML responses |
| 7 | + * via a Nitro route rule. This covers both SSR/ISR pages and prerendered |
| 8 | + * pages (which don't run server middleware). |
| 9 | + * |
| 10 | + * Current policy uses 'unsafe-inline' for scripts and styles because: |
| 11 | + * - Nuxt injects inline scripts for hydration and payload transfer |
| 12 | + * - Vue uses inline styles for :style bindings and scoped CSS |
| 13 | + */ |
| 14 | +export default defineNuxtModule({ |
| 15 | + meta: { name: 'security-headers' }, |
| 16 | + setup(_, nuxt) { |
| 17 | + const imgSrc = [ |
| 18 | + "'self'", |
| 19 | + 'data:', |
| 20 | + ...TRUSTED_IMAGE_DOMAINS.map(domain => `https://${domain}`), |
| 21 | + ].join(' ') |
| 22 | + |
| 23 | + const connectSrc = [ |
| 24 | + "'self'", |
| 25 | + 'https://*.algolia.net', |
| 26 | + 'https://registry.npmjs.org', |
| 27 | + 'https://api.npmjs.org', |
| 28 | + 'https://npm.antfu.dev', |
| 29 | + ...ALL_KNOWN_GIT_API_ORIGINS, |
| 30 | + ].join(' ') |
| 31 | + |
| 32 | + const frameSrc = ['https://bsky.app', 'https://pdsmoover.com'].join(' ') |
| 33 | + |
| 34 | + const csp = [ |
| 35 | + `default-src 'none'`, |
| 36 | + `script-src 'self' 'unsafe-inline'`, |
| 37 | + `style-src 'self' 'unsafe-inline'`, |
| 38 | + `img-src ${imgSrc}`, |
| 39 | + `font-src 'self'`, |
| 40 | + `connect-src ${connectSrc}`, |
| 41 | + `frame-src ${frameSrc}`, |
| 42 | + `frame-ancestors 'none'`, |
| 43 | + `base-uri 'self'`, |
| 44 | + `form-action 'self'`, |
| 45 | + `object-src 'none'`, |
| 46 | + `manifest-src 'self'`, |
| 47 | + 'upgrade-insecure-requests', |
| 48 | + ].join('; ') |
| 49 | + |
| 50 | + const headers = { |
| 51 | + 'Content-Security-Policy': csp, |
| 52 | + 'X-Content-Type-Options': 'nosniff', |
| 53 | + 'X-Frame-Options': 'DENY', |
| 54 | + 'Referrer-Policy': 'strict-origin-when-cross-origin', |
| 55 | + } |
| 56 | + |
| 57 | + // Apply to all page routes via a catch-all rule. |
| 58 | + // API routes are excluded — CSP doesn't make sense for JSON responses. |
| 59 | + nuxt.options.routeRules ??= {} |
| 60 | + nuxt.options.routeRules['/**'] = { |
| 61 | + ...nuxt.options.routeRules['/**'], |
| 62 | + headers, |
| 63 | + } |
| 64 | + nuxt.options.routeRules['/api/**'] = { |
| 65 | + ...nuxt.options.routeRules['/api/**'], |
| 66 | + headers: { |
| 67 | + 'X-Content-Type-Options': 'nosniff', |
| 68 | + }, |
| 69 | + } |
| 70 | + }, |
| 71 | +}) |
0 commit comments