diff --git a/.env.example b/.env.example index 76aba4fa82..beff9e50c4 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ #secure password, can use openssl rand -hex 32 -NUXT_SESSION_PASSWORD="" \ No newline at end of file +NUXT_SESSION_PASSWORD="" + +#HMAC secret for image proxy URL signing, can use openssl rand -hex 32 +NUXT_IMAGE_PROXY_SECRET="" \ No newline at end of file diff --git a/modules/image-proxy.ts b/modules/image-proxy.ts new file mode 100644 index 0000000000..fbd6f23bb5 --- /dev/null +++ b/modules/image-proxy.ts @@ -0,0 +1,41 @@ +import { defineNuxtModule, useNuxt } from 'nuxt/kit' +import process from 'node:process' +import { join } from 'node:path' +import { appendFileSync, existsSync, readFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' + +/** + * Auto-generates `NUXT_IMAGE_PROXY_SECRET` for local development if it is not + * already set. The secret is used to HMAC-sign image proxy URLs so that only + * server-generated URLs are accepted by the proxy endpoint. + * + * In production, `NUXT_IMAGE_PROXY_SECRET` must be set as an environment variable. + */ +export default defineNuxtModule({ + meta: { + name: 'image-proxy', + }, + setup() { + const nuxt = useNuxt() + + if (nuxt.options._prepare || process.env.NUXT_IMAGE_PROXY_SECRET) { + return + } + + const envPath = join(nuxt.options.rootDir, '.env') + const hasSecret = + existsSync(envPath) && /^NUXT_IMAGE_PROXY_SECRET=/m.test(readFileSync(envPath, 'utf-8')) + + if (!hasSecret) { + // eslint-disable-next-line no-console + console.info('Generating NUXT_IMAGE_PROXY_SECRET for development environment.') + const secret = randomUUID().replace(/-/g, '') + + nuxt.options.runtimeConfig.imageProxySecret = secret + appendFileSync( + envPath, + `# generated by image-proxy module\nNUXT_IMAGE_PROXY_SECRET=${secret}\n`, + ) + } + }, +}) diff --git a/nuxt.config.ts b/nuxt.config.ts index a59a409885..a51b06d6e3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -34,6 +34,7 @@ export default defineNuxtConfig({ runtimeConfig: { sessionPassword: '', + imageProxySecret: '', github: { orgToken: '', }, diff --git a/package.json b/package.json index b6898f73c7..37016c2e39 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "fast-npm-meta": "1.2.1", "focus-trap": "^8.0.0", "gray-matter": "4.0.3", + "ipaddr.js": "2.3.0", "marked": "17.0.3", "module-replacements": "2.11.0", "nuxt": "4.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d560fe3014..616edc8679 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,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.3 version: 17.0.3 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 0000000000..b058043131 --- /dev/null +++ b/server/api/registry/image-proxy/index.get.ts @@ -0,0 +1,236 @@ +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, + verifyImageUrl, +} 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 + +/** Maximum number of redirects to follow manually */ +const MAX_REDIRECTS = 5 + +/** HTTP status codes that indicate a redirect */ +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) + +/** + * 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&sig= + * + * The `sig` parameter is an HMAC-SHA256 signature of the URL, generated server-side + * during README rendering. This prevents the endpoint from being used as an open proxy. + * + * 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 + const sig = (Array.isArray(query.sig) ? query.sig[0] : query.sig) as string | undefined + + if (!url) { + throw createError({ + statusCode: 400, + message: 'Missing required "url" query parameter.', + }) + } + + if (!sig) { + throw createError({ + statusCode: 400, + message: 'Missing required "sig" query parameter.', + }) + } + + // Verify HMAC signature to ensure this URL was generated server-side + const { imageProxySecret } = useRuntimeConfig() + if (!imageProxySecret || !verifyImageUrl(url, sig, imageProxySecret)) { + throw createError({ + statusCode: 403, + message: 'Invalid signature.', + }) + } + + // 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 { + // Manually follow redirects so we can validate each hop before connecting. + // Using `redirect: 'follow'` would let fetch connect to internal IPs via redirects + // before we could validate them (TOCTOU issue). + let currentUrl = url + let response: Response | undefined + + for (let i = 0; i <= MAX_REDIRECTS; i++) { + response = await fetch(currentUrl, { + headers: { + 'User-Agent': 'npmx-image-proxy/1.0', + 'Accept': 'image/*', + }, + redirect: 'manual', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + + if (!REDIRECT_STATUSES.has(response.status)) { + break + } + + const location = response.headers.get('location') + if (!location) { + break + } + + // Resolve relative redirect URLs against the current URL + const redirectUrl = new URL(location, currentUrl).href + + // Validate the redirect target before following it + if (!isAllowedImageUrl(redirectUrl)) { + throw createError({ + statusCode: 400, + message: 'Redirect to disallowed URL.', + }) + } + + if (!(await resolveAndValidateHost(redirectUrl))) { + throw createError({ + statusCode: 400, + message: 'Redirect to disallowed URL.', + }) + } + + // Consume the redirect response body to free resources + await response.body?.cancel() + currentUrl = redirectUrl + } + + if (!response) { + throw createError({ + statusCode: 502, + message: 'Failed to fetch image.', + }) + } + + // Check if we exhausted the redirect limit + if (REDIRECT_STATUSES.has(response.status)) { + await response.body?.cancel() + throw createError({ + statusCode: 502, + message: 'Too many redirects.', + }) + } + + if (!response.ok) { + await response.body?.cancel() + 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