Skip to content
12 changes: 6 additions & 6 deletions app/composables/useRepoMeta.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -132,7 +132,7 @@ const githubAdapter: ProviderAdapter = {
let res: UnghRepoResponse | null = null
try {
const { data } = await cachedFetch<UnghRepoResponse>(
`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,
)
Expand Down Expand Up @@ -254,7 +254,7 @@ const bitbucketAdapter: ProviderAdapter = {
let res: BitbucketRepoResponse | null = null
try {
const { data } = await cachedFetch<BitbucketRepoResponse>(
`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,
)
Expand Down Expand Up @@ -312,7 +312,7 @@ const codebergAdapter: ProviderAdapter = {
let res: GiteaRepoResponse | null = null
try {
const { data } = await cachedFetch<GiteaRepoResponse>(
`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,
)
Expand Down Expand Up @@ -370,7 +370,7 @@ const giteeAdapter: ProviderAdapter = {
let res: GiteeRepoResponse | null = null
try {
const { data } = await cachedFetch<GiteeRepoResponse>(
`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,
)
Expand Down Expand Up @@ -623,7 +623,7 @@ const radicleAdapter: ProviderAdapter = {
let res: RadicleProjectResponse | null = null
try {
const { data } = await cachedFetch<RadicleProjectResponse>(
`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,
)
Expand Down
78 changes: 78 additions & 0 deletions modules/security-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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 <meta http-equiv> tag in <head>, 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 <meta> tag — only present in HTML pages, not API responses.
nuxt.options.app.head ??= {}
const head = nuxt.options.app.head as { meta?: Array<Record<string, string>> }
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 ??= {}
const wildCardRules = nuxt.options.routeRules['/**']
nuxt.options.routeRules['/**'] = {
...wildCardRules,
headers: {
...wildCardRules?.headers,
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
}
},
})
2 changes: 1 addition & 1 deletion server/utils/image-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
21 changes: 21 additions & 0 deletions shared/utils/git-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,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<Record<ProviderId, string>>

/**
* 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}`),
]
36 changes: 36 additions & 0 deletions test/e2e/security-headers.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <meta http-equiv> in <head>
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([])
})
}
})
31 changes: 30 additions & 1 deletion test/e2e/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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)
Expand All @@ -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 }
Loading