Skip to content

Commit 69ab95f

Browse files
committed
Merge branch 'main' of https://github.com/alex-key/npmx.dev into fix/docs-page-improvements
2 parents 637a2f1 + d166ce1 commit 69ab95f

File tree

9 files changed

+298
-14
lines changed

9 files changed

+298
-14
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+
})

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
"vite-plugin-pwa": "1.2.0",
110110
"vite-plus": "0.1.12",
111111
"vue": "3.5.30",
112-
"vue-data-ui": "3.16.5"
112+
"vue-data-ui": "3.17.1"
113113
},
114114
"devDependencies": {
115115
"@e18e/eslint-plugin": "0.3.0",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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 }

0 commit comments

Comments
 (0)