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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

163 changes: 163 additions & 0 deletions server/api/registry/image-proxy/index.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { createError, getQuery, setResponseHeaders, sendStream } from 'h3'
import { Readable } from 'node:stream'
import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
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.
*
* 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 defineEventHandler(async event => {
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 syntactically
if (!isAllowedImageUrl(url)) {
throw createError({
statusCode: 400,
message: 'Invalid or disallowed image URL.',
})
}

// 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: {
// Use a generic User-Agent to avoid leaking server info
'User-Agent': 'npmx-image-proxy/1.0',
'Accept': 'image/*',
},
redirect: 'follow',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})

// Validate final URL after any redirects to prevent SSRF bypass
if (response.url !== url && !isAllowedImageUrl(response.url)) {
throw createError({
statusCode: 400,
message: 'Redirect to disallowed URL.',
})
}

// 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,
message: `Failed to fetch image: ${response.status}`,
})
}

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

// Only allow raster/vector image content types, but block SVG to prevent
// embedded JavaScript execution (SVGs can contain <script> tags, event handlers, etc.)
if (!contentType.startsWith('image/') || contentType.includes('svg')) {
throw createError({
statusCode: 400,
message: 'URL does not point to an allowed image type.',
})
}

// Check Content-Length header if present (may be absent or dishonest)
const contentLength = response.headers.get('content-length')
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
throw createError({
statusCode: 413,
message: 'Image too large.',
})
}

if (!response.body) {
throw createError({
statusCode: 502,
message: 'No response body from upstream.',
})
}

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'",
})

// Stream the response with a size limit to prevent memory exhaustion.
// Uses pipe-based backpressure so the upstream pauses when the consumer is slow.
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() {
// Resume the upstream when the consumer is ready for more data
upstream.resume()
},
})

upstream.on('data', (chunk: Buffer) => {
bytesRead += chunk.length
if (bytesRead > MAX_SIZE) {
upstream.destroy()
limited.destroy(new Error('Image too large'))
} else {
// Respect backpressure: if push() returns false, pause the upstream
// until the consumer calls read() again
if (!limited.push(chunk)) {
upstream.pause()
}
}
})
upstream.on('end', () => limited.push(null))
upstream.on('error', (err: Error) => limited.destroy(err))

return sendStream(event, limited)
} 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.',
})
}
})
163 changes: 163 additions & 0 deletions server/utils/image-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Image proxy utilities for privacy-safe README image rendering.
*
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
*/

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 = [
// 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 {
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}`),
)
}

/**
* Check if a resolved IP address is in a private/reserved range.
* Uses ipaddr.js for comprehensive IPv4, IPv6, and IPv4-mapped IPv6 range detection.
*/
function isPrivateIP(ip: string): boolean {
const bare = ip.startsWith('[') && ip.endsWith(']') ? ip.slice(1, -1) : ip
if (!ipaddr.isValid(bare)) return false
const addr = ipaddr.process(bare)
return addr.range() !== 'unicast'
}

/**
* 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)
if (!parsed) return false

// Only allow HTTP and HTTPS protocols
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false
}

const hostname = parsed.hostname.toLowerCase()

// 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)
if (isPrivateIP(hostname)) {
return false
}

return true
}

/**
* Resolve the hostname of a URL via DNS and validate that all resolved IPs are
* public unicast addresses. This prevents DNS rebinding SSRF attacks where a
* hostname passes the initial string-based check but resolves to a private IP.
*
* Returns true if the hostname resolves to a safe (unicast) IP.
* Returns false if any resolved IP is private/reserved, or if resolution fails.
*/
export async function resolveAndValidateHost(url: string): Promise<boolean> {
const parsed = URL.parse(url)
if (!parsed) return false

const hostname = parsed.hostname.toLowerCase()

// If it's already an IP literal, skip DNS resolution (already validated by isAllowedImageUrl)
const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
if (ipaddr.isValid(bare)) {
return !isPrivateIP(bare)
}

try {
// Resolve with { all: true } to get every A/AAAA record. A hostname can
// have multiple records; an attacker could mix a public IP with a private
// one. If any resolved IP is private/reserved, reject the entire request.
const results = await lookup(hostname, { all: true })
if (results.length === 0) return false
return results.every(result => !isPrivateIP(result.address))
} catch {
// DNS resolution failed — block the request
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
}

// 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
}

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

// Proxy through our server endpoint
return `/api/registry/image-proxy?url=${encodeURIComponent(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 '#server/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
Loading
Loading