Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
119 changes: 119 additions & 0 deletions server/api/registry/image-proxy/index.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { createError, getQuery, setResponseHeaders } from 'h3'
import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
import { isAllowedImageUrl } from '#shared/utils/image-proxy'

/**
* Image proxy endpoint to prevent privacy leaks from README images.
*
* Instead of letting the client's browser fetch images directly from third-party
* servers (which exposes visitor IP, User-Agent, etc.), this endpoint fetches
* images server-side and forwards them to the client.
*
* Similar to GitHub's camo proxy: https://github.blog/2014-01-28-proxying-user-images/
*
* Usage: /api/registry/image-proxy?url=https://example.com/image.png
*
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
*/
export default defineCachedEventHandler(
async event => {
const query = getQuery(event)
const url = query.url as string | undefined
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle potential array values in query parameter.

If a request contains multiple url parameters (e.g., ?url=a&url=b), query.url will be an array, making the type cast unsafe. This also affects the cache key on line 116.

🔧 Suggested fix
     const query = getQuery(event)
-    const url = query.url as string | undefined
+    const rawUrl = query.url
+    const url = Array.isArray(rawUrl) ? rawUrl[0] : rawUrl as string | undefined
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const query = getQuery(event)
const url = query.url as string | undefined
const query = getQuery(event)
const rawUrl = query.url
const url = Array.isArray(rawUrl) ? rawUrl[0] : rawUrl as string | undefined


if (!url) {
throw createError({
statusCode: 400,
message: 'Missing required "url" query parameter.',
})
}

// Validate URL
if (!isAllowedImageUrl(url)) {
throw createError({
statusCode: 400,
message: 'Invalid or disallowed image URL.',
})
}

try {
const response = await fetch(url, {
headers: {
// Use a generic User-Agent to avoid leaking server info
'User-Agent': 'npmx-image-proxy/1.0',
'Accept': 'image/*',
},
// Prevent redirects to non-HTTP protocols
redirect: 'follow',
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (!response.ok) {
throw createError({
statusCode: response.status === 404 ? 404 : 502,
message: `Failed to fetch image: ${response.status}`,
})
}

const contentType = response.headers.get('content-type') || 'application/octet-stream'

// Only allow image content types
if (
!contentType.startsWith('image/') &&
!contentType.startsWith('application/octet-stream')
) {
throw createError({
statusCode: 400,
message: 'URL does not point to an image.',
})
}

// Enforce a maximum size of 10 MB to prevent abuse
const contentLength = response.headers.get('content-length')
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
throw createError({
statusCode: 413,
message: 'Image too large.',
})
}

const imageBuffer = await response.arrayBuffer()
Comment thread
danielroe marked this conversation as resolved.
Outdated

// Check actual size
if (imageBuffer.byteLength > MAX_SIZE) {
throw createError({
statusCode: 413,
message: 'Image too large.',
})
}

setResponseHeaders(event, {
'Content-Type': contentType,
'Content-Length': imageBuffer.byteLength.toString(),
'Cache-Control': `public, max-age=${CACHE_MAX_AGE_ONE_DAY}, s-maxage=${CACHE_MAX_AGE_ONE_DAY}`,
// Security headers - prevent content sniffing and restrict usage
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
})

return Buffer.from(imageBuffer)
} catch (error: unknown) {
// Re-throw H3 errors
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error
}

throw createError({
statusCode: 502,
message: 'Failed to proxy image.',
})
}
},
{
maxAge: CACHE_MAX_AGE_ONE_DAY,
swr: true,
getKey: event => {
const query = getQuery(event)
return `image-proxy:${query.url}`
},
},
)
13 changes: 9 additions & 4 deletions server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ReadmeResponse, TocItem } from '#shared/types/readme'
import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers'
import { highlightCodeSync } from './shiki'
import { convertToEmoji } from '#shared/utils/emoji'
import { toProxiedImageUrl } from '#shared/utils/image-proxy'

/**
* Playground provider configuration
Expand Down Expand Up @@ -256,12 +257,16 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
// Convert blob/src URLs to raw URLs for images across all providers
// e.g. https://github.com/nuxt/nuxt/blob/main/.github/assets/banner.svg
// → https://github.com/nuxt/nuxt/raw/main/.github/assets/banner.svg
//
// External images are proxied through /api/registry/image-proxy to prevent
// third-party servers from collecting visitor IP addresses and User-Agent data.
// See: https://github.com/npmx-dev/npmx.dev/issues/1138
function resolveImageUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string {
const resolved = resolveUrl(url, packageName, repoInfo)
if (repoInfo?.provider) {
return convertBlobOrFileToRawUrl(resolved, repoInfo.provider)
}
return resolved
const rawUrl = repoInfo?.provider
? convertBlobOrFileToRawUrl(resolved, repoInfo.provider)
: resolved
return toProxiedImageUrl(rawUrl)
}

// Helper to prefix id attributes with 'user-content-'
Expand Down
118 changes: 118 additions & 0 deletions shared/utils/image-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Image proxy utilities for privacy-safe README image rendering.
*
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
*/

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

