diff --git a/app/composables/useRepoMeta.ts b/app/composables/useRepoMeta.ts index 226812338e..e79e52418f 100644 --- a/app/composables/useRepoMeta.ts +++ b/app/composables/useRepoMeta.ts @@ -1,5 +1,5 @@ import type { ProviderId, RepoRef } from '#shared/utils/git-providers' -import { parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers' +import { GIT_PROVIDER_API_ORIGINS, parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers' // TTL for git repo metadata (10 minutes - repo stats don't change frequently) const REPO_META_TTL = 60 * 10 @@ -132,7 +132,7 @@ const githubAdapter: ProviderAdapter = { let res: UnghRepoResponse | null = null try { const { data } = await cachedFetch( - `https://ungh.cc/repos/${ref.owner}/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.github}/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) @@ -254,7 +254,7 @@ const bitbucketAdapter: ProviderAdapter = { let res: BitbucketRepoResponse | null = null try { const { data } = await cachedFetch( - `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.bitbucket}/2.0/repositories/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) @@ -312,7 +312,7 @@ const codebergAdapter: ProviderAdapter = { let res: GiteaRepoResponse | null = null try { const { data } = await cachedFetch( - `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.codeberg}/api/v1/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) @@ -370,7 +370,7 @@ const giteeAdapter: ProviderAdapter = { let res: GiteeRepoResponse | null = null try { const { data } = await cachedFetch( - `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.gitee}/api/v5/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) @@ -623,7 +623,7 @@ const radicleAdapter: ProviderAdapter = { let res: RadicleProjectResponse | null = null try { const { data } = await cachedFetch( - `https://seed.radicle.at/api/v1/projects/${ref.repo}`, + `${GIT_PROVIDER_API_ORIGINS.radicle}/api/v1/projects/${ref.repo}`, { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options }, REPO_META_TTL, ) diff --git a/modules/security-headers.ts b/modules/security-headers.ts new file mode 100644 index 0000000000..64479f7140 --- /dev/null +++ b/modules/security-headers.ts @@ -0,0 +1,76 @@ +import { defineNuxtModule } from 'nuxt/kit' +import { ALL_KNOWN_GIT_API_ORIGINS } from '#shared/utils/git-providers' +import { TRUSTED_IMAGE_DOMAINS } from '#server/utils/image-proxy' + +/** + * Adds Content-Security-Policy and other security headers to all pages. + * + * CSP is delivered via a tag in , so it naturally + * only applies to HTML pages (not API routes). The remaining security + * headers are set via a catch-all route rule. + * + * Note: frame-ancestors is not supported in meta-tag CSP, but + * X-Frame-Options: DENY (set via route rule) provides equivalent protection. + * + * Current policy uses 'unsafe-inline' for scripts and styles because: + * - Nuxt injects inline scripts for hydration and payload transfer + * - Vue uses inline styles for :style bindings and scoped CSS + */ +export default defineNuxtModule({ + meta: { name: 'security-headers' }, + setup(_, nuxt) { + const imgSrc = [ + "'self'", + 'data:', + ...TRUSTED_IMAGE_DOMAINS.map(domain => `https://${domain}`), + ].join(' ') + + const connectSrc = [ + "'self'", + 'https://*.algolia.net', + 'https://registry.npmjs.org', + 'https://api.npmjs.org', + 'https://npm.antfu.dev', + ...ALL_KNOWN_GIT_API_ORIGINS, + // Local CLI connector (npmx CLI communicates via localhost) + 'http://127.0.0.1:*', + ].join(' ') + + const frameSrc = ['https://bsky.app', 'https://pdsmoover.com'].join(' ') + + const csp = [ + `default-src 'none'`, + `script-src 'self' 'unsafe-inline'`, + `style-src 'self' 'unsafe-inline'`, + `img-src ${imgSrc}`, + `font-src 'self'`, + `connect-src ${connectSrc}`, + `frame-src ${frameSrc}`, + `base-uri 'self'`, + `form-action 'self'`, + `object-src 'none'`, + `manifest-src 'self'`, + 'upgrade-insecure-requests', + ].join('; ') + + // CSP via tag — only present in HTML pages, not API responses. + nuxt.options.app.head ??= {} + const head = nuxt.options.app.head as { meta?: Array> } + head.meta ??= [] + head.meta.push({ + 'http-equiv': 'Content-Security-Policy', + 'content': csp, + }) + + // Other security headers via route rules (fine on all responses). + nuxt.options.routeRules ??= {} + nuxt.options.routeRules['/**'] = { + ...nuxt.options.routeRules['/**'], + headers: { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + }, + } + }, +}) diff --git a/server/utils/image-proxy.ts b/server/utils/image-proxy.ts index ae590a9d0d..5d5ac420f4 100644 --- a/server/utils/image-proxy.ts +++ b/server/utils/image-proxy.ts @@ -29,7 +29,7 @@ import { lookup } from 'node:dns/promises' import ipaddr from 'ipaddr.js' /** Trusted image domains that don't need proxying (first-party or well-known CDNs) */ -const TRUSTED_IMAGE_DOMAINS = [ +export const TRUSTED_IMAGE_DOMAINS = [ // First-party 'npmx.dev', diff --git a/shared/utils/git-providers.ts b/shared/utils/git-providers.ts index e14e82f5f7..7f42ba2705 100644 --- a/shared/utils/git-providers.ts +++ b/shared/utils/git-providers.ts @@ -404,3 +404,24 @@ export function convertBlobOrFileToRawUrl(url: string, providerId: ProviderId): export function isKnownGitProvider(url: string): boolean { return parseRepoUrl(url) !== null } + +/** + * API origins used by each provider for client-side repo metadata fetches. + * Self-hosted providers are excluded because their origins can be anything. + */ +export const GIT_PROVIDER_API_ORIGINS = { + github: 'https://ungh.cc', // via UNGH proxy to avoid rate limits + bitbucket: 'https://api.bitbucket.org', + codeberg: 'https://codeberg.org', + gitee: 'https://gitee.com', + radicle: 'https://seed.radicle.at', +} as const satisfies Partial> + +/** + * All known external API origins that git provider adapters may fetch from. + * Includes both the per-provider origins and known self-hosted instances. + */ +export const ALL_KNOWN_GIT_API_ORIGINS: readonly string[] = [ + ...Object.values(GIT_PROVIDER_API_ORIGINS), + ...GITLAB_HOSTS.map(host => `https://${host}`), +] diff --git a/test/e2e/security-headers.spec.ts b/test/e2e/security-headers.spec.ts new file mode 100644 index 0000000000..8e4925cfb2 --- /dev/null +++ b/test/e2e/security-headers.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from './test-utils' + +test.describe('security headers', () => { + test('HTML pages include CSP meta tag and security headers', async ({ page, baseURL }) => { + const response = await page.goto(baseURL!) + const headers = response!.headers() + + // CSP is delivered via in + const cspContent = await page + .locator('meta[http-equiv="Content-Security-Policy"]') + .getAttribute('content') + expect(cspContent).toContain("script-src 'self'") + + // Other security headers via route rules + expect(headers['x-content-type-options']).toBe('nosniff') + expect(headers['x-frame-options']).toBe('DENY') + expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin') + }) + + test('API routes do not include CSP', async ({ page, baseURL }) => { + const response = await page.request.get(`${baseURL}/api/registry/package-meta/vue`) + + expect(response.headers()['content-security-policy']).toBeUndefined() + }) + + // Navigate key pages and assert no CSP violations are logged. + // This catches new external resources that weren't added to the CSP. + const PAGES = ['/', '/package/nuxt', '/search?q=vue', '/compare'] as const + + for (const path of PAGES) { + test(`no CSP violations on ${path}`, async ({ goto, cspViolations }) => { + await goto(path, { waitUntil: 'hydration' }) + expect(cspViolations).toEqual([]) + }) + } +}) diff --git a/test/e2e/test-utils.ts b/test/e2e/test-utils.ts index 66465b03cb..a7ad3234ad 100644 --- a/test/e2e/test-utils.ts +++ b/test/e2e/test-utils.ts @@ -74,6 +74,19 @@ function isHydrationMismatch(message: ConsoleMessage): boolean { return HYDRATION_MISMATCH_PATTERNS.some(pattern => text.includes(pattern)) } +/** + * Detect Content-Security-Policy violations logged to the console. + * + * Browsers log CSP violations as console errors with a distinctive prefix. + * Catching these in e2e tests ensures new external resources are added to the + * CSP before they land in production. + */ +function isCspViolation(message: ConsoleMessage): boolean { + if (message.type() !== 'error') return false + const text = message.text() + return text.includes('Content-Security-Policy') || text.includes('content security policy') +} + /** * Extended test fixture with automatic external API mocking and hydration mismatch detection. * @@ -83,7 +96,11 @@ function isHydrationMismatch(message: ConsoleMessage): boolean { * Hydration mismatches are detected via Vue's console.error output, which is always * emitted in production builds when server-rendered HTML doesn't match client expectations. */ -export const test = base.extend<{ mockExternalApis: void; hydrationErrors: string[] }>({ +export const test = base.extend<{ + mockExternalApis: void + hydrationErrors: string[] + cspViolations: string[] +}>({ mockExternalApis: [ async ({ page }, use) => { await setupRouteMocking(page) @@ -103,6 +120,18 @@ export const test = base.extend<{ mockExternalApis: void; hydrationErrors: strin await use(errors) }, + + cspViolations: async ({ page }, use) => { + const violations: string[] = [] + + page.on('console', message => { + if (isCspViolation(message)) { + violations.push(message.text()) + } + }) + + await use(violations) + }, }) export { expect }