Skip to content

Commit 010723b

Browse files
committed
feat: add CSP and other security headers to HTML responses
Add a global Nitro middleware that sets CSP and security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) on page responses. API routes and internal paths (/_nuxt/, /_v/, etc.) are skipped. The CSP img-src directive imports TRUSTED_IMAGE_DOMAINS from the image proxy module so the two stay in sync automatically.
1 parent a170292 commit 010723b

File tree

3 files changed

+92
-1
lines changed

3 files changed

+92
-1
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { TRUSTED_IMAGE_DOMAINS } from '../utils/image-proxy'
2+
3+
/**
4+
* Set Content-Security-Policy and other security headers on HTML responses.
5+
*
6+
* Skips API routes and internal Nuxt/Vercel paths (see SKIP_PREFIXES).
7+
* Static assets from public/ are served by the CDN in production and don't
8+
* hit Nitro 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+
15+
const imgSrc = [
16+
"'self'",
17+
// README images may use data URIs
18+
'data:',
19+
// Trusted image domains loaded directly (not proxied).
20+
// All other README images go through /api/registry/image-proxy ('self').
21+
...TRUSTED_IMAGE_DOMAINS.map(domain => `https://${domain}`),
22+
].join(' ')
23+
24+
const connectSrc = [
25+
"'self'",
26+
'https://*.algolia.net', // Algolia npm-search client
27+
].join(' ')
28+
29+
const frameSrc = [
30+
'https://bsky.app', // embedded Bluesky posts
31+
'https://pdsmoover.com', // PDS migration tool
32+
].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+
/** Paths that should not receive the global CSP header. */
51+
const SKIP_PREFIXES = [
52+
'/api/', // API routes set their own headers (e.g. image proxy has its own CSP)
53+
'/_nuxt/', // Built JS/CSS chunks
54+
'/_v/', // Vercel analytics proxy
55+
'/_avatar/', // Gravatar proxy
56+
'/__og-image__/', // OG image generation
57+
'/__nuxt_error', // Nuxt error page (internal)
58+
]
59+
60+
export default defineEventHandler(event => {
61+
const path = event.path.split('?')[0]!
62+
if (SKIP_PREFIXES.some(prefix => path.startsWith(prefix))) {
63+
return
64+
}
65+
66+
setHeader(event, 'Content-Security-Policy', csp)
67+
setHeader(event, 'X-Content-Type-Options', 'nosniff')
68+
setHeader(event, 'X-Frame-Options', 'DENY')
69+
setHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin')
70+
})

server/utils/image-proxy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { lookup } from 'node:dns/promises'
2929
import ipaddr from 'ipaddr.js'
3030

3131
/** Trusted image domains that don't need proxying (first-party or well-known CDNs) */
32-
const TRUSTED_IMAGE_DOMAINS = [
32+
export const TRUSTED_IMAGE_DOMAINS = [
3333
// First-party
3434
'npmx.dev',
3535

test/e2e/security-headers.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect, test } from './test-utils'
2+
3+
test.describe('security headers', () => {
4+
test('HTML pages include CSP and security headers', async ({ page, baseURL }) => {
5+
const response = await page.goto(baseURL!)
6+
const headers = response!.headers()
7+
8+
expect(headers['content-security-policy']).toBeDefined()
9+
expect(headers['content-security-policy']).toContain("script-src 'self'")
10+
expect(headers['x-content-type-options']).toBe('nosniff')
11+
expect(headers['x-frame-options']).toBe('DENY')
12+
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin')
13+
})
14+
15+
test('API routes do not include CSP headers', async ({ page, baseURL }) => {
16+
const response = await page.request.get(`${baseURL}/api/registry/package-meta/vue`)
17+
const headers = response.headers()
18+
19+
expect(headers['content-security-policy']).toBeFalsy()
20+
})
21+
})

0 commit comments

Comments
 (0)