Skip to content

Commit b6c0f06

Browse files
author
root
committed
fix: proxy external images in README to prevent privacy leak
1 parent 0990f81 commit b6c0f06

File tree

5 files changed

+445
-4
lines changed

5 files changed

+445
-4
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { createError, getQuery, setResponseHeaders } from 'h3'
2+
import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
3+
import { isAllowedImageUrl } from '#shared/utils/image-proxy'
4+
5+
/**
6+
* Image proxy endpoint to prevent privacy leaks from README images.
7+
*
8+
* Instead of letting the client's browser fetch images directly from third-party
9+
* servers (which exposes visitor IP, User-Agent, etc.), this endpoint fetches
10+
* images server-side and forwards them to the client.
11+
*
12+
* Similar to GitHub's camo proxy: https://github.blog/2014-01-28-proxying-user-images/
13+
*
14+
* Usage: /api/registry/image-proxy?url=https://example.com/image.png
15+
*
16+
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
17+
*/
18+
export default defineCachedEventHandler(
19+
async event => {
20+
const query = getQuery(event)
21+
const url = query.url as string | undefined
22+
23+
if (!url) {
24+
throw createError({
25+
statusCode: 400,
26+
message: 'Missing required "url" query parameter.',
27+
})
28+
}
29+
30+
// Validate URL
31+
if (!isAllowedImageUrl(url)) {
32+
throw createError({
33+
statusCode: 400,
34+
message: 'Invalid or disallowed image URL.',
35+
})
36+
}
37+
38+
try {
39+
const response = await fetch(url, {
40+
headers: {
41+
// Use a generic User-Agent to avoid leaking server info
42+
'User-Agent': 'npmx-image-proxy/1.0',
43+
'Accept': 'image/*',
44+
},
45+
// Prevent redirects to non-HTTP protocols
46+
redirect: 'follow',
47+
})
48+
49+
if (!response.ok) {
50+
throw createError({
51+
statusCode: response.status === 404 ? 404 : 502,
52+
message: `Failed to fetch image: ${response.status}`,
53+
})
54+
}
55+
56+
const contentType = response.headers.get('content-type') || 'application/octet-stream'
57+
58+
// Only allow image content types
59+
if (!contentType.startsWith('image/') && !contentType.startsWith('application/octet-stream')) {
60+
throw createError({
61+
statusCode: 400,
62+
message: 'URL does not point to an image.',
63+
})
64+
}
65+
66+
// Enforce a maximum size of 10 MB to prevent abuse
67+
const contentLength = response.headers.get('content-length')
68+
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
69+
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
70+
throw createError({
71+
statusCode: 413,
72+
message: 'Image too large.',
73+
})
74+
}
75+
76+
const imageBuffer = await response.arrayBuffer()
77+
78+
// Check actual size
79+
if (imageBuffer.byteLength > MAX_SIZE) {
80+
throw createError({
81+
statusCode: 413,
82+
message: 'Image too large.',
83+
})
84+
}
85+
86+
setResponseHeaders(event, {
87+
'Content-Type': contentType,
88+
'Content-Length': imageBuffer.byteLength.toString(),
89+
'Cache-Control': `public, max-age=${CACHE_MAX_AGE_ONE_DAY}, s-maxage=${CACHE_MAX_AGE_ONE_DAY}`,
90+
// Security headers - prevent content sniffing and restrict usage
91+
'X-Content-Type-Options': 'nosniff',
92+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
93+
})
94+
95+
return Buffer.from(imageBuffer)
96+
} catch (error: unknown) {
97+
// Re-throw H3 errors
98+
if (error && typeof error === 'object' && 'statusCode' in error) {
99+
throw error
100+
}
101+
102+
throw createError({
103+
statusCode: 502,
104+
message: 'Failed to proxy image.',
105+
})
106+
}
107+
},
108+
{
109+
maxAge: CACHE_MAX_AGE_ONE_DAY,
110+
swr: true,
111+
getKey: event => {
112+
const query = getQuery(event)
113+
return `image-proxy:${query.url}`
114+
},
115+
},
116+
)

server/utils/readme.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ReadmeResponse, TocItem } from '#shared/types/readme'
55
import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers'
66
import { highlightCodeSync } from './shiki'
77
import { convertToEmoji } from '#shared/utils/emoji'
8+
import { toProxiedImageUrl } from '#shared/utils/image-proxy'
89

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

267272
// Helper to prefix id attributes with 'user-content-'

