diff --git a/modules/security-headers.ts b/modules/security-headers.ts index 748e5ac6c4..e5b6b0dfaa 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,7 +19,16 @@ import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' */ export default defineNuxtModule({ meta: { name: 'security-headers' }, - setup(_, nuxt) { + setup() { + const nuxt = useNuxt() + const devtools = nuxt.options.devtools + + 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'] const imgSrc = [ @@ -39,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'`, @@ -74,9 +95,20 @@ 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..2ba961c1fe --- /dev/null +++ b/test/unit/modules/security-headers.spec.ts @@ -0,0 +1,116 @@ +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() + }) +})