From b6c0f0648020874596fe87bc117bdd0444839bee Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Feb 2026 18:48:01 +0800 Subject: [PATCH 1/7] fix: proxy external images in README to prevent privacy leak --- server/api/registry/image-proxy/index.get.ts | 116 +++++++++++++++ server/utils/readme.ts | 13 +- shared/utils/image-proxy.ts | 118 +++++++++++++++ test/unit/server/utils/readme.spec.ts | 58 ++++++++ test/unit/shared/utils/image-proxy.spec.ts | 144 +++++++++++++++++++ 5 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 server/api/registry/image-proxy/index.get.ts create mode 100644 shared/utils/image-proxy.ts create mode 100644 test/unit/shared/utils/image-proxy.spec.ts diff --git a/server/api/registry/image-proxy/index.get.ts b/server/api/registry/image-proxy/index.get.ts new file mode 100644 index 000000000..2e5c040fb --- /dev/null +++ b/server/api/registry/image-proxy/index.get.ts @@ -0,0 +1,116 @@ +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 + + 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', + }) + + 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() + + // 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}` + }, + }, +) diff --git a/server/utils/readme.ts b/server/utils/readme.ts index dc2a54043..1f68a9564 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -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 @@ -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-' diff --git a/shared/utils/image-proxy.ts b/shared/utils/image-proxy.ts new file mode 100644 index 000000000..b58e3fb0b --- /dev/null +++ b/shared/utils/image-proxy.ts @@ -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 + } +} + +/** + * 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 + } +} + +/** + * 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 + } + + // Trusted domains don't need proxying + if (isTrustedImageDomain(url)) { + return url + } + + // Proxy through our server endpoint + return `/api/registry/image-proxy?url=${encodeURIComponent(url)}` +} diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts index 7790d4d04..0eace751d 100644 --- a/test/unit/server/utils/readme.spec.ts +++ b/test/unit/server/utils/readme.spec.ts @@ -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 = `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 () => { diff --git a/test/unit/shared/utils/image-proxy.spec.ts b/test/unit/shared/utils/image-proxy.spec.ts new file mode 100644 index 000000000..eb19fb62f --- /dev/null +++ b/test/unit/shared/utils/image-proxy.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { + isTrustedImageDomain, + isAllowedImageUrl, + toProxiedImageUrl, +} from '../../../../shared/utils/image-proxy' + +describe('Image Proxy Utils', () => { + describe('isTrustedImageDomain', () => { + it('trusts GitHub raw content URLs', () => { + expect(isTrustedImageDomain('https://raw.githubusercontent.com/owner/repo/main/img.png')).toBe(true) + }) + + it('trusts GitHub user images', () => { + expect(isTrustedImageDomain('https://user-images.githubusercontent.com/123/image.png')).toBe(true) + }) + + it('trusts shields.io badge URLs', () => { + expect(isTrustedImageDomain('https://img.shields.io/badge/test-passing-green')).toBe(true) + }) + + it('trusts jsdelivr CDN URLs', () => { + expect(isTrustedImageDomain('https://cdn.jsdelivr.net/npm/pkg/logo.png')).toBe(true) + }) + + it('trusts npmx.dev URLs', () => { + expect(isTrustedImageDomain('https://npmx.dev/images/logo.png')).toBe(true) + }) + + it('trusts subdomain of trusted domains', () => { + expect(isTrustedImageDomain('https://sub.gitlab.com/image.png')).toBe(true) + }) + + it('does not trust arbitrary domains', () => { + expect(isTrustedImageDomain('https://evil-tracker.com/pixel.gif')).toBe(false) + }) + + it('does not trust similar-looking domains', () => { + expect(isTrustedImageDomain('https://notgithub.com/image.png')).toBe(false) + }) + + it('returns false for invalid URLs', () => { + expect(isTrustedImageDomain('not-a-url')).toBe(false) + }) + }) + + describe('isAllowedImageUrl', () => { + it('allows HTTPS URLs', () => { + expect(isAllowedImageUrl('https://example.com/image.png')).toBe(true) + }) + + it('allows HTTP URLs', () => { + expect(isAllowedImageUrl('http://example.com/image.png')).toBe(true) + }) + + it('blocks data: URIs', () => { + expect(isAllowedImageUrl('data:image/png;base64,abc')).toBe(false) + }) + + it('blocks javascript: URIs', () => { + expect(isAllowedImageUrl('javascript:alert(1)')).toBe(false) + }) + + it('blocks localhost', () => { + expect(isAllowedImageUrl('http://localhost/image.png')).toBe(false) + }) + + it('blocks 127.0.0.1', () => { + expect(isAllowedImageUrl('http://127.0.0.1/image.png')).toBe(false) + }) + + it('blocks private IPs (10.x)', () => { + expect(isAllowedImageUrl('http://10.0.0.1/image.png')).toBe(false) + }) + + it('blocks private IPs (192.168.x)', () => { + expect(isAllowedImageUrl('http://192.168.1.1/image.png')).toBe(false) + }) + + it('blocks .local domains', () => { + expect(isAllowedImageUrl('http://myhost.local/image.png')).toBe(false) + }) + + it('blocks .internal domains', () => { + expect(isAllowedImageUrl('http://service.internal/image.png')).toBe(false) + }) + + it('returns false for invalid URLs', () => { + expect(isAllowedImageUrl('not-a-url')).toBe(false) + }) + }) + + describe('toProxiedImageUrl', () => { + it('returns trusted URLs as-is', () => { + const url = 'https://raw.githubusercontent.com/owner/repo/main/image.png' + expect(toProxiedImageUrl(url)).toBe(url) + }) + + it('proxies untrusted external URLs', () => { + const url = 'https://evil-tracker.com/pixel.gif' + expect(toProxiedImageUrl(url)).toBe( + `/api/registry/image-proxy?url=${encodeURIComponent(url)}`, + ) + }) + + it('proxies unknown third-party image hosts', () => { + const url = 'https://some-random-site.com/tracking-pixel.png' + expect(toProxiedImageUrl(url)).toBe( + `/api/registry/image-proxy?url=${encodeURIComponent(url)}`, + ) + }) + + it('does not proxy shields.io badges', () => { + const url = 'https://img.shields.io/badge/build-passing-green' + expect(toProxiedImageUrl(url)).toBe(url) + }) + + it('does not proxy jsdelivr CDN images', () => { + const url = 'https://cdn.jsdelivr.net/npm/pkg/logo.png' + expect(toProxiedImageUrl(url)).toBe(url) + }) + + it('returns empty string for empty input', () => { + expect(toProxiedImageUrl('')).toBe('') + }) + + it('returns anchor links as-is', () => { + expect(toProxiedImageUrl('#section')).toBe('#section') + }) + + it('returns data URIs as-is', () => { + expect(toProxiedImageUrl('data:image/png;base64,abc')).toBe('data:image/png;base64,abc') + }) + + it('returns relative URLs as-is', () => { + expect(toProxiedImageUrl('./images/logo.png')).toBe('./images/logo.png') + }) + + it('does not proxy GitHub blob URLs', () => { + const url = 'https://github.com/owner/repo/blob/main/assets/logo.png' + expect(toProxiedImageUrl(url)).toBe(url) + }) + }) +}) From ffc0b5ef8c644af700a505d4d003db2ccc9033bd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:49:26 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- server/api/registry/image-proxy/index.get.ts | 5 ++++- test/unit/shared/utils/image-proxy.spec.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/server/api/registry/image-proxy/index.get.ts b/server/api/registry/image-proxy/index.get.ts index 2e5c040fb..7735ce93b 100644 --- a/server/api/registry/image-proxy/index.get.ts +++ b/server/api/registry/image-proxy/index.get.ts @@ -56,7 +56,10 @@ export default defineCachedEventHandler( const contentType = response.headers.get('content-type') || 'application/octet-stream' // Only allow image content types - if (!contentType.startsWith('image/') && !contentType.startsWith('application/octet-stream')) { + if ( + !contentType.startsWith('image/') && + !contentType.startsWith('application/octet-stream') + ) { throw createError({ statusCode: 400, message: 'URL does not point to an image.', diff --git a/test/unit/shared/utils/image-proxy.spec.ts b/test/unit/shared/utils/image-proxy.spec.ts index eb19fb62f..59aef4b43 100644 --- a/test/unit/shared/utils/image-proxy.spec.ts +++ b/test/unit/shared/utils/image-proxy.spec.ts @@ -8,11 +8,15 @@ import { describe('Image Proxy Utils', () => { describe('isTrustedImageDomain', () => { it('trusts GitHub raw content URLs', () => { - expect(isTrustedImageDomain('https://raw.githubusercontent.com/owner/repo/main/img.png')).toBe(true) + expect( + isTrustedImageDomain('https://raw.githubusercontent.com/owner/repo/main/img.png'), + ).toBe(true) }) it('trusts GitHub user images', () => { - expect(isTrustedImageDomain('https://user-images.githubusercontent.com/123/image.png')).toBe(true) + expect(isTrustedImageDomain('https://user-images.githubusercontent.com/123/image.png')).toBe( + true, + ) }) it('trusts shields.io badge URLs', () => { From 53b2cf59c5ff612cb2ea47357b3382b68627ec0c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 8 Feb 2026 23:26:49 +0000 Subject: [PATCH 3/7] fix: return stream, protect against ssrf --- server/api/registry/image-proxy/index.get.ts | 183 ++++++++++--------- shared/utils/image-proxy.ts | 93 ++++++---- test/unit/shared/utils/image-proxy.spec.ts | 24 +++ 3 files changed, 177 insertions(+), 123 deletions(-) diff --git a/server/api/registry/image-proxy/index.get.ts b/server/api/registry/image-proxy/index.get.ts index 7735ce93b..f88f909ee 100644 --- a/server/api/registry/image-proxy/index.get.ts +++ b/server/api/registry/image-proxy/index.get.ts @@ -1,4 +1,5 @@ -import { createError, getQuery, setResponseHeaders } from 'h3' +import { createError, getQuery, setResponseHeaders, sendStream } from 'h3' +import { Readable } from 'node:stream' import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' import { isAllowedImageUrl } from '#shared/utils/image-proxy' @@ -15,105 +16,119 @@ import { isAllowedImageUrl } from '#shared/utils/image-proxy' * * 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 +export default defineEventHandler(async event => { + const query = getQuery(event) + const rawUrl = query.url + const url = (Array.isArray(rawUrl) ? rawUrl[0] : rawUrl) as string | undefined - if (!url) { + 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/*', + }, + redirect: 'follow', + }) + + // Validate final URL after any redirects to prevent SSRF bypass + if (response.url !== url && !isAllowedImageUrl(response.url)) { throw createError({ statusCode: 400, - message: 'Missing required "url" query parameter.', + message: 'Redirect to disallowed URL.', }) } - // Validate URL - if (!isAllowedImageUrl(url)) { + if (!response.ok) { throw createError({ - statusCode: 400, - message: 'Invalid or disallowed image URL.', + statusCode: response.status === 404 ? 404 : 502, + message: `Failed to fetch image: ${response.status}`, }) } - 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', - }) - - 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' + 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.', - }) - } + // Only allow image content types + if (!contentType.startsWith('image/')) { + 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.', - }) - } + // Check Content-Length header if present (may be absent or dishonest) + const MAX_SIZE = 10 * 1024 * 1024 // 10 MB + const contentLength = response.headers.get('content-length') + if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) { + throw createError({ + statusCode: 413, + message: 'Image too large.', + }) + } - const imageBuffer = await response.arrayBuffer() + if (!response.body) { + throw createError({ + statusCode: 502, + message: 'No response body from upstream.', + }) + } - // Check actual size - if (imageBuffer.byteLength > MAX_SIZE) { - throw createError({ - statusCode: 413, - message: 'Image too large.', - }) - } + setResponseHeaders(event, { + 'Content-Type': contentType, + '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'", + }) - 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'", - }) + // Stream the response with a size limit to prevent memory exhaustion. + // This avoids buffering the entire image into memory before sending. + let bytesRead = 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const upstream = Readable.fromWeb(response.body as any) + const limited = new Readable({ + read() { + /* pulling is driven by upstream push */ + }, + }) - return Buffer.from(imageBuffer) - } catch (error: unknown) { - // Re-throw H3 errors - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error + upstream.on('data', (chunk: Buffer) => { + bytesRead += chunk.length + if (bytesRead > MAX_SIZE) { + upstream.destroy() + limited.destroy(new Error('Image too large')) + } else { + limited.push(chunk) } + }) + upstream.on('end', () => limited.push(null)) + upstream.on('error', (err: Error) => limited.destroy(err)) - throw createError({ - statusCode: 502, - message: 'Failed to proxy image.', - }) + return sendStream(event, limited) + } catch (error: unknown) { + // Re-throw H3 errors + if (error && typeof error === 'object' && 'statusCode' in error) { + throw error } - }, - { - maxAge: CACHE_MAX_AGE_ONE_DAY, - swr: true, - getKey: event => { - const query = getQuery(event) - return `image-proxy:${query.url}` - }, - }, -) + + throw createError({ + statusCode: 502, + message: 'Failed to proxy image.', + }) + } +}) diff --git a/shared/utils/image-proxy.ts b/shared/utils/image-proxy.ts index b58e3fb0b..5982d9049 100644 --- a/shared/utils/image-proxy.ts +++ b/shared/utils/image-proxy.ts @@ -45,46 +45,66 @@ const TRUSTED_IMAGE_DOMAINS = [ * 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 - } + const parsed = URL.parse(url) + if (!parsed) return false + + const hostname = parsed.hostname.toLowerCase() + return TRUSTED_IMAGE_DOMAINS.some( + domain => hostname === domain || hostname.endsWith(`.${domain}`), + ) } /** * 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 { + const parsed = URL.parse(url) + if (!parsed) return false + + // 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 === '0.0.0.0' || + hostname.startsWith('10.') || + hostname.startsWith('192.168.') || + // RFC 1918: 172.16.0.0 – 172.31.255.255 + /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || + // Link-local (cloud metadata: 169.254.169.254) + hostname.startsWith('169.254.') || + hostname.endsWith('.local') || + hostname.endsWith('.internal') || + // IPv6 loopback + hostname === '::1' || + hostname === '[::1]' || + // IPv6 link-local + hostname.startsWith('fe80:') || + hostname.startsWith('[fe80:') || + // IPv6 unique local (fc00::/7) + hostname.startsWith('fc') || + hostname.startsWith('fd') || + hostname.startsWith('[fc') || + hostname.startsWith('[fd') || + // IPv4-mapped IPv6 addresses + hostname.startsWith('::ffff:127.') || + hostname.startsWith('::ffff:10.') || + hostname.startsWith('::ffff:192.168.') || + hostname.startsWith('::ffff:169.254.') || + hostname.startsWith('[::ffff:127.') || + hostname.startsWith('[::ffff:10.') || + hostname.startsWith('[::ffff:192.168.') || + hostname.startsWith('[::ffff:169.254.') + ) { + return false + } + + return true } /** @@ -98,13 +118,8 @@ export function toProxiedImageUrl(url: string): string { 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) + const parsed = URL.parse(url) + if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) { return url } diff --git a/test/unit/shared/utils/image-proxy.spec.ts b/test/unit/shared/utils/image-proxy.spec.ts index 59aef4b43..354d20a57 100644 --- a/test/unit/shared/utils/image-proxy.spec.ts +++ b/test/unit/shared/utils/image-proxy.spec.ts @@ -89,6 +89,30 @@ describe('Image Proxy Utils', () => { expect(isAllowedImageUrl('http://service.internal/image.png')).toBe(false) }) + it('blocks cloud metadata IP (169.254.x.x)', () => { + expect(isAllowedImageUrl('http://169.254.169.254/latest/meta-data/')).toBe(false) + }) + + it('blocks private 172.16-31.x.x range', () => { + expect(isAllowedImageUrl('http://172.16.0.1/image.png')).toBe(false) + expect(isAllowedImageUrl('http://172.31.255.255/image.png')).toBe(false) + }) + + it('allows public 172.x IPs outside RFC 1918', () => { + expect(isAllowedImageUrl('http://172.64.0.1/image.png')).toBe(true) + expect(isAllowedImageUrl('http://172.15.0.1/image.png')).toBe(true) + expect(isAllowedImageUrl('http://172.32.0.1/image.png')).toBe(true) + }) + + it('blocks IPv6 link-local (fe80::)', () => { + expect(isAllowedImageUrl('http://[fe80::1]/image.png')).toBe(false) + }) + + it('blocks IPv6 unique local (fc00::/fd)', () => { + expect(isAllowedImageUrl('http://[fc00::1]/image.png')).toBe(false) + expect(isAllowedImageUrl('http://[fd12::1]/image.png')).toBe(false) + }) + it('returns false for invalid URLs', () => { expect(isAllowedImageUrl('not-a-url')).toBe(false) }) From 85d5bd99ef7b411831fe8ffbffcb43eadadf90fc Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 8 Feb 2026 23:33:52 +0000 Subject: [PATCH 4/7] refactor: use ipaddr.js to handle private ranges + move to server/ dir --- package.json | 1 + pnpm-lock.yaml | 3 ++ server/api/registry/image-proxy/index.get.ts | 2 +- {shared => server}/utils/image-proxy.ts | 52 +++++++------------ server/utils/readme.ts | 2 +- .../utils/image-proxy.spec.ts | 2 +- vitest.config.ts | 1 + 7 files changed, 26 insertions(+), 37 deletions(-) rename {shared => server}/utils/image-proxy.ts (66%) rename test/unit/{shared => server}/utils/image-proxy.spec.ts (99%) diff --git a/package.json b/package.json index df6720dfa..e9bfa4314 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "fast-npm-meta": "1.0.0", "focus-trap": "^7.8.0", "gray-matter": "4.0.3", + "ipaddr.js": "2.3.0", "marked": "17.0.1", "module-replacements": "2.11.0", "nuxt": "4.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4903d2079..84181090e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: gray-matter: specifier: 4.0.3 version: 4.0.3 + ipaddr.js: + specifier: 2.3.0 + version: 2.3.0 marked: specifier: 17.0.1 version: 17.0.1 diff --git a/server/api/registry/image-proxy/index.get.ts b/server/api/registry/image-proxy/index.get.ts index f88f909ee..2972a9f49 100644 --- a/server/api/registry/image-proxy/index.get.ts +++ b/server/api/registry/image-proxy/index.get.ts @@ -1,7 +1,7 @@ import { createError, getQuery, setResponseHeaders, sendStream } from 'h3' import { Readable } from 'node:stream' import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' -import { isAllowedImageUrl } from '#shared/utils/image-proxy' +import { isAllowedImageUrl } from '#server/utils/image-proxy' /** * Image proxy endpoint to prevent privacy leaks from README images. diff --git a/shared/utils/image-proxy.ts b/server/utils/image-proxy.ts similarity index 66% rename from shared/utils/image-proxy.ts rename to server/utils/image-proxy.ts index 5982d9049..4bac095f8 100644 --- a/shared/utils/image-proxy.ts +++ b/server/utils/image-proxy.ts @@ -4,6 +4,8 @@ * Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138 */ +import ipaddr from 'ipaddr.js' + /** Trusted image domains that don't need proxying (first-party or well-known CDNs) */ const TRUSTED_IMAGE_DOMAINS = [ // First-party @@ -56,6 +58,8 @@ export function isTrustedImageDomain(url: string): boolean { /** * Validate that a URL is a valid HTTP(S) image URL suitable for proxying. + * Blocks private/reserved IPs (SSRF protection) using ipaddr.js for comprehensive + * IPv4, IPv6, and IPv4-mapped IPv6 range detection. */ export function isAllowedImageUrl(url: string): boolean { const parsed = URL.parse(url) @@ -66,44 +70,24 @@ export function isAllowedImageUrl(url: string): boolean { return false } - // Block localhost / private IPs to prevent SSRF const hostname = parsed.hostname.toLowerCase() - if ( - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname.startsWith('10.') || - hostname.startsWith('192.168.') || - // RFC 1918: 172.16.0.0 – 172.31.255.255 - /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || - // Link-local (cloud metadata: 169.254.169.254) - hostname.startsWith('169.254.') || - hostname.endsWith('.local') || - hostname.endsWith('.internal') || - // IPv6 loopback - hostname === '::1' || - hostname === '[::1]' || - // IPv6 link-local - hostname.startsWith('fe80:') || - hostname.startsWith('[fe80:') || - // IPv6 unique local (fc00::/7) - hostname.startsWith('fc') || - hostname.startsWith('fd') || - hostname.startsWith('[fc') || - hostname.startsWith('[fd') || - // IPv4-mapped IPv6 addresses - hostname.startsWith('::ffff:127.') || - hostname.startsWith('::ffff:10.') || - hostname.startsWith('::ffff:192.168.') || - hostname.startsWith('::ffff:169.254.') || - hostname.startsWith('[::ffff:127.') || - hostname.startsWith('[::ffff:10.') || - hostname.startsWith('[::ffff:192.168.') || - hostname.startsWith('[::ffff:169.254.') - ) { + + // Block non-IP hostnames that resolve to internal services + if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.internal')) { return false } + // For IP addresses, use ipaddr.js to check against all reserved ranges + // (loopback, private RFC 1918, link-local 169.254, IPv6 ULA fc00::/7, etc.) + // ipaddr.process() also unwraps IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 → 127.0.0.1) + const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + if (ipaddr.isValid(bare)) { + const addr = ipaddr.process(bare) + if (addr.range() !== 'unicast') { + return false + } + } + return true } diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 1f68a9564..ccfc988e7 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -5,7 +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' +import { toProxiedImageUrl } from '#server/utils/image-proxy' /** * Playground provider configuration diff --git a/test/unit/shared/utils/image-proxy.spec.ts b/test/unit/server/utils/image-proxy.spec.ts similarity index 99% rename from test/unit/shared/utils/image-proxy.spec.ts rename to test/unit/server/utils/image-proxy.spec.ts index 354d20a57..095edb10d 100644 --- a/test/unit/shared/utils/image-proxy.spec.ts +++ b/test/unit/server/utils/image-proxy.spec.ts @@ -3,7 +3,7 @@ import { isTrustedImageDomain, isAllowedImageUrl, toProxiedImageUrl, -} from '../../../../shared/utils/image-proxy' +} from '../../../../server/utils/image-proxy' describe('Image Proxy Utils', () => { describe('isTrustedImageDomain', () => { diff --git a/vitest.config.ts b/vitest.config.ts index c891e47fd..766cd0fe4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ resolve: { alias: { '#shared': `${rootDir}/shared`, + '#server': `${rootDir}/server`, }, }, test: { From baf57282a309b2d607caf3d3471a66215c99d1fb Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 08:34:11 +0000 Subject: [PATCH 5/7] fix: handle protocol relative urls --- server/utils/image-proxy.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/utils/image-proxy.ts b/server/utils/image-proxy.ts index 4bac095f8..d97a3ebd0 100644 --- a/server/utils/image-proxy.ts +++ b/server/utils/image-proxy.ts @@ -102,6 +102,11 @@ export function toProxiedImageUrl(url: string): string { return url } + // Protocol-relative URLs should be treated as HTTPS for proxying purposes + if (url.startsWith('//')) { + url = `https:${url}` + } + const parsed = URL.parse(url) if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) { return url From 225c88342af590ed750dd66793d59d8705ded37f Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 09:16:54 +0000 Subject: [PATCH 6/7] fix: add timeout, block svgs, prevent rebinding --- server/api/registry/image-proxy/index.get.ts | 47 +++++++++++++++---- server/utils/image-proxy.ts | 48 ++++++++++++++++++-- test/unit/server/utils/image-proxy.spec.ts | 39 ++++++++++++++++ 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/server/api/registry/image-proxy/index.get.ts b/server/api/registry/image-proxy/index.get.ts index 2972a9f49..e2ef71a4f 100644 --- a/server/api/registry/image-proxy/index.get.ts +++ b/server/api/registry/image-proxy/index.get.ts @@ -1,7 +1,13 @@ import { createError, getQuery, setResponseHeaders, sendStream } from 'h3' import { Readable } from 'node:stream' import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' -import { isAllowedImageUrl } from '#server/utils/image-proxy' +import { isAllowedImageUrl, resolveAndValidateHost } from '#server/utils/image-proxy' + +/** Fetch timeout in milliseconds to prevent slow-drip resource exhaustion */ +const FETCH_TIMEOUT_MS = 15_000 + +/** Maximum image size in bytes (10 MB) */ +const MAX_SIZE = 10 * 1024 * 1024 /** * Image proxy endpoint to prevent privacy leaks from README images. @@ -28,7 +34,7 @@ export default defineEventHandler(async event => { }) } - // Validate URL + // Validate URL syntactically if (!isAllowedImageUrl(url)) { throw createError({ statusCode: 400, @@ -36,6 +42,15 @@ export default defineEventHandler(async event => { }) } + // Resolve hostname via DNS and validate the resolved IP is not private. + // This prevents DNS rebinding attacks where a hostname resolves to a private IP. + if (!(await resolveAndValidateHost(url))) { + throw createError({ + statusCode: 400, + message: 'Invalid or disallowed image URL.', + }) + } + try { const response = await fetch(url, { headers: { @@ -44,6 +59,7 @@ export default defineEventHandler(async event => { 'Accept': 'image/*', }, redirect: 'follow', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }) // Validate final URL after any redirects to prevent SSRF bypass @@ -54,6 +70,14 @@ export default defineEventHandler(async event => { }) } + // Also validate the resolved IP of the redirect target + if (response.url !== url && !(await resolveAndValidateHost(response.url))) { + throw createError({ + statusCode: 400, + message: 'Redirect to disallowed URL.', + }) + } + if (!response.ok) { throw createError({ statusCode: response.status === 404 ? 404 : 502, @@ -63,16 +87,16 @@ export default defineEventHandler(async event => { const contentType = response.headers.get('content-type') || 'application/octet-stream' - // Only allow image content types - if (!contentType.startsWith('image/')) { + // Only allow raster/vector image content types, but block SVG to prevent + // embedded JavaScript execution (SVGs can contain