From f02eb396db1f6f85552c44354bcab2e0c3916590 Mon Sep 17 00:00:00 2001 From: rygrit Date: Mon, 30 Mar 2026 20:45:04 +0800 Subject: [PATCH 1/3] fix: skip security headers when devtools is active --- modules/security-headers.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/security-headers.ts b/modules/security-headers.ts index 748e5ac6c4..8ffc300306 100644 --- a/modules/security-headers.ts +++ b/modules/security-headers.ts @@ -20,6 +20,13 @@ import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' export default defineNuxtModule({ meta: { name: 'security-headers' }, setup(_, nuxt) { + const isDevtoolsRuntime = + nuxt.options.dev && nuxt.options.devtools !== false && !process.env.TEST + + // Nuxt DevTools relies on injected client assets and an iframe-based UI in dev. + // Keep strict CSP/frame restrictions for non-dev environments. + if (isDevtoolsRuntime) return + // These assets are embedded directly on blog pages and should not affect image-proxy trust. const cspOnlyImgOrigins = ['https://api.star-history.com', 'https://cdn.bsky.app'] const imgSrc = [ From c853088d233e9c0e89b3967db2ec97d157878ee4 Mon Sep 17 00:00:00 2001 From: rygrit Date: Sat, 4 Apr 2026 21:18:34 +0800 Subject: [PATCH 2/3] fix: relax devtools-only CSP and frame headers in dev --- modules/security-headers.ts | 48 +++++++-- test/unit/modules/security-headers.spec.ts | 115 +++++++++++++++++++++ 2 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 test/unit/modules/security-headers.spec.ts diff --git a/modules/security-headers.ts b/modules/security-headers.ts index 8ffc300306..8c59be19c7 100644 --- a/modules/security-headers.ts +++ b/modules/security-headers.ts @@ -1,4 +1,4 @@ -import { defineNuxtModule } from 'nuxt/kit' +import { defineNuxtModule, useNuxt } from 'nuxt/kit' import { BLUESKY_API } from '#shared/utils/constants' import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers' import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' @@ -19,13 +19,15 @@ import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' */ export default defineNuxtModule({ meta: { name: 'security-headers' }, - setup(_, nuxt) { - const isDevtoolsRuntime = - nuxt.options.dev && nuxt.options.devtools !== false && !process.env.TEST + setup() { + const nuxt = useNuxt() + const devtools = nuxt.options.devtools - // Nuxt DevTools relies on injected client assets and an iframe-based UI in dev. - // Keep strict CSP/frame restrictions for non-dev environments. - if (isDevtoolsRuntime) return + const isDevtoolsRuntime = + nuxt.options.dev + && devtools !== false + && (devtools == null || typeof devtools !== 'object' || devtools.enabled !== false) + && !process.env.TEST // These assets are embedded directly on blog pages and should not affect image-proxy trust. const cspOnlyImgOrigins = ['https://api.star-history.com', 'https://cdn.bsky.app'] @@ -46,9 +48,21 @@ export default defineNuxtModule({ ...ALL_KNOWN_GIT_API_ORIGINS, // Local CLI connector (npmx CLI communicates via localhost) 'http://127.0.0.1:*', + // Devtools runtime (Vue Devtools, Nuxt Devtools, etc) — only in dev mode with devtools enabled + ...(isDevtoolsRuntime ? ['ws://localhost:*'] : []), + ].join(' ') + + const frameSrc = [ + 'https://bsky.app', + 'https://pdsmoover.com', + ...(isDevtoolsRuntime ? ["'self'"] : []), ].join(' ') - const frameSrc = ['https://bsky.app', 'https://pdsmoover.com'].join(' ') + const securityHeaders = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + } const csp = [ `default-src 'none'`, @@ -81,9 +95,21 @@ export default defineNuxtModule({ ...wildCardRules, headers: { ...wildCardRules?.headers, - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'Referrer-Policy': 'strict-origin-when-cross-origin', + ...securityHeaders, + }, + } + + if (!isDevtoolsRuntime) + return + + const devtoolsRule = nuxt.options.routeRules['/__nuxt_devtools__/**'] + nuxt.options.routeRules['/__nuxt_devtools__/**'] = { + ...devtoolsRule, + headers: { + ...wildCardRules?.headers, + ...securityHeaders, + ...devtoolsRule?.headers, + 'X-Frame-Options': 'SAMEORIGIN', }, } }, diff --git a/test/unit/modules/security-headers.spec.ts b/test/unit/modules/security-headers.spec.ts new file mode 100644 index 0000000000..126afe506a --- /dev/null +++ b/test/unit/modules/security-headers.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { useNuxt } = vi.hoisted(() => ({ + useNuxt: vi.fn(), +})) + +vi.mock('nuxt/kit', () => ({ + defineNuxtModule: (module: T) => module, + useNuxt, +})) + +import securityHeadersModule from '../../../modules/security-headers' + +type RouteRule = { + headers?: Record + redirect?: string +} + +type MockNuxt = { + options: { + app: { + head?: { + meta?: Array> + } + } + dev: boolean + devtools?: boolean | { enabled?: boolean } + routeRules: Record + } +} + +function createNuxt(options: Partial = {}): MockNuxt { + return { + options: { + app: {}, + dev: false, + devtools: false, + routeRules: {}, + ...options, + }, + } +} + +function getCsp(nuxt: MockNuxt) { + return nuxt.options.app.head?.meta?.find( + meta => meta['http-equiv'] === 'Content-Security-Policy', + )?.content +} + +describe('security headers module', () => { + beforeEach(() => { + delete process.env.TEST + useNuxt.mockReset() + }) + + it('keeps security headers and only relaxes devtools-specific bits in dev', () => { + const nuxt = createNuxt({ + dev: true, + devtools: { enabled: true }, + routeRules: { + '/**': { + headers: { + 'Permissions-Policy': 'camera=()', + }, + }, + '/__nuxt_devtools__/**': { + headers: { + 'Cache-Control': 'no-store', + }, + redirect: '/devtools', + }, + }, + }) + + useNuxt.mockReturnValue(nuxt) + securityHeadersModule.setup() + + const csp = getCsp(nuxt) + + expect(csp).toContain('ws://localhost:*') + expect(csp).toContain("frame-src https://bsky.app https://pdsmoover.com 'self'") + expect(nuxt.options.routeRules['/**']?.headers).toEqual(expect.objectContaining({ + 'Permissions-Policy': 'camera=()', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + })) + expect(nuxt.options.routeRules['/__nuxt_devtools__/**']).toEqual({ + headers: { + 'Cache-Control': 'no-store', + 'Permissions-Policy': 'camera=()', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + }, + redirect: '/devtools', + }) + }) + + it('does not apply devtools relaxations when devtools are disabled via object config', () => { + const nuxt = createNuxt({ + dev: true, + devtools: { enabled: false }, + }) + + useNuxt.mockReturnValue(nuxt) + securityHeadersModule.setup() + + const csp = getCsp(nuxt) + + expect(csp).not.toContain('ws://localhost:*') + expect(csp).not.toContain("frame-src https://bsky.app https://pdsmoover.com 'self'") + expect(nuxt.options.routeRules['/__nuxt_devtools__/**']).toBeUndefined() + }) +}) From 0c92f0890974850c4ad219324e6311a9bfb27de3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:19:43 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- modules/security-headers.ts | 11 +++++------ test/unit/modules/security-headers.spec.ts | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/modules/security-headers.ts b/modules/security-headers.ts index 8c59be19c7..e5b6b0dfaa 100644 --- a/modules/security-headers.ts +++ b/modules/security-headers.ts @@ -24,10 +24,10 @@ export default defineNuxtModule({ const devtools = nuxt.options.devtools const isDevtoolsRuntime = - nuxt.options.dev - && devtools !== false - && (devtools == null || typeof devtools !== 'object' || devtools.enabled !== false) - && !process.env.TEST + nuxt.options.dev && + devtools !== false && + (devtools == null || typeof devtools !== 'object' || devtools.enabled !== false) && + !process.env.TEST // These assets are embedded directly on blog pages and should not affect image-proxy trust. const cspOnlyImgOrigins = ['https://api.star-history.com', 'https://cdn.bsky.app'] @@ -99,8 +99,7 @@ export default defineNuxtModule({ }, } - if (!isDevtoolsRuntime) - return + if (!isDevtoolsRuntime) return const devtoolsRule = nuxt.options.routeRules['/__nuxt_devtools__/**'] nuxt.options.routeRules['/__nuxt_devtools__/**'] = { diff --git a/test/unit/modules/security-headers.spec.ts b/test/unit/modules/security-headers.spec.ts index 126afe506a..2ba961c1fe 100644 --- a/test/unit/modules/security-headers.spec.ts +++ b/test/unit/modules/security-headers.spec.ts @@ -42,9 +42,8 @@ function createNuxt(options: Partial = {}): MockNuxt { } function getCsp(nuxt: MockNuxt) { - return nuxt.options.app.head?.meta?.find( - meta => meta['http-equiv'] === 'Content-Security-Policy', - )?.content + return nuxt.options.app.head?.meta?.find(meta => meta['http-equiv'] === 'Content-Security-Policy') + ?.content } describe('security headers module', () => { @@ -79,12 +78,14 @@ describe('security headers module', () => { expect(csp).toContain('ws://localhost:*') expect(csp).toContain("frame-src https://bsky.app https://pdsmoover.com 'self'") - expect(nuxt.options.routeRules['/**']?.headers).toEqual(expect.objectContaining({ - 'Permissions-Policy': 'camera=()', - 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - })) + expect(nuxt.options.routeRules['/**']?.headers).toEqual( + expect.objectContaining({ + 'Permissions-Policy': 'camera=()', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + }), + ) expect(nuxt.options.routeRules['/__nuxt_devtools__/**']).toEqual({ headers: { 'Cache-Control': 'no-store',