From 010723b637f62c0fe6f7b686af345b83fdde3379 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 14 Mar 2026 09:45:56 -0400 Subject: [PATCH 1/9] 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. --- server/middleware/security-headers.global.ts | 70 ++++++++++++++++++++ server/utils/image-proxy.ts | 2 +- test/e2e/security-headers.spec.ts | 21 ++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 server/middleware/security-headers.global.ts create mode 100644 test/e2e/security-headers.spec.ts diff --git a/server/middleware/security-headers.global.ts b/server/middleware/security-headers.global.ts new file mode 100644 index 0000000000..e4b215f8fe --- /dev/null +++ b/server/middleware/security-headers.global.ts @@ -0,0 +1,70 @@ +import { TRUSTED_IMAGE_DOMAINS } from '../utils/image-proxy' + +/** + * Set Content-Security-Policy and other security headers on HTML responses. + * + * Skips API routes and internal Nuxt/Vercel paths (see SKIP_PREFIXES). + * Static assets from public/ are served by the CDN in production and don't + * hit Nitro middleware. + * + * Current policy uses 'unsafe-inline' for scripts and styles because: + * - Nuxt injects inline scripts for hydration and payload transfer + * - Vue uses inline styles for :style bindings and scoped CSS + */ + +const imgSrc = [ + "'self'", + // README images may use data URIs + 'data:', + // Trusted image domains loaded directly (not proxied). + // All other README images go through /api/registry/image-proxy ('self'). + ...TRUSTED_IMAGE_DOMAINS.map(domain => `https://${domain}`), +].join(' ') + +const connectSrc = [ + "'self'", + 'https://*.algolia.net', // Algolia npm-search client +].join(' ') + +const frameSrc = [ + 'https://bsky.app', // embedded Bluesky posts + 'https://pdsmoover.com', // PDS migration tool +].join(' ') + +const csp = [ + `default-src 'none'`, + `script-src 'self' 'unsafe-inline'`, + `style-src 'self' 'unsafe-inline'`, + `img-src ${imgSrc}`, + `font-src 'self'`, + `connect-src ${connectSrc}`, + `frame-src ${frameSrc}`, + `frame-ancestors 'none'`, + `base-uri 'self'`, + `form-action 'self'`, + `object-src 'none'`, + `manifest-src 'self'`, + 'upgrade-insecure-requests', +].join('; ') + +/** Paths that should not receive the global CSP header. */ +const SKIP_PREFIXES = [ + '/api/', // API routes set their own headers (e.g. image proxy has its own CSP) + '/_nuxt/', // Built JS/CSS chunks + '/_v/', // Vercel analytics proxy + '/_avatar/', // Gravatar proxy + '/__og-image__/', // OG image generation + '/__nuxt_error', // Nuxt error page (internal) +] + +export default defineEventHandler(event => { + const path = event.path.split('?')[0]! + if (SKIP_PREFIXES.some(prefix => path.startsWith(prefix))) { + return + } + + setHeader(event, 'Content-Security-Policy', csp) + setHeader(event, 'X-Content-Type-Options', 'nosniff') + setHeader(event, 'X-Frame-Options', 'DENY') + setHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin') +}) diff --git a/server/utils/image-proxy.ts b/server/utils/image-proxy.ts index ae590a9d0d..5d5ac420f4 100644 --- a/server/utils/image-proxy.ts +++ b/server/utils/image-proxy.ts @@ -29,7 +29,7 @@ import { lookup } from 'node:dns/promises' import ipaddr from 'ipaddr.js' /** Trusted image domains that don't need proxying (first-party or well-known CDNs) */ -const TRUSTED_IMAGE_DOMAINS = [ +export const TRUSTED_IMAGE_DOMAINS = [ // First-party 'npmx.dev', diff --git a/test/e2e/security-headers.spec.ts b/test/e2e/security-headers.spec.ts new file mode 100644 index 0000000000..7652da8d6e --- /dev/null +++ b/test/e2e/security-headers.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from './test-utils' + +test.describe('security headers', () => { + test('HTML pages include CSP and security headers', async ({ page, baseURL }) => { + const response = await page.goto(baseURL!) + const headers = response!.headers() + + expect(headers['content-security-policy']).toBeDefined() + expect(headers['content-security-policy']).toContain("script-src 'self'") + expect(headers['x-content-type-options']).toBe('nosniff') + expect(headers['x-frame-options']).toBe('DENY') + expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin') + }) + + test('API routes do not include CSP headers', async ({ page, baseURL }) => { + const response = await page.request.get(`${baseURL}/api/registry/package-meta/vue`) + const headers = response.headers() + + expect(headers['content-security-policy']).toBeFalsy() + }) +}) From a06d5fc64f7a9dd5de6b31d6ddf4ad9e3093c2d4 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 14 Mar 2026 10:16:34 -0400 Subject: [PATCH 2/9] fix: allow more origins in CSP --- app/composables/useRepoMeta.ts | 12 +++++------ server/middleware/security-headers.global.ts | 11 +++++++++- shared/utils/git-providers.ts | 21 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/composables/useRepoMeta.ts b/app/composables/useRepoMeta.ts index 226812338e..e79e52418f 100644 --- a/app/composables/useRepoMeta.ts +++ b/app/composables/useRepoMeta.ts @@ -1,5 +1,5 @@ import type { ProviderId, RepoRef } from '#shared/utils/git-providers' -import { parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers' +import { GIT_PROVIDER_API_ORIGINS, parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers' // TTL for git repo metadata (10 minutes - repo stats don't change frequently) const REPO_META_TTL = 60 * 10 @@ -132,7 +132,7 @@ const githubAdapter: ProviderAdapter = { let res: UnghRepoResponse | null = null try { const { data } = await cachedFetch( - `https://ungh.cc/repos/${ref.owner}/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.github}/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) @@ -254,7 +254,7 @@ const bitbucketAdapter: ProviderAdapter = { let res: BitbucketRepoResponse | null = null try { const { data } = await cachedFetch( - `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.bitbucket}/2.0/repositories/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) @@ -312,7 +312,7 @@ const codebergAdapter: ProviderAdapter = { let res: GiteaRepoResponse | null = null try { const { data } = await cachedFetch( - `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.codeberg}/api/v1/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) @@ -370,7 +370,7 @@ const giteeAdapter: ProviderAdapter = { let res: GiteeRepoResponse | null = null try { const { data } = await cachedFetch( - `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.gitee}/api/v5/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) @@ -623,7 +623,7 @@ const radicleAdapter: ProviderAdapter = { let res: RadicleProjectResponse | null = null try { const { data } = await cachedFetch( - `https://seed.radicle.at/api/v1/projects/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.radicle}/api/v1/projects/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) diff --git a/server/middleware/security-headers.global.ts b/server/middleware/security-headers.global.ts index e4b215f8fe..750b95c688 100644 --- a/server/middleware/security-headers.global.ts +++ b/server/middleware/security-headers.global.ts @@ -1,3 +1,4 @@ +import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers' import { TRUSTED_IMAGE_DOMAINS } from '../utils/image-proxy' /** @@ -23,7 +24,15 @@ const imgSrc = [ const connectSrc = [ "'self'", - 'https://*.algolia.net', // Algolia npm-search client + // Algolia npm-search client + 'https://*.algolia.net', + // npm registry & API (client-side fetches via $npmRegistry, $npmApi, useCachedFetch) + 'https://registry.npmjs.org', + 'https://api.npmjs.org', + // fast-npm-meta (version resolution) + 'https://npm.antfu.dev', + // Git hosting APIs (repo metadata on client-side navigation) + ...ALL_KNOWN_GIT_API_ORIGINS, ].join(' ') const frameSrc = [ diff --git a/shared/utils/git-providers.ts b/shared/utils/git-providers.ts index e14e82f5f7..7f42ba2705 100644 --- a/shared/utils/git-providers.ts +++ b/shared/utils/git-providers.ts @@ -404,3 +404,24 @@ export function convertBlobOrFileToRawUrl(url: string, providerId: ProviderId): export function isKnownGitProvider(url: string): boolean { return parseRepoUrl(url) !== null } + +/** + * API origins used by each provider for client-side repo metadata fetches. + * Self-hosted providers are excluded because their origins can be anything. + */ +export const GIT_PROVIDER_API_ORIGINS = { + github: 'https://ungh.cc', // via UNGH proxy to avoid rate limits + bitbucket: 'https://api.bitbucket.org', + codeberg: 'https://codeberg.org', + gitee: 'https://gitee.com', + radicle: 'https://seed.radicle.at', +} as const satisfies Partial> + +/** + * All known external API origins that git provider adapters may fetch from. + * Includes both the per-provider origins and known self-hosted instances. + */ +export const ALL_KNOWN_GIT_API_ORIGINS: readonly string[] = [ + ...Object.values(GIT_PROVIDER_API_ORIGINS), + ...GITLAB_HOSTS.map(host => `https://${host}`), +] From 5ff3953b1ef1ff63eb5cf462f46abfaf8b98efd9 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 14 Mar 2026 10:17:11 -0400 Subject: [PATCH 3/9] test: catch new CSP violations before they land --- test/e2e/security-headers.spec.ts | 11 +++++++++++ test/e2e/test-utils.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/test/e2e/security-headers.spec.ts b/test/e2e/security-headers.spec.ts index 7652da8d6e..2396d17ec0 100644 --- a/test/e2e/security-headers.spec.ts +++ b/test/e2e/security-headers.spec.ts @@ -18,4 +18,15 @@ test.describe('security headers', () => { expect(headers['content-security-policy']).toBeFalsy() }) + + // Navigate key pages and assert no CSP violations are logged. + // This catches new external resources that weren't added to the CSP. + const PAGES = ['/', '/package/nuxt', '/search?q=vue', '/compare'] as const + + for (const path of PAGES) { + test(`no CSP violations on ${path}`, async ({ goto, cspViolations }) => { + await goto(path, { waitUntil: 'hydration' }) + expect(cspViolations).toEqual([]) + }) + } }) diff --git a/test/e2e/test-utils.ts b/test/e2e/test-utils.ts index 66465b03cb..a7ad3234ad 100644 --- a/test/e2e/test-utils.ts +++ b/test/e2e/test-utils.ts @@ -74,6 +74,19 @@ function isHydrationMismatch(message: ConsoleMessage): boolean { return HYDRATION_MISMATCH_PATTERNS.some(pattern => text.includes(pattern)) } +/** + * Detect Content-Security-Policy violations logged to the console. + * + * Browsers log CSP violations as console errors with a distinctive prefix. + * Catching these in e2e tests ensures new external resources are added to the + * CSP before they land in production. + */ +function isCspViolation(message: ConsoleMessage): boolean { + if (message.type() !== 'error') return false + const text = message.text() + return text.includes('Content-Security-Policy') || text.includes('content security policy') +} + /** * Extended test fixture with automatic external API mocking and hydration mismatch detection. * @@ -83,7 +96,11 @@ function isHydrationMismatch(message: ConsoleMessage): boolean { * Hydration mismatches are detected via Vue's console.error output, which is always * emitted in production builds when server-rendered HTML doesn't match client expectations. */ -export const test = base.extend<{ mockExternalApis: void; hydrationErrors: string[] }>({ +export const test = base.extend<{ + mockExternalApis: void + hydrationErrors: string[] + cspViolations: string[] +}>({ mockExternalApis: [ async ({ page }, use) => { await setupRouteMocking(page) @@ -103,6 +120,18 @@ export const test = base.extend<{ mockExternalApis: void; hydrationErrors: strin await use(errors) }, + + cspViolations: async ({ page }, use) => { + const violations: string[] = [] + + page.on('console', message => { + if (isCspViolation(message)) { + violations.push(message.text()) + } + }) + + await use(violations) + }, }) export { expect } From a22dff5c1f45379e5dd14320cee3e871bb93e25e Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 14 Mar 2026 10:49:54 -0400 Subject: [PATCH 4/9] fix: also apply security headers to prerendered pages --- modules/security-headers.ts | 71 ++++++++++++++++++ server/middleware/security-headers.global.ts | 79 -------------------- test/e2e/security-headers.spec.ts | 5 +- 3 files changed, 73 insertions(+), 82 deletions(-) create mode 100644 modules/security-headers.ts delete mode 100644 server/middleware/security-headers.global.ts diff --git a/modules/security-headers.ts b/modules/security-headers.ts new file mode 100644 index 0000000000..5870ada417 --- /dev/null +++ b/modules/security-headers.ts @@ -0,0 +1,71 @@ +import { defineNuxtModule } from 'nuxt/kit' +import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers' +import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' + +/** + * Adds Content-Security-Policy and security headers to all HTML responses + * via a Nitro route rule. This covers both SSR/ISR pages and prerendered + * pages (which don't run server middleware). + * + * Current policy uses 'unsafe-inline' for scripts and styles because: + * - Nuxt injects inline scripts for hydration and payload transfer + * - Vue uses inline styles for :style bindings and scoped CSS + */ +export default defineNuxtModule({ + meta: { name: 'security-headers' }, + setup(_, nuxt) { + const imgSrc = [ + "'self'", + 'data:', + ...TRUSTED_IMAGE_DOMAINS.map(domain => `https://${domain}`), + ].join(' ') + + const connectSrc = [ + "'self'", + 'https://*.algolia.net', + 'https://registry.npmjs.org', + 'https://api.npmjs.org', + 'https://npm.antfu.dev', + ...ALL_KNOWN_GIT_API_ORIGINS, + ].join(' ') + + const frameSrc = ['https://bsky.app', 'https://pdsmoover.com'].join(' ') + + const csp = [ + `default-src 'none'`, + `script-src 'self' 'unsafe-inline'`, + `style-src 'self' 'unsafe-inline'`, + `img-src ${imgSrc}`, + `font-src 'self'`, + `connect-src ${connectSrc}`, + `frame-src ${frameSrc}`, + `frame-ancestors 'none'`, + `base-uri 'self'`, + `form-action 'self'`, + `object-src 'none'`, + `manifest-src 'self'`, + 'upgrade-insecure-requests', + ].join('; ') + + const headers = { + 'Content-Security-Policy': csp, + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + } + + // Apply to all page routes via a catch-all rule. + // API routes are excluded — CSP doesn't make sense for JSON responses. + nuxt.options.routeRules ??= {} + nuxt.options.routeRules['/**'] = { + ...nuxt.options.routeRules['/**'], + headers, + } + nuxt.options.routeRules['/api/**'] = { + ...nuxt.options.routeRules['/api/**'], + headers: { + 'X-Content-Type-Options': 'nosniff', + }, + } + }, +}) diff --git a/server/middleware/security-headers.global.ts b/server/middleware/security-headers.global.ts deleted file mode 100644 index 750b95c688..0000000000 --- a/server/middleware/security-headers.global.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers' -import { TRUSTED_IMAGE_DOMAINS } from '../utils/image-proxy' - -/** - * Set Content-Security-Policy and other security headers on HTML responses. - * - * Skips API routes and internal Nuxt/Vercel paths (see SKIP_PREFIXES). - * Static assets from public/ are served by the CDN in production and don't - * hit Nitro middleware. - * - * Current policy uses 'unsafe-inline' for scripts and styles because: - * - Nuxt injects inline scripts for hydration and payload transfer - * - Vue uses inline styles for :style bindings and scoped CSS - */ - -const imgSrc = [ - "'self'", - // README images may use data URIs - 'data:', - // Trusted image domains loaded directly (not proxied). - // All other README images go through /api/registry/image-proxy ('self'). - ...TRUSTED_IMAGE_DOMAINS.map(domain => `https://${domain}`), -].join(' ') - -const connectSrc = [ - "'self'", - // Algolia npm-search client - 'https://*.algolia.net', - // npm registry & API (client-side fetches via $npmRegistry, $npmApi, useCachedFetch) - 'https://registry.npmjs.org', - 'https://api.npmjs.org', - // fast-npm-meta (version resolution) - 'https://npm.antfu.dev', - // Git hosting APIs (repo metadata on client-side navigation) - ...ALL_KNOWN_GIT_API_ORIGINS, -].join(' ') - -const frameSrc = [ - 'https://bsky.app', // embedded Bluesky posts - 'https://pdsmoover.com', // PDS migration tool -].join(' ') - -const csp = [ - `default-src 'none'`, - `script-src 'self' 'unsafe-inline'`, - `style-src 'self' 'unsafe-inline'`, - `img-src ${imgSrc}`, - `font-src 'self'`, - `connect-src ${connectSrc}`, - `frame-src ${frameSrc}`, - `frame-ancestors 'none'`, - `base-uri 'self'`, - `form-action 'self'`, - `object-src 'none'`, - `manifest-src 'self'`, - 'upgrade-insecure-requests', -].join('; ') - -/** Paths that should not receive the global CSP header. */ -const SKIP_PREFIXES = [ - '/api/', // API routes set their own headers (e.g. image proxy has its own CSP) - '/_nuxt/', // Built JS/CSS chunks - '/_v/', // Vercel analytics proxy - '/_avatar/', // Gravatar proxy - '/__og-image__/', // OG image generation - '/__nuxt_error', // Nuxt error page (internal) -] - -export default defineEventHandler(event => { - const path = event.path.split('?')[0]! - if (SKIP_PREFIXES.some(prefix => path.startsWith(prefix))) { - return - } - - setHeader(event, 'Content-Security-Policy', csp) - setHeader(event, 'X-Content-Type-Options', 'nosniff') - setHeader(event, 'X-Frame-Options', 'DENY') - setHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin') -}) diff --git a/test/e2e/security-headers.spec.ts b/test/e2e/security-headers.spec.ts index 2396d17ec0..2471cbff60 100644 --- a/test/e2e/security-headers.spec.ts +++ b/test/e2e/security-headers.spec.ts @@ -12,11 +12,10 @@ test.describe('security headers', () => { expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin') }) - test('API routes do not include CSP headers', async ({ page, baseURL }) => { + test('API routes do not include CSP', async ({ page, baseURL }) => { const response = await page.request.get(`${baseURL}/api/registry/package-meta/vue`) - const headers = response.headers() - expect(headers['content-security-policy']).toBeFalsy() + expect(response.headers()['content-security-policy']).toBeUndefined() }) // Navigate key pages and assert no CSP violations are logged. From 2160c06cf603729c0f01644ff04717fc19ce6696 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 14 Mar 2026 11:24:57 -0400 Subject: [PATCH 5/9] fix: actually disable security headers on API routes --- modules/security-headers.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/modules/security-headers.ts b/modules/security-headers.ts index 5870ada417..dde50cb607 100644 --- a/modules/security-headers.ts +++ b/modules/security-headers.ts @@ -4,8 +4,10 @@ import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' /** * Adds Content-Security-Policy and security headers to all HTML responses - * via a Nitro route rule. This covers both SSR/ISR pages and prerendered - * pages (which don't run server middleware). + * via Nitro route rules. This covers both SSR/ISR pages and prerendered + * pages (which are served as static files on Vercel and don't hit the server). + * + * API routes opt out via `false` to disable the inherited headers. * * Current policy uses 'unsafe-inline' for scripts and styles because: * - Nuxt injects inline scripts for hydration and payload transfer @@ -54,18 +56,15 @@ export default defineNuxtModule({ 'Referrer-Policy': 'strict-origin-when-cross-origin', } - // Apply to all page routes via a catch-all rule. - // API routes are excluded — CSP doesn't make sense for JSON responses. nuxt.options.routeRules ??= {} nuxt.options.routeRules['/**'] = { ...nuxt.options.routeRules['/**'], headers, } + // Disable page-specific headers on API routes — CSP doesn't apply to JSON. nuxt.options.routeRules['/api/**'] = { ...nuxt.options.routeRules['/api/**'], - headers: { - 'X-Content-Type-Options': 'nosniff', - }, + headers: false, } }, }) From f17932f7467cc00169346e1bf8a42991d7409f21 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 14 Mar 2026 16:42:20 -0400 Subject: [PATCH 6/9] fix: set CSP via for easier targeting of just HTML --- modules/security-headers.ts | 38 +++++++++++++++++-------------- test/e2e/security-headers.spec.ts | 11 ++++++--- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/modules/security-headers.ts b/modules/security-headers.ts index dde50cb607..9f2ff7cf30 100644 --- a/modules/security-headers.ts +++ b/modules/security-headers.ts @@ -3,11 +3,14 @@ import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers' import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' /** - * Adds Content-Security-Policy and security headers to all HTML responses - * via Nitro route rules. This covers both SSR/ISR pages and prerendered - * pages (which are served as static files on Vercel and don't hit the server). + * Adds Content-Security-Policy and other security headers to all pages. * - * API routes opt out via `false` to disable the inherited headers. + * CSP is delivered via a tag in , so it naturally + * only applies to HTML pages (not API routes). The remaining security + * headers are set via a catch-all route rule. + * + * Note: frame-ancestors is not supported in meta-tag CSP, but + * X-Frame-Options: DENY (set via route rule) provides equivalent protection. * * Current policy uses 'unsafe-inline' for scripts and styles because: * - Nuxt injects inline scripts for hydration and payload transfer @@ -41,7 +44,6 @@ export default defineNuxtModule({ `font-src 'self'`, `connect-src ${connectSrc}`, `frame-src ${frameSrc}`, - `frame-ancestors 'none'`, `base-uri 'self'`, `form-action 'self'`, `object-src 'none'`, @@ -49,22 +51,24 @@ export default defineNuxtModule({ 'upgrade-insecure-requests', ].join('; ') - const headers = { - 'Content-Security-Policy': csp, - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'Referrer-Policy': 'strict-origin-when-cross-origin', - } + // CSP via tag — only present in HTML pages, not API responses. + nuxt.options.app.head ??= {} + const head = nuxt.options.app.head as { meta?: Array> } + head.meta ??= [] + head.meta.push({ + 'http-equiv': 'Content-Security-Policy', + 'content': csp, + }) + // Other security headers via route rules (fine on all responses). nuxt.options.routeRules ??= {} nuxt.options.routeRules['/**'] = { ...nuxt.options.routeRules['/**'], - headers, - } - // Disable page-specific headers on API routes — CSP doesn't apply to JSON. - nuxt.options.routeRules['/api/**'] = { - ...nuxt.options.routeRules['/api/**'], - headers: false, + headers: { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + }, } }, }) diff --git a/test/e2e/security-headers.spec.ts b/test/e2e/security-headers.spec.ts index 2471cbff60..8e4925cfb2 100644 --- a/test/e2e/security-headers.spec.ts +++ b/test/e2e/security-headers.spec.ts @@ -1,12 +1,17 @@ import { expect, test } from './test-utils' test.describe('security headers', () => { - test('HTML pages include CSP and security headers', async ({ page, baseURL }) => { + test('HTML pages include CSP meta tag and security headers', async ({ page, baseURL }) => { const response = await page.goto(baseURL!) const headers = response!.headers() - expect(headers['content-security-policy']).toBeDefined() - expect(headers['content-security-policy']).toContain("script-src 'self'") + // CSP is delivered via in + const cspContent = await page + .locator('meta[http-equiv="Content-Security-Policy"]') + .getAttribute('content') + expect(cspContent).toContain("script-src 'self'") + + // Other security headers via route rules expect(headers['x-content-type-options']).toBe('nosniff') expect(headers['x-frame-options']).toBe('DENY') expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin') From 49438d8d9cc96d922297b1f9c3ec07cc590892df Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 14 Mar 2026 17:29:20 -0400 Subject: [PATCH 7/9] fix: allow localhost connection in CSP --- modules/security-headers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/security-headers.ts b/modules/security-headers.ts index 9f2ff7cf30..64479f7140 100644 --- a/modules/security-headers.ts +++ b/modules/security-headers.ts @@ -32,6 +32,8 @@ export default defineNuxtModule({ 'https://api.npmjs.org', 'https://npm.antfu.dev', ...ALL_KNOWN_GIT_API_ORIGINS, + // Local CLI connector (npmx CLI communicates via localhost) + 'http://127.0.0.1:*', ].join(' ') const frameSrc = ['https://bsky.app', 'https://pdsmoover.com'].join(' ') From 5379482bfceecefc1b8676d43d6d8906884b5401 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:28:53 +0000 Subject: [PATCH 8/9] chore: retain existing headers --- modules/security-headers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/security-headers.ts b/modules/security-headers.ts index 64479f7140..517b387873 100644 --- a/modules/security-headers.ts +++ b/modules/security-headers.ts @@ -64,9 +64,11 @@ export default defineNuxtModule({ // Other security headers via route rules (fine on all responses). nuxt.options.routeRules ??= {} + const wildCardRules = nuxt.options.routeRules['/**']; nuxt.options.routeRules['/**'] = { - ...nuxt.options.routeRules['/**'], + ...wildCardRules, headers: { + ...wildCardRules?.headers, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'strict-origin-when-cross-origin', From de9d5882c9f8aa006bb076dc110d5ceea392b1dd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:30:24 +0000 Subject: [PATCH 9/9] [autofix.ci] apply automated fixes --- modules/security-headers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/security-headers.ts b/modules/security-headers.ts index 517b387873..68d4a7d559 100644 --- a/modules/security-headers.ts +++ b/modules/security-headers.ts @@ -64,7 +64,7 @@ export default defineNuxtModule({ // Other security headers via route rules (fine on all responses). nuxt.options.routeRules ??= {} - const wildCardRules = nuxt.options.routeRules['/**']; + const wildCardRules = nuxt.options.routeRules['/**'] nuxt.options.routeRules['/**'] = { ...wildCardRules, headers: {