Skip to content

Commit ae16a6b

Browse files
fix: relax devtools-specific security headers in dev (#2331)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 08bb984 commit ae16a6b

File tree

2 files changed

+154
-6
lines changed

2 files changed

+154
-6
lines changed

modules/security-headers.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineNuxtModule } from 'nuxt/kit'
1+
import { defineNuxtModule, useNuxt } from 'nuxt/kit'
22
import { BLUESKY_API } from '#shared/utils/constants'
33
import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers'
44
import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy'
@@ -19,7 +19,16 @@ import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy'
1919
*/
2020
export default defineNuxtModule({
2121
meta: { name: 'security-headers' },
22-
setup(_, nuxt) {
22+
setup() {
23+
const nuxt = useNuxt()
24+
const devtools = nuxt.options.devtools
25+
26+
const isDevtoolsRuntime =
27+
nuxt.options.dev &&
28+
devtools !== false &&
29+
(devtools == null || typeof devtools !== 'object' || devtools.enabled !== false) &&
30+
!process.env.TEST
31+
2332
// These assets are embedded directly on blog pages and should not affect image-proxy trust.
2433
const cspOnlyImgOrigins = ['https://api.star-history.com', 'https://cdn.bsky.app']
2534
const imgSrc = [
@@ -39,9 +48,21 @@ export default defineNuxtModule({
3948
...ALL_KNOWN_GIT_API_ORIGINS,
4049
// Local CLI connector (npmx CLI communicates via localhost)
4150
'http://127.0.0.1:*',
51+
// Devtools runtime (Vue Devtools, Nuxt Devtools, etc) — only in dev mode with devtools enabled
52+
...(isDevtoolsRuntime ? ['ws://localhost:*'] : []),
53+
].join(' ')
54+
55+
const frameSrc = [
56+
'https://bsky.app',
57+
'https://pdsmoover.com',
58+
...(isDevtoolsRuntime ? ["'self'"] : []),
4259
].join(' ')
4360

44-
const frameSrc = ['https://bsky.app', 'https://pdsmoover.com'].join(' ')
61+
const securityHeaders = {
62+
'X-Content-Type-Options': 'nosniff',
63+
'X-Frame-Options': 'DENY',
64+
'Referrer-Policy': 'strict-origin-when-cross-origin',
65+
}
4566

4667
const csp = [
4768
`default-src 'none'`,
@@ -74,9 +95,20 @@ export default defineNuxtModule({
7495
...wildCardRules,
7596
headers: {
7697
...wildCardRules?.headers,
77-
'X-Content-Type-Options': 'nosniff',
78-
'X-Frame-Options': 'DENY',
79-
'Referrer-Policy': 'strict-origin-when-cross-origin',
98+
...securityHeaders,
99+
},
100+
}
101+
102+
if (!isDevtoolsRuntime) return
103+
104+
const devtoolsRule = nuxt.options.routeRules['/__nuxt_devtools__/**']
105+
nuxt.options.routeRules['/__nuxt_devtools__/**'] = {
106+
...devtoolsRule,
107+
headers: {
108+
...wildCardRules?.headers,
109+
...securityHeaders,
110+
...devtoolsRule?.headers,
111+
'X-Frame-Options': 'SAMEORIGIN',
80112
},
81113
}
82114
},
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const { useNuxt } = vi.hoisted(() => ({
4+
useNuxt: vi.fn(),
5+
}))
6+
7+
vi.mock('nuxt/kit', () => ({
8+
defineNuxtModule: <T>(module: T) => module,
9+
useNuxt,
10+
}))
11+
12+
import securityHeadersModule from '../../../modules/security-headers'
13+
14+
type RouteRule = {
15+
headers?: Record<string, string>
16+
redirect?: string
17+
}
18+
19+
type MockNuxt = {
20+
options: {
21+
app: {
22+
head?: {
23+
meta?: Array<Record<string, string>>
24+
}
25+
}
26+
dev: boolean
27+
devtools?: boolean | { enabled?: boolean }
28+
routeRules: Record<string, RouteRule>
29+
}
30+
}
31+
32+
function createNuxt(options: Partial<MockNuxt['options']> = {}): MockNuxt {
33+
return {
34+
options: {
35+
app: {},
36+
dev: false,
37+
devtools: false,
38+
routeRules: {},
39+
...options,
40+
},
41+
}
42+
}
43+
44+
function getCsp(nuxt: MockNuxt) {
45+
return nuxt.options.app.head?.meta?.find(meta => meta['http-equiv'] === 'Content-Security-Policy')
46+
?.content
47+
}
48+
49+
describe('security headers module', () => {
50+
beforeEach(() => {
51+
delete process.env.TEST
52+
useNuxt.mockReset()
53+
})
54+
55+
it('keeps security headers and only relaxes devtools-specific bits in dev', () => {
56+
const nuxt = createNuxt({
57+
dev: true,
58+
devtools: { enabled: true },
59+
routeRules: {
60+
'/**': {
61+
headers: {
62+
'Permissions-Policy': 'camera=()',
63+
},
64+
},
65+
'/__nuxt_devtools__/**': {
66+
headers: {
67+
'Cache-Control': 'no-store',
68+
},
69+
redirect: '/devtools',
70+
},
71+
},
72+
})
73+
74+
useNuxt.mockReturnValue(nuxt)
75+
securityHeadersModule.setup()
76+
77+
const csp = getCsp(nuxt)
78+
79+
expect(csp).toContain('ws://localhost:*')
80+
expect(csp).toContain("frame-src https://bsky.app https://pdsmoover.com 'self'")
81+
expect(nuxt.options.routeRules['/**']?.headers).toEqual(
82+
expect.objectContaining({
83+
'Permissions-Policy': 'camera=()',
84+
'Referrer-Policy': 'strict-origin-when-cross-origin',
85+
'X-Content-Type-Options': 'nosniff',
86+
'X-Frame-Options': 'DENY',
87+
}),
88+
)
89+
expect(nuxt.options.routeRules['/__nuxt_devtools__/**']).toEqual({
90+
headers: {
91+
'Cache-Control': 'no-store',
92+
'Permissions-Policy': 'camera=()',
93+
'Referrer-Policy': 'strict-origin-when-cross-origin',
94+
'X-Content-Type-Options': 'nosniff',
95+
'X-Frame-Options': 'SAMEORIGIN',
96+
},
97+
redirect: '/devtools',
98+
})
99+
})
100+
101+
it('does not apply devtools relaxations when devtools are disabled via object config', () => {
102+
const nuxt = createNuxt({
103+
dev: true,
104+
devtools: { enabled: false },
105+
})
106+
107+
useNuxt.mockReturnValue(nuxt)
108+
securityHeadersModule.setup()
109+
110+
const csp = getCsp(nuxt)
111+
112+
expect(csp).not.toContain('ws://localhost:*')
113+
expect(csp).not.toContain("frame-src https://bsky.app https://pdsmoover.com 'self'")
114+
expect(nuxt.options.routeRules['/__nuxt_devtools__/**']).toBeUndefined()
115+
})
116+
})

0 commit comments

Comments
 (0)