Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions modules/security-headers.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 = [
Expand All @@ -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'`,
Expand Down Expand Up @@ -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',
},
}
},
Expand Down
116 changes: 116 additions & 0 deletions test/unit/modules/security-headers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { useNuxt } = vi.hoisted(() => ({
useNuxt: vi.fn(),
}))

vi.mock('nuxt/kit', () => ({
defineNuxtModule: <T>(module: T) => module,
useNuxt,
}))

import securityHeadersModule from '../../../modules/security-headers'

type RouteRule = {
headers?: Record<string, string>
redirect?: string
}

type MockNuxt = {
options: {
app: {
head?: {
meta?: Array<Record<string, string>>
}
}
dev: boolean
devtools?: boolean | { enabled?: boolean }
routeRules: Record<string, RouteRule>
}
}

function createNuxt(options: Partial<MockNuxt['options']> = {}): 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()
})
})
Loading