// GitHub (already proxied by GitHub's own camo)
'raw.githubusercontent.com',
'github.com',
'user-images.githubusercontent.com',
'avatars.githubusercontent.com',
'repository-images.githubusercontent.com',
'github.githubassets.com',
'objects.githubusercontent.com',

// GitLab
'gitlab.com',

// CDNs commonly used in READMEs
'cdn.jsdelivr.net',
'unpkg.com',

// Well-known badge/shield services
'img.shields.io',
'shields.io',
'badge.fury.io',
'badgen.net',
'flat.badgen.net',
'codecov.io',
'coveralls.io',
'david-dm.org',
'snyk.io',
'app.fossa.com',
'api.codeclimate.com',
'bundlephobia.com',
'packagephobia.com',
]

/**
* Check if a URL points to a trusted domain that doesn't need proxying.
*/
export function isTrustedImageDomain(url: string): boolean {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
return TRUSTED_IMAGE_DOMAINS.some(
domain => hostname === domain || hostname.endsWith(`.${domain}`),
)
} catch {
return false
}
Comment thread
danielroe marked this conversation as resolved.
Outdated
}

/**
* Validate that a URL is a valid HTTP(S) image URL suitable for proxying.
*/
export function isAllowedImageUrl(url: string): boolean {
try {
const parsed = new URL(url)
// Only allow HTTP and HTTPS protocols
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false
}
// Block localhost / private IPs to prevent SSRF
const hostname = parsed.hostname.toLowerCase()
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('172.') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal')
) {
return false
}
return true
} catch {
return false
}
Comment thread
danielroe marked this conversation as resolved.
Outdated
}

/**
* Convert an external image URL to a proxied URL.
* Trusted domains are returned as-is.
* Returns the original URL for non-HTTP(S) URLs.
*/
export function toProxiedImageUrl(url: string): string {
// Don't proxy data URIs, relative URLs, or anchor links
if (!url || url.startsWith('#') || url.startsWith('data:')) {
return url
}

try {
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return url
}
} catch {
// Not an absolute URL, return as-is (relative URLs are fine)
return url
}
Comment thread
danielroe marked this conversation as resolved.
Outdated

// Trusted domains don't need proxying
if (isTrustedImageDomain(url)) {
return url
}

// Proxy through our server endpoint
return `/api/registry/image-proxy?url=${encodeURIComponent(url)}`
}
58 changes: 58 additions & 0 deletions test/unit/server/utils/readme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,64 @@ describe('Markdown File URL Resolution', () => {
})
})

describe('Image Privacy Proxy', () => {
describe('trusted domains (not proxied)', () => {
it('does not proxy GitHub raw content images', async () => {
const repoInfo = createRepoInfo()
const markdown = `![logo](./assets/logo.png)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'src="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"',
)
expect(result.html).not.toContain('/api/registry/image-proxy')
})

it('does not proxy shields.io badge images', async () => {
const markdown = `![badge](https://img.shields.io/badge/build-passing-green)`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toContain('src="https://img.shields.io/badge/build-passing-green"')
expect(result.html).not.toContain('/api/registry/image-proxy')
})

it('does not proxy jsdelivr CDN images', async () => {
const markdown = `![logo](./logo.png)`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toContain('src="https://cdn.jsdelivr.net/npm/test-pkg/logo.png"')
expect(result.html).not.toContain('/api/registry/image-proxy')
})
})

describe('untrusted domains (proxied)', () => {
it('proxies images from unknown third-party domains', async () => {
const markdown = `![tracker](https://evil-tracker.com/pixel.gif)`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toContain('/api/registry/image-proxy?url=')
expect(result.html).toContain(encodeURIComponent('https://evil-tracker.com/pixel.gif'))
expect(result.html).not.toContain('src="https://evil-tracker.com/pixel.gif"')
})

it('proxies images from arbitrary hosts', async () => {
const markdown = `![img](https://some-random-host.com/image.png)`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toContain('/api/registry/image-proxy?url=')
expect(result.html).toContain(encodeURIComponent('https://some-random-host.com/image.png'))
})

it('proxies HTML img tags from untrusted domains', async () => {
const markdown = `<img src="https://unknown-site.org/tracking.png" alt="test">`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toContain('/api/registry/image-proxy?url=')
expect(result.html).toContain(encodeURIComponent('https://unknown-site.org/tracking.png'))
})
})
})

describe('Markdown Content Extraction', () => {
describe('Markdown', () => {
it('returns original markdown content unchanged', async () => {
Expand Down
Loading
Loading