|
| 1 | +import { createError, getQuery, setResponseHeaders, sendStream } from 'h3' |
| 2 | +import { Readable } from 'node:stream' |
| 3 | +import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' |
| 4 | +import { |
| 5 | + isAllowedImageUrl, |
| 6 | + resolveAndValidateHost, |
| 7 | + verifyImageUrl, |
| 8 | +} from '#server/utils/image-proxy' |
| 9 | + |
| 10 | +/** Fetch timeout in milliseconds to prevent slow-drip resource exhaustion */ |
| 11 | +const FETCH_TIMEOUT_MS = 15_000 |
| 12 | + |
| 13 | +/** Maximum image size in bytes (10 MB) */ |
| 14 | +const MAX_SIZE = 10 * 1024 * 1024 |
| 15 | + |
| 16 | +/** Maximum number of redirects to follow manually */ |
| 17 | +const MAX_REDIRECTS = 5 |
| 18 | + |
| 19 | +/** HTTP status codes that indicate a redirect */ |
| 20 | +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) |
| 21 | + |
| 22 | +/** |
| 23 | + * Image proxy endpoint to prevent privacy leaks from README images. |
| 24 | + * |
| 25 | + * Instead of letting the client's browser fetch images directly from third-party |
| 26 | + * servers (which exposes visitor IP, User-Agent, etc.), this endpoint fetches |
| 27 | + * images server-side and forwards them to the client. |
| 28 | + * |
| 29 | + * Similar to GitHub's camo proxy: https://github.blog/2014-01-28-proxying-user-images/ |
| 30 | + * |
| 31 | + * Usage: /api/registry/image-proxy?url=https://example.com/image.png&sig=<hmac> |
| 32 | + * |
| 33 | + * The `sig` parameter is an HMAC-SHA256 signature of the URL, generated server-side |
| 34 | + * during README rendering. This prevents the endpoint from being used as an open proxy. |
| 35 | + * |
| 36 | + * Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138 |
| 37 | + */ |
| 38 | +export default defineEventHandler(async event => { |
| 39 | + const query = getQuery(event) |
| 40 | + const rawUrl = query.url |
| 41 | + const url = (Array.isArray(rawUrl) ? rawUrl[0] : rawUrl) as string | undefined |
| 42 | + const sig = (Array.isArray(query.sig) ? query.sig[0] : query.sig) as string | undefined |
| 43 | + |
| 44 | + if (!url) { |
| 45 | + throw createError({ |
| 46 | + statusCode: 400, |
| 47 | + message: 'Missing required "url" query parameter.', |
| 48 | + }) |
| 49 | + } |
| 50 | + |
| 51 | + if (!sig) { |
| 52 | + throw createError({ |
| 53 | + statusCode: 400, |
| 54 | + message: 'Missing required "sig" query parameter.', |
| 55 | + }) |
| 56 | + } |
| 57 | + |
| 58 | + // Verify HMAC signature to ensure this URL was generated server-side |
| 59 | + const { imageProxySecret } = useRuntimeConfig() |
| 60 | + if (!imageProxySecret || !verifyImageUrl(url, sig, imageProxySecret)) { |
| 61 | + throw createError({ |
| 62 | + statusCode: 403, |
| 63 | + message: 'Invalid signature.', |
| 64 | + }) |
| 65 | + } |
| 66 | + |
| 67 | + // Validate URL syntactically |
| 68 | + if (!isAllowedImageUrl(url)) { |
| 69 | + throw createError({ |
| 70 | + statusCode: 400, |
| 71 | + message: 'Invalid or disallowed image URL.', |
| 72 | + }) |
| 73 | + } |
| 74 | + |
| 75 | + // Resolve hostname via DNS and validate the resolved IP is not private. |
| 76 | + // This prevents DNS rebinding attacks where a hostname resolves to a private IP. |
| 77 | + if (!(await resolveAndValidateHost(url))) { |
| 78 | + throw createError({ |
| 79 | + statusCode: 400, |
| 80 | + message: 'Invalid or disallowed image URL.', |
| 81 | + }) |
| 82 | + } |
| 83 | + |
| 84 | + try { |
| 85 | + // Manually follow redirects so we can validate each hop before connecting. |
| 86 | + // Using `redirect: 'follow'` would let fetch connect to internal IPs via redirects |
| 87 | + // before we could validate them (TOCTOU issue). |
| 88 | + let currentUrl = url |
| 89 | + let response: Response | undefined |
| 90 | + |
| 91 | + for (let i = 0; i <= MAX_REDIRECTS; i++) { |
| 92 | + response = await fetch(currentUrl, { |
| 93 | + headers: { |
| 94 | + 'User-Agent': 'npmx-image-proxy/1.0', |
| 95 | + 'Accept': 'image/*', |
| 96 | + }, |
| 97 | + redirect: 'manual', |
| 98 | + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), |
| 99 | + }) |
| 100 | + |
| 101 | + if (!REDIRECT_STATUSES.has(response.status)) { |
| 102 | + break |
| 103 | + } |
| 104 | + |
| 105 | + const location = response.headers.get('location') |
| 106 | + if (!location) { |
| 107 | + break |
| 108 | + } |
| 109 | + |
| 110 | + // Resolve relative redirect URLs against the current URL |
| 111 | + const redirectUrl = new URL(location, currentUrl).href |
| 112 | + |
| 113 | + // Validate the redirect target before following it |
| 114 | + if (!isAllowedImageUrl(redirectUrl)) { |
| 115 | + throw createError({ |
| 116 | + statusCode: 400, |
| 117 | + message: 'Redirect to disallowed URL.', |
| 118 | + }) |
| 119 | + } |
| 120 | + |
| 121 | + if (!(await resolveAndValidateHost(redirectUrl))) { |
| 122 | + throw createError({ |
| 123 | + statusCode: 400, |
| 124 | + message: 'Redirect to disallowed URL.', |
| 125 | + }) |
| 126 | + } |
| 127 | + |
| 128 | + // Consume the redirect response body to free resources |
| 129 | + await response.body?.cancel() |
| 130 | + currentUrl = redirectUrl |
| 131 | + } |
| 132 | + |
| 133 | + if (!response) { |
| 134 | + throw createError({ |
| 135 | + statusCode: 502, |
| 136 | + message: 'Failed to fetch image.', |
| 137 | + }) |
| 138 | + } |
| 139 | + |
| 140 | + // Check if we exhausted the redirect limit |
| 141 | + if (REDIRECT_STATUSES.has(response.status)) { |
| 142 | + await response.body?.cancel() |
| 143 | + throw createError({ |
| 144 | + statusCode: 502, |
| 145 | + message: 'Too many redirects.', |
| 146 | + }) |
| 147 | + } |
| 148 | + |
| 149 | + if (!response.ok) { |
| 150 | + await response.body?.cancel() |
| 151 | + throw createError({ |
| 152 | + statusCode: response.status === 404 ? 404 : 502, |
| 153 | + message: `Failed to fetch image: ${response.status}`, |
| 154 | + }) |
| 155 | + } |
| 156 | + |
| 157 | + const contentType = response.headers.get('content-type') || 'application/octet-stream' |
| 158 | + |
| 159 | + // Only allow raster/vector image content types, but block SVG to prevent |
| 160 | + // embedded JavaScript execution (SVGs can contain <script> tags, event handlers, etc.) |
| 161 | + if (!contentType.startsWith('image/') || contentType.includes('svg')) { |
| 162 | + await response.body?.cancel() |
| 163 | + throw createError({ |
| 164 | + statusCode: 400, |
| 165 | + message: 'URL does not point to an allowed image type.', |
| 166 | + }) |
| 167 | + } |
| 168 | + |
| 169 | + // Check Content-Length header if present (may be absent or dishonest) |
| 170 | + const contentLength = response.headers.get('content-length') |
| 171 | + if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) { |
| 172 | + await response.body?.cancel() |
| 173 | + throw createError({ |
| 174 | + statusCode: 413, |
| 175 | + message: 'Image too large.', |
| 176 | + }) |
| 177 | + } |
| 178 | + |
| 179 | + if (!response.body) { |
| 180 | + throw createError({ |
| 181 | + statusCode: 502, |
| 182 | + message: 'No response body from upstream.', |
| 183 | + }) |
| 184 | + } |
| 185 | + |
| 186 | + // Do not forward upstream Content-Length since we may truncate the stream |
| 187 | + // at MAX_SIZE, which would cause a mismatch with the declared length. |
| 188 | + setResponseHeaders(event, { |
| 189 | + 'Content-Type': contentType, |
| 190 | + 'Cache-Control': `public, max-age=${CACHE_MAX_AGE_ONE_DAY}, s-maxage=${CACHE_MAX_AGE_ONE_DAY}`, |
| 191 | + // Security headers - prevent content sniffing and restrict usage |
| 192 | + 'X-Content-Type-Options': 'nosniff', |
| 193 | + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'", |
| 194 | + }) |
| 195 | + |
| 196 | + // Stream the response with a size limit to prevent memory exhaustion. |
| 197 | + // Uses pipe-based backpressure so the upstream pauses when the consumer is slow. |
| 198 | + let bytesRead = 0 |
| 199 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 200 | + const upstream = Readable.fromWeb(response.body as any) |
| 201 | + const limited = new Readable({ |
| 202 | + read() { |
| 203 | + // Resume the upstream when the consumer is ready for more data |
| 204 | + upstream.resume() |
| 205 | + }, |
| 206 | + }) |
| 207 | + |
| 208 | + upstream.on('data', (chunk: Buffer) => { |
| 209 | + bytesRead += chunk.length |
| 210 | + if (bytesRead > MAX_SIZE) { |
| 211 | + upstream.destroy() |
| 212 | + limited.destroy(new Error('Image too large')) |
| 213 | + } else { |
| 214 | + // Respect backpressure: if push() returns false, pause the upstream |
| 215 | + // until the consumer calls read() again |
| 216 | + if (!limited.push(chunk)) { |
| 217 | + upstream.pause() |
| 218 | + } |
| 219 | + } |
| 220 | + }) |
| 221 | + upstream.on('end', () => limited.push(null)) |
| 222 | + upstream.on('error', (err: Error) => limited.destroy(err)) |
| 223 | + |
| 224 | + return sendStream(event, limited) |
| 225 | + } catch (error: unknown) { |
| 226 | + // Re-throw H3 errors |
| 227 | + if (error && typeof error === 'object' && 'statusCode' in error) { |
| 228 | + throw error |
| 229 | + } |
| 230 | + |
| 231 | + throw createError({ |
| 232 | + statusCode: 502, |
| 233 | + message: 'Failed to proxy image.', |
| 234 | + }) |
| 235 | + } |
| 236 | +}) |
0 commit comments