shared/utils/image-proxy.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Image proxy utilities for privacy-safe README image rendering.
3+
*
4+
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
5+
*/
6+
7+
/** Trusted image domains that don't need proxying (first-party or well-known CDNs) */
8+
const TRUSTED_IMAGE_DOMAINS = [
9+
// First-party
10+
'npmx.dev',
11+
12+
// GitHub (already proxied by GitHub's own camo)
13+
'raw.githubusercontent.com',
14+
'github.com',
15+
'user-images.githubusercontent.com',
16+
'avatars.githubusercontent.com',
17+
'repository-images.githubusercontent.com',
18+
'github.githubassets.com',
19+
'objects.githubusercontent.com',
20+
21+
// GitLab
22+
'gitlab.com',
23+
24+
// CDNs commonly used in READMEs
25+
'cdn.jsdelivr.net',
26+
'unpkg.com',
27+
28+
// Well-known badge/shield services
29+
'img.shields.io',
30+
'shields.io',
31+
'badge.fury.io',
32+
'badgen.net',
33+
'flat.badgen.net',
34+
'codecov.io',
35+
'coveralls.io',
36+
'david-dm.org',
37+
'snyk.io',
38+
'app.fossa.com',
39+
'api.codeclimate.com',
40+
'bundlephobia.com',
41+
'packagephobia.com',
42+
]
43+
44+
/**
45+
* Check if a URL points to a trusted domain that doesn't need proxying.
46+
*/
47+
export function isTrustedImageDomain(url: string): boolean {
48+
try {
49+
const parsed = new URL(url)
50+
const hostname = parsed.hostname.toLowerCase()
51+
return TRUSTED_IMAGE_DOMAINS.some(
52+
domain => hostname === domain || hostname.endsWith(`.${domain}`),
53+
)
54+
} catch {
55+
return false
56+
}
57+
}
58+
59+
/**
60+
* Validate that a URL is a valid HTTP(S) image URL suitable for proxying.
61+
*/
62+
export function isAllowedImageUrl(url: string): boolean {
63+
try {
64+
const parsed = new URL(url)
65+
// Only allow HTTP and HTTPS protocols
66+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
67+
return false
68+
}
69+
// Block localhost / private IPs to prevent SSRF
70+
const hostname = parsed.hostname.toLowerCase()
71+
if (
72+
hostname === 'localhost' ||
73+
hostname === '127.0.0.1' ||
74+
hostname === '::1' ||
75+
hostname === '0.0.0.0' ||
76+
hostname.startsWith('10.') ||
77+
hostname.startsWith('192.168.') ||
78+
hostname.startsWith('172.') ||
79+
hostname.endsWith('.local') ||
80+
hostname.endsWith('.internal')
81+
) {
82+
return false
83+
}
84+
return true
85+
} catch {
86+
return false
87+
}
88+
}
89+
90+
/**
91+
* Convert an external image URL to a proxied URL.
92+
* Trusted domains are returned as-is.
93+
* Returns the original URL for non-HTTP(S) URLs.
94+
*/
95+
export function toProxiedImageUrl(url: string): string {
96+
// Don't proxy data URIs, relative URLs, or anchor links
97+
if (!url || url.startsWith('#') || url.startsWith('data:')) {
98+
return url
99+
}
100+
101+
try {
102+
const parsed = new URL(url)
103+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
104+
return url
105+
}
106+
} catch {
107+
// Not an absolute URL, return as-is (relative URLs are fine)
108+
return url
109+
}
110+
111+
// Trusted domains don't need proxying
112+
if (isTrustedImageDomain(url)) {
113+
return url
114+
}
115+
116+
// Proxy through our server endpoint
117+
return `/api/registry/image-proxy?url=${encodeURIComponent(url)}`
118+
}

test/unit/server/utils/readme.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,64 @@ describe('Markdown File URL Resolution', () => {
308308
})
309309
})
310310

311+
describe('Image Privacy Proxy', () => {
312+
describe('trusted domains (not proxied)', () => {
313+
it('does not proxy GitHub raw content images', async () => {
314+
const repoInfo = createRepoInfo()
315+
const markdown = `![logo](./assets/logo.png)`
316+
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
317+
318+
expect(result.html).toContain(
319+
'src="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"',
320+
)
321+
expect(result.html).not.toContain('/api/registry/image-proxy')
322+
})
323+
324+
it('does not proxy shields.io badge images', async () => {
325+
const markdown = `![badge](https://img.shields.io/badge/build-passing-green)`
326+
const result = await renderReadmeHtml(markdown, 'test-pkg')
327+
328+
expect(result.html).toContain('src="https://img.shields.io/badge/build-passing-green"')
329+
expect(result.html).not.toContain('/api/registry/image-proxy')
330+
})
331+
332+
it('does not proxy jsdelivr CDN images', async () => {
333+
const markdown = `![logo](./logo.png)`
334+
const result = await renderReadmeHtml(markdown, 'test-pkg')
335+
336+
expect(result.html).toContain('src="https://cdn.jsdelivr.net/npm/test-pkg/logo.png"')
337+
expect(result.html).not.toContain('/api/registry/image-proxy')
338+
})
339+
})
340+
341+
describe('untrusted domains (proxied)', () => {
342+
it('proxies images from unknown third-party domains', async () => {
343+
const markdown = `![tracker](https://evil-tracker.com/pixel.gif)`
344+
const result = await renderReadmeHtml(markdown, 'test-pkg')
345+
346+
expect(result.html).toContain('/api/registry/image-proxy?url=')
347+
expect(result.html).toContain(encodeURIComponent('https://evil-tracker.com/pixel.gif'))
348+
expect(result.html).not.toContain('src="https://evil-tracker.com/pixel.gif"')
349+
})
350+
351+
it('proxies images from arbitrary hosts', async () => {
352+
const markdown = `![img](https://some-random-host.com/image.png)`
353+
const result = await renderReadmeHtml(markdown, 'test-pkg')
354+
355+
expect(result.html).toContain('/api/registry/image-proxy?url=')
356+
expect(result.html).toContain(encodeURIComponent('https://some-random-host.com/image.png'))
357+
})
358+
359+
it('proxies HTML img tags from untrusted domains', async () => {
360+
const markdown = `<img src="https://unknown-site.org/tracking.png" alt="test">`
361+
const result = await renderReadmeHtml(markdown, 'test-pkg')
362+
363+
expect(result.html).toContain('/api/registry/image-proxy?url=')
364+
expect(result.html).toContain(encodeURIComponent('https://unknown-site.org/tracking.png'))
365+
})
366+
})
367+
})
368+
311369
describe('Markdown Content Extraction', () => {
312370
describe('Markdown', () => {
313371
it('returns original markdown content unchanged', async () => {

0 commit comments

Comments
 (0)