Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
76 changes: 76 additions & 0 deletions modules/security-headers.ts
Original file line number Diff line number Diff line change
@@ -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 <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 ??= {}
nuxt.options.routeRules['/**'] = {
...nuxt.options.routeRules['/**'],
headers: {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
Comment on lines +67 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether other catch-all headers exist and could be overwritten.
rg -n --type=ts -C4 "routeRules"
rg -n --type=ts -C4 "['\"]/\\*\\*['\"]\\s*:"
rg -n --type=ts -C3 "headers\\s*:"

Repository: npmx-dev/npmx.dev

Length of output: 29908


Merge existing headers instead of clobbering them when setting catch-all route rules.

The current code spreads ...nuxt.options.routeRules['/**'] at the object level but then immediately overwrites the headers property, discarding any pre-existing headers. Use optional chaining to safely merge the new headers with any existing ones.

Proposed merge-safe fix
-    nuxt.options.routeRules['/**'] = {
-      ...nuxt.options.routeRules['/**'],
-      headers: {
-        'X-Content-Type-Options': 'nosniff',
-        'X-Frame-Options': 'DENY',
-        'Referrer-Policy': 'strict-origin-when-cross-origin',
-      },
-    }
+    const existingCatchAll = nuxt.options.routeRules['/**']
+    nuxt.options.routeRules['/**'] = {
+      ...existingCatchAll,
+      headers: {
+        ...(existingCatchAll?.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 @@ -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<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