Skip to content

Commit f17932f

Browse files
committed
fix: set CSP via <meta> for easier targeting of just HTML
1 parent 2160c06 commit f17932f

File tree

2 files changed

+29
-20
lines changed

2 files changed

+29
-20
lines changed

modules/security-headers.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers'
33
import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy'
44

55
/**
6-
* Adds Content-Security-Policy and security headers to all HTML responses
7-
* via Nitro route rules. This covers both SSR/ISR pages and prerendered
8-
* pages (which are served as static files on Vercel and don't hit the server).
6+
* Adds Content-Security-Policy and other security headers to all pages.
97
*
10-
* API routes opt out via `false` to disable the inherited headers.
8+
* CSP is delivered via a <meta http-equiv> tag in <head>, so it naturally
9+
* only applies to HTML pages (not API routes). The remaining security
10+
* headers are set via a catch-all route rule.
11+
*
12+
* Note: frame-ancestors is not supported in meta-tag CSP, but
13+
* X-Frame-Options: DENY (set via route rule) provides equivalent protection.
1114
*
1215
* Current policy uses 'unsafe-inline' for scripts and styles because:
1316
* - Nuxt injects inline scripts for hydration and payload transfer
@@ -41,30 +44,31 @@ export default defineNuxtModule({
4144
`font-src 'self'`,
4245
`connect-src ${connectSrc}`,
4346
`frame-src ${frameSrc}`,
44-
`frame-ancestors 'none'`,
4547
`base-uri 'self'`,
4648
`form-action 'self'`,
4749
`object-src 'none'`,
4850
`manifest-src 'self'`,
4951
'upgrade-insecure-requests',
5052
].join('; ')
5153

52-
const headers = {
53-
'Content-Security-Policy': csp,
54-
'X-Content-Type-Options': 'nosniff',
55-
'X-Frame-Options': 'DENY',
56-
'Referrer-Policy': 'strict-origin-when-cross-origin',
57-
}
54+
// CSP via <meta> tag — only present in HTML pages, not API responses.
55+
nuxt.options.app.head ??= {}
56+
const head = nuxt.options.app.head as { meta?: Array<Record<string, string>> }
57+
head.meta ??= []
58+
head.meta.push({
59+
'http-equiv': 'Content-Security-Policy',
60+
'content': csp,
61+
})
5862

63+
// Other security headers via route rules (fine on all responses).
5964
nuxt.options.routeRules ??= {}
6065
nuxt.options.routeRules['/**'] = {
6166
...nuxt.options.routeRules['/**'],
62-
headers,
63-
}
64-
// Disable page-specific headers on API routes — CSP doesn't apply to JSON.
65-
nuxt.options.routeRules['/api/**'] = {
66-
...nuxt.options.routeRules['/api/**'],
67-
headers: false,
67+
headers: {
68+
'X-Content-Type-Options': 'nosniff',
69+
'X-Frame-Options': 'DENY',
70+
'Referrer-Policy': 'strict-origin-when-cross-origin',
71+
},
6872
}
6973
},
7074
})

test/e2e/security-headers.spec.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { expect, test } from './test-utils'
22

33
test.describe('security headers', () => {
4-
test('HTML pages include CSP and security headers', async ({ page, baseURL }) => {
4+
test('HTML pages include CSP meta tag and security headers', async ({ page, baseURL }) => {
55
const response = await page.goto(baseURL!)
66
const headers = response!.headers()
77

8-
expect(headers['content-security-policy']).toBeDefined()
9-
expect(headers['content-security-policy']).toContain("script-src 'self'")
8+
// CSP is delivered via <meta http-equiv> in <head>
9+
const cspContent = await page
10+
.locator('meta[http-equiv="Content-Security-Policy"]')
11+
.getAttribute('content')
12+
expect(cspContent).toContain("script-src 'self'")
13+
14+
// Other security headers via route rules
1015
expect(headers['x-content-type-options']).toBe('nosniff')
1116
expect(headers['x-frame-options']).toBe('DENY')
1217
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin')

0 commit comments

Comments
 (0)