Skip to content

Commit a5fe55d

Browse files
liuxiaopai-airootautofix-ci[bot]danielroe
authored
fix: proxy external images in package readmes (#1143)
Co-authored-by: root <root@localhost.localdomain> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent f374326 commit a5fe55d

File tree

11 files changed

+871
-6
lines changed

11 files changed

+871
-6
lines changed

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
#secure password, can use openssl rand -hex 32
2-
NUXT_SESSION_PASSWORD=""
2+
NUXT_SESSION_PASSWORD=""
3+
4+
#HMAC secret for image proxy URL signing, can use openssl rand -hex 32
5+
NUXT_IMAGE_PROXY_SECRET=""

modules/image-proxy.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { defineNuxtModule, useNuxt } from 'nuxt/kit'
2+
import process from 'node:process'
3+
import { join } from 'node:path'
4+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
5+
import { randomUUID } from 'node:crypto'
6+
7+
/**
8+
* Auto-generates `NUXT_IMAGE_PROXY_SECRET` for local development if it is not
9+
* already set. The secret is used to HMAC-sign image proxy URLs so that only
10+
* server-generated URLs are accepted by the proxy endpoint.
11+
*
12+
* In production, `NUXT_IMAGE_PROXY_SECRET` must be set as an environment variable.
13+
*/
14+
export default defineNuxtModule({
15+
meta: {
16+
name: 'image-proxy',
17+
},
18+
setup() {
19+
const nuxt = useNuxt()
20+
21+
if (nuxt.options._prepare || process.env.NUXT_IMAGE_PROXY_SECRET) {
22+
return
23+
}
24+
25+
const envPath = join(nuxt.options.rootDir, '.env')
26+
const hasSecret =
27+
existsSync(envPath) && /^NUXT_IMAGE_PROXY_SECRET=/m.test(readFileSync(envPath, 'utf-8'))
28+
29+
if (!hasSecret) {
30+
// eslint-disable-next-line no-console
31+
console.info('Generating NUXT_IMAGE_PROXY_SECRET for development environment.')
32+
const secret = randomUUID().replace(/-/g, '')
33+
34+
nuxt.options.runtimeConfig.imageProxySecret = secret
35+
appendFileSync(
36+
envPath,
37+
`# generated by image-proxy module\nNUXT_IMAGE_PROXY_SECRET=${secret}\n`,
38+
)
39+
}
40+
},
41+
})

nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default defineNuxtConfig({
3434

3535
runtimeConfig: {
3636
sessionPassword: '',
37+
imageProxySecret: '',
3738
github: {
3839
orgToken: '',
3940
},

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"fast-npm-meta": "1.2.1",
8888
"focus-trap": "^8.0.0",
8989
"gray-matter": "4.0.3",
90+
"ipaddr.js": "2.3.0",
9091
"marked": "17.0.3",
9192
"module-replacements": "2.11.0",
9293
"nuxt": "4.3.1",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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

Comments
 (0)