Skip to content

Commit d166ce1

Browse files
serhalp43081jautofix-ci[bot]
authored
feat: add CSP and some security headers to HTML pages (#2075)
Co-authored-by: James Garbutt <43081j@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e62baf4 commit d166ce1

File tree

7 files changed

+292
-8
lines changed

7 files changed

+292
-8
lines changed

app/composables/useRepoMeta.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ProviderId, RepoRef } from '#shared/utils/git-providers'
2-
import { parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers'
2+
import { GIT_PROVIDER_API_ORIGINS, parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers'
33

44
// TTL for git repo metadata (10 minutes - repo stats don't change frequently)
55
const REPO_META_TTL = 60 * 10
@@ -134,7 +134,7 @@ const githubAdapter: ProviderAdapter = {
134134
let res: UnghRepoResponse | null = null
135135
try {
136136
const { data } = await cachedFetch<UnghRepoResponse>(
137-
`https://ungh.cc/repos/${ref.owner}/${ref.repo}`,
137+
`${GIT_PROVIDER_API_ORIGINS.github}/repos/${ref.owner}/${ref.repo}`,
138138
{ headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
139139
UNGH_REPO_META_TTL,
140140
)
@@ -256,7 +256,7 @@ const bitbucketAdapter: ProviderAdapter = {
256256
let res: BitbucketRepoResponse | null = null
257257
try {
258258
const { data } = await cachedFetch<BitbucketRepoResponse>(
259-
`https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`,
259+
`${GIT_PROVIDER_API_ORIGINS.bitbucket}/2.0/repositories/${ref.owner}/${ref.repo}`,
260260
{ headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
261261
REPO_META_TTL,
262262
)
@@ -314,7 +314,7 @@ const codebergAdapter: ProviderAdapter = {
314314
let res: GiteaRepoResponse | null = null
315315
try {
316316
const { data } = await cachedFetch<GiteaRepoResponse>(
317-
`https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`,
317+
`${GIT_PROVIDER_API_ORIGINS.codeberg}/api/v1/repos/${ref.owner}/${ref.repo}`,
318318
{ headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
319319
REPO_META_TTL,
320320
)
@@ -372,7 +372,7 @@ const giteeAdapter: ProviderAdapter = {
372372
let res: GiteeRepoResponse | null = null
373373
try {
374374
const { data } = await cachedFetch<GiteeRepoResponse>(
375-
`https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`,
375+
`${GIT_PROVIDER_API_ORIGINS.gitee}/api/v5/repos/${ref.owner}/${ref.repo}`,
376376
{ headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
377377
REPO_META_TTL,
378378
)
@@ -625,7 +625,7 @@ const radicleAdapter: ProviderAdapter = {
625625
let res: RadicleProjectResponse | null = null
626626
try {
627627
const { data } = await cachedFetch<RadicleProjectResponse>(
628-
`https://seed.radicle.at/api/v1/projects/${ref.repo}`,
628+
`${GIT_PROVIDER_API_ORIGINS.radicle}/api/v1/projects/${ref.repo}`,
629629
{ headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
630630
REPO_META_TTL,
631631
)

modules/security-headers.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { defineNuxtModule } from 'nuxt/kit'
2+
import { BLUESKY_API } from '#shared/utils/constants'
3+
import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers'
4+
import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy'
5+
6+
/**
7+
* Adds Content-Security-Policy and other security headers to all pages.
8+
*
9+
* CSP is delivered via a <meta http-equiv> tag in <head>, so it naturally
10+
* only applies to HTML pages (not API routes). The remaining security
11+
* headers are set via a catch-all route rule.
12+
*
13+
* Note: frame-ancestors is not supported in meta-tag CSP, but
14+
* X-Frame-Options: DENY (set via route rule) provides equivalent protection.
15+
*
16+
* Current policy uses 'unsafe-inline' for scripts and styles because:
17+
* - Nuxt injects inline scripts for hydration and payload transfer
18+
* - Vue uses inline styles for :style bindings and scoped CSS
19+
*/
20+
export default defineNuxtModule({
21+
meta: { name: 'security-headers' },
22+
setup(_, nuxt) {
23+
// These assets are embedded directly on blog pages and should not affect image-proxy trust.
24+
const cspOnlyImgOrigins = ['https://api.star-history.com', 'https://cdn.bsky.app']
25+
const imgSrc = [
26+
"'self'",
27+
'data:',
28+
...TRUSTED_IMAGE_DOMAINS.map(domain => `https://${domain}`),
29+
...cspOnlyImgOrigins,
30+
].join(' ')
31+
32+
const connectSrc = [
33+
"'self'",
34+
'https://*.algolia.net',
35+
'https://registry.npmjs.org',
36+
'https://api.npmjs.org',
37+
'https://npm.antfu.dev',
38+
BLUESKY_API,
39+
...ALL_KNOWN_GIT_API_ORIGINS,
40+
// Local CLI connector (npmx CLI communicates via localhost)
41+
'http://127.0.0.1:*',
42+
].join(' ')
43+
44+
const frameSrc = ['https://bsky.app', 'https://pdsmoover.com'].join(' ')
45+
46+
const csp = [
47+
`default-src 'none'`,
48+
`script-src 'self' 'unsafe-inline'`,
49+
`style-src 'self' 'unsafe-inline'`,
50+
`img-src ${imgSrc}`,
51+
`font-src 'self'`,
52+
`connect-src ${connectSrc}`,
53+
`frame-src ${frameSrc}`,
54+
`base-uri 'self'`,
55+
`form-action 'self'`,
56+
`object-src 'none'`,
57+
`manifest-src 'self'`,
58+
'upgrade-insecure-requests',
59+
].join('; ')
60+
61+
// CSP via <meta> tag — only present in HTML pages, not API responses.
62+
nuxt.options.app.head ??= {}
63+
const head = nuxt.options.app.head as { meta?: Array<Record<string, string>> }
64+
head.meta ??= []
65+
head.meta.push({
66+
'http-equiv': 'Content-Security-Policy',
67+
'content': csp,
68+
})
69+
70+
// Other security headers via route rules (fine on all responses).
71+
nuxt.options.routeRules ??= {}
72+
const wildCardRules = nuxt.options.routeRules['/**']
73+
nuxt.options.routeRules['/**'] = {
74+
...wildCardRules,
75+
headers: {
76+
...wildCardRules?.headers,
77+
'X-Content-Type-Options': 'nosniff',
78+
'X-Frame-Options': 'DENY',
79+
'Referrer-Policy': 'strict-origin-when-cross-origin',
80+
},
81+
}
82+
},
83+
})

server/utils/image-proxy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { lookup } from 'node:dns/promises'
2929
import ipaddr from 'ipaddr.js'
3030

3131
/** Trusted image domains that don't need proxying (first-party or well-known CDNs) */
32-
const TRUSTED_IMAGE_DOMAINS = [
32+
export const TRUSTED_IMAGE_DOMAINS = [
3333
// First-party
3434
'npmx.dev',
3535

shared/utils/git-providers.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,24 @@ export function convertBlobOrFileToRawUrl(url: string, providerId: ProviderId):
387387
export function isKnownGitProvider(url: string): boolean {
388388
return parseRepoUrl(url) !== null
389389
}
390+
391+
/**
392+
* API origins used by each provider for client-side repo metadata fetches.
393+
* Self-hosted providers are excluded because their origins can be anything.
394+
*/
395+
export const GIT_PROVIDER_API_ORIGINS = {
396+
github: 'https://ungh.cc', // via UNGH proxy to avoid rate limits
397+
bitbucket: 'https://api.bitbucket.org',
398+
codeberg: 'https://codeberg.org',
399+
gitee: 'https://gitee.com',
400+
radicle: 'https://seed.radicle.at',
401+
} as const satisfies Partial<Record<ProviderId, string>>
402+
403+
/**
404+
* All known external API origins that git provider adapters may fetch from.
405+
* Includes both the per-provider origins and known self-hosted instances.
406+
*/
407+
export const ALL_KNOWN_GIT_API_ORIGINS: readonly string[] = [
408+
...Object.values(GIT_PROVIDER_API_ORIGINS),
409+
...GITLAB_HOSTS.map(host => `https://${host}`),
410+
]

test/e2e/security-headers.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { expect, test } from './test-utils'
2+
3+
test.describe('security headers', () => {
4+
test('HTML pages include CSP meta tag and security headers', async ({ page, baseURL }) => {
5+
const response = await page.goto(baseURL!)
6+
const headers = response!.headers()
7+
8+
// CSP is delivered via <meta http-equiv> in <head>
9+
const cspContent = await page
10+
.locator('meta[http-equiv="Content-Security-Policy"]')
11+
.getAttribute('content')
12+
expect(cspContent).toContain("script-src 'self'")
13+
expect(cspContent).toContain('https://api.star-history.com')
14+
expect(cspContent).toContain('https://cdn.bsky.app')
15+
expect(cspContent).toContain('https://public.api.bsky.app')
16+
17+
// Other security headers via route rules
18+
expect(headers['x-content-type-options']).toBe('nosniff')
19+
expect(headers['x-frame-options']).toBe('DENY')
20+
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin')
21+
})
22+
23+
test('API routes do not include CSP', async ({ page, baseURL }) => {
24+
const response = await page.request.get(`${baseURL}/api/registry/package-meta/vue`)
25+
26+
expect(response.headers()['content-security-policy']).toBeUndefined()
27+
})
28+
29+
// Navigate key pages and assert no CSP violations are logged.
30+
// This catches new external resources that weren't added to the CSP.
31+
const PAGES = ['/', '/package/nuxt', '/search?q=vue', '/compare', '/blog/alpha-release'] as const
32+
33+
for (const path of PAGES) {
34+
test(`no CSP violations on ${path}`, async ({ goto, cspViolations }) => {
35+
await goto(path, { waitUntil: 'hydration' })
36+
expect(cspViolations).toEqual([])
37+
})
38+
}
39+
})

test/e2e/test-utils.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ function isHydrationMismatch(message: ConsoleMessage): boolean {
7474
return HYDRATION_MISMATCH_PATTERNS.some(pattern => text.includes(pattern))
7575
}
7676

77+
/**
78+
* Detect Content-Security-Policy violations logged to the console.
79+
*
80+
* Browsers log CSP violations as console errors with a distinctive prefix.
81+
* Catching these in e2e tests ensures new external resources are added to the
82+
* CSP before they land in production.
83+
*/
84+
function isCspViolation(message: ConsoleMessage): boolean {
85+
if (message.type() !== 'error') return false
86+
const text = message.text()
87+
return text.includes('Content-Security-Policy') || text.includes('content security policy')
88+
}
89+
7790
/**
7891
* Extended test fixture with automatic external API mocking and hydration mismatch detection.
7992
*
@@ -83,7 +96,11 @@ function isHydrationMismatch(message: ConsoleMessage): boolean {
8396
* Hydration mismatches are detected via Vue's console.error output, which is always
8497
* emitted in production builds when server-rendered HTML doesn't match client expectations.
8598
*/
86-
export const test = base.extend<{ mockExternalApis: void; hydrationErrors: string[] }>({
99+
export const test = base.extend<{
100+
mockExternalApis: void
101+
hydrationErrors: string[]
102+
cspViolations: string[]
103+
}>({
87104
mockExternalApis: [
88105
async ({ page }, use) => {
89106
await setupRouteMocking(page)
@@ -103,6 +120,18 @@ export const test = base.extend<{ mockExternalApis: void; hydrationErrors: strin
103120

104121
await use(errors)
105122
},
123+
124+
cspViolations: async ({ page }, use) => {
125+
const violations: string[] = []
126+
127+
page.on('console', message => {
128+
if (isCspViolation(message)) {
129+
violations.push(message.text())
130+
}
131+
})
132+
133+
await use(violations)
134+
},
106135
})
107136

108137
export { expect }

test/fixtures/mock-routes.cjs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,111 @@ function matchConstellationApi(urlString) {
467467
return json(null)
468468
}
469469

470+
const BLUESKY_EMBED_DID = 'did:plc:2gkh62xvzokhlf6li4ol3b3d'
471+
472+
/**
473+
* @param {string} urlString
474+
* @returns {MockResponse | null}
475+
*/
476+
function matchBlueskyApi(urlString) {
477+
const url = new URL(urlString)
478+
479+
if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') {
480+
return json({ did: BLUESKY_EMBED_DID })
481+
}
482+
483+
if (url.pathname === '/xrpc/app.bsky.feed.getPosts') {
484+
const requestedUri =
485+
url.searchParams.getAll('uris')[0] ||
486+
`at://${BLUESKY_EMBED_DID}/app.bsky.feed.post/3md3cmrg56k2r`
487+
488+
return json({
489+
posts: [
490+
{
491+
uri: requestedUri,
492+
author: {
493+
did: BLUESKY_EMBED_DID,
494+
handle: 'danielroe.dev',
495+
displayName: 'Daniel Roe',
496+
avatar: `https://cdn.bsky.app/img/avatar/plain/${BLUESKY_EMBED_DID}/mock-avatar@jpeg`,
497+
},
498+
record: {
499+
text: 'Mock Bluesky post for CSP coverage.',
500+
createdAt: '2026-03-03T12:00:00.000Z',
501+
},
502+
embed: {
503+
$type: 'app.bsky.embed.images#view',
504+
images: [
505+
{
506+
thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${BLUESKY_EMBED_DID}/mock-image@jpeg`,
507+
fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${BLUESKY_EMBED_DID}/mock-image@jpeg`,
508+
alt: 'Mock Bluesky image',
509+
aspectRatio: { width: 1200, height: 630 },
510+
},
511+
],
512+
},
513+
likeCount: 42,
514+
replyCount: 7,
515+
repostCount: 3,
516+
},
517+
],
518+
})
519+
}
520+
521+
if (url.pathname === '/xrpc/app.bsky.actor.getProfiles') {
522+
const actors = url.searchParams.getAll('actors')
523+
524+
return json({
525+
profiles: actors.map(handle => ({
526+
handle,
527+
avatar: `https://cdn.bsky.app/img/avatar/plain/${BLUESKY_EMBED_DID}/mock-avatar`,
528+
})),
529+
})
530+
}
531+
532+
return null
533+
}
534+
535+
/**
536+
* @param {string} _urlString
537+
* @returns {MockResponse}
538+
*/
539+
function matchBlueskyCdn(_urlString) {
540+
return {
541+
status: 200,
542+
contentType: 'image/svg+xml',
543+
body:
544+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">' +
545+
'<rect width="120" height="120" fill="#0ea5e9"/>' +
546+
'<circle cx="60" cy="44" r="18" fill="#f8fafc"/>' +
547+
'<rect x="24" y="74" width="72" height="18" rx="9" fill="#f8fafc"/>' +
548+
'</svg>',
549+
}
550+
}
551+
552+
/**
553+
* @param {string} urlString
554+
* @returns {MockResponse | null}
555+
*/
556+
function matchStarHistoryApi(urlString) {
557+
const url = new URL(urlString)
558+
559+
if (url.pathname === '/svg') {
560+
return {
561+
status: 200,
562+
contentType: 'image/svg+xml',
563+
body:
564+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 320">' +
565+
'<rect width="640" height="320" fill="#0f172a"/>' +
566+
'<path d="M32 256 L160 220 L288 168 L416 120 L544 88 L608 64" stroke="#f59e0b" stroke-width="8" fill="none"/>' +
567+
'<text x="32" y="44" fill="#f8fafc" font-family="monospace" font-size="24">Mock Star History</text>' +
568+
'</svg>',
569+
}
570+
}
571+
572+
return null
573+
}
574+
470575
/**
471576
* @param {string} urlString
472577
* @returns {MockResponse | null}
@@ -524,6 +629,13 @@ const routes = [
524629
pattern: 'https://constellation.microcosm.blue/**',
525630
match: matchConstellationApi,
526631
},
632+
{ name: 'Bluesky API', pattern: 'https://public.api.bsky.app/**', match: matchBlueskyApi },
633+
{ name: 'Bluesky CDN', pattern: 'https://cdn.bsky.app/**', match: matchBlueskyCdn },
634+
{
635+
name: 'Star History API',
636+
pattern: 'https://api.star-history.com/**',
637+
match: matchStarHistoryApi,
638+
},
527639
]
528640

529641
/**

0 commit comments

Comments
 (0)