diff --git a/package.json b/package.json index 77e6dd29e..80eb6717c 100644 --- a/package.json +++ b/package.json @@ -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", 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 new file mode 100644 index 000000000..e2ef71a4f --- /dev/null +++ b/server/api/registry/image-proxy/index.get.ts @@ -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