Skip to content

Commit e6a53ca

Browse files
committed
fix: allow query for api image-proxy
1 parent dbbf9ff commit e6a53ca

2 files changed

Lines changed: 192 additions & 182 deletions

File tree

nuxt.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ export default defineNuxtConfig({
107107
allowQuery: ['color', 'labelColor', 'label', 'name', 'style'],
108108
},
109109
},
110+
'/api/registry/image-proxy/**': {
111+
isr: {
112+
expiration: 60 * 60 /* one hour */,
113+
passQuery: true,
114+
allowQuery: ['url', 'sig'],
115+
},
116+
},
110117
'/api/registry/downloads/**': {
111118
isr: {
112119
expiration: 60 * 60 /* one hour */,

server/api/registry/image-proxy/index.get.ts

Lines changed: 185 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -42,192 +42,195 @@ export default defineEventHandler(async event => {
4242
const sig = (Array.isArray(query.sig) ? query.sig[0] : query.sig) as string | undefined
4343

4444
if (!url) {
45-
return {eventPath: event.path, eventParams: event.context.params, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl}
45+
throw createError({
46+
statusCode: 400,
47+
message: 'Missing required "url" query parameter.',
48+
})
4649
}
4750

4851
if (!sig) {
49-
return { sig: sig || "none", origSig: query.sig, query}
52+
throw createError({
53+
statusCode: 400,
54+
message: 'Missing required "sig" query parameter.',
55+
})
5056
}
51-
return {"ok": true, url, sig, origSig: query.sig, query};
5257

5358
// Verify HMAC signature to ensure this URL was generated server-side
54-
// const { imageProxySecret } = useRuntimeConfig()
55-
// if (!imageProxySecret || !verifyImageUrl(url, sig, imageProxySecret)) {
56-
// // throw createError({
57-
// // statusCode: 403,
58-
// // message: 'Invalid signature.',
59-
// // })
60-
// return {url, sig, imageProxySecret}
61-
// }
62-
// return {"ok": true};
63-
64-
// // Validate URL syntactically
65-
// if (!isAllowedImageUrl(url)) {
66-
// throw createError({
67-
// statusCode: 400,
68-
// message: 'Invalid or disallowed image URL.',
69-
// })
70-
// }
71-
72-
// // Resolve hostname via DNS and validate the resolved IP is not private.
73-
// // This prevents DNS rebinding attacks where a hostname resolves to a private IP.
74-
// if (!(await resolveAndValidateHost(url))) {
75-
// throw createError({
76-
// statusCode: 400,
77-
// message: 'Invalid or disallowed image URL.',
78-
// })
79-
// }
80-
81-
// try {
82-
// // Manually follow redirects so we can validate each hop before connecting.
83-
// // Using `redirect: 'follow'` would let fetch connect to internal IPs via redirects
84-
// // before we could validate them (TOCTOU issue).
85-
// let currentUrl = url
86-
// let response: Response | undefined
87-
88-
// for (let i = 0; i <= MAX_REDIRECTS; i++) {
89-
// response = await fetch(currentUrl, {
90-
// headers: {
91-
// 'User-Agent': 'npmx-image-proxy/1.0',
92-
// 'Accept': 'image/*',
93-
// },
94-
// redirect: 'manual',
95-
// signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
96-
// })
97-
98-
// if (!REDIRECT_STATUSES.has(response.status)) {
99-
// break
100-
// }
101-
102-
// const location = response.headers.get('location')
103-
// if (!location) {
104-
// break
105-
// }
106-
107-
// // Resolve relative redirect URLs against the current URL
108-
// const redirectUrl = new URL(location, currentUrl).href
109-
110-
// // Validate the redirect target before following it
111-
// if (!isAllowedImageUrl(redirectUrl)) {
112-
// throw createError({
113-
// statusCode: 400,
114-
// message: 'Redirect to disallowed URL.',
115-
// })
116-
// }
117-
118-
// if (!(await resolveAndValidateHost(redirectUrl))) {
119-
// throw createError({
120-
// statusCode: 400,
121-
// message: 'Redirect to disallowed URL.',
122-
// })
123-
// }
124-
125-
// // Consume the redirect response body to free resources
126-
// await response.body?.cancel()
127-
// currentUrl = redirectUrl
128-
// }
129-
130-
// if (!response) {
131-
// throw createError({
132-
// statusCode: 502,
133-
// message: 'Failed to fetch image.',
134-
// })
135-
// }
136-
137-
// // Check if we exhausted the redirect limit
138-
// if (REDIRECT_STATUSES.has(response.status)) {
139-
// await response.body?.cancel()
140-
// throw createError({
141-
// statusCode: 502,
142-
// message: 'Too many redirects.',
143-
// })
144-
// }
145-
146-
// if (!response.ok) {
147-
// await response.body?.cancel()
148-
// throw createError({
149-
// statusCode: response.status === 404 ? 404 : 502,
150-
// message: `Failed to fetch image: ${response.status}`,
151-
// })
152-
// }
153-
154-
// const contentType = response.headers.get('content-type') || 'application/octet-stream'
155-
156-
// // Only allow raster/vector image content types, but block SVG to prevent
157-
// // embedded JavaScript execution (SVGs can contain <script> tags, event handlers, etc.)
158-
// if (!contentType.startsWith('image/') || contentType.includes('svg')) {
159-
// await response.body?.cancel()
160-
// throw createError({
161-
// statusCode: 400,
162-
// message: 'URL does not point to an allowed image type.',
163-
// })
164-
// }
165-
166-
// // Check Content-Length header if present (may be absent or dishonest)
167-
// const contentLength = response.headers.get('content-length')
168-
// if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
169-
// await response.body?.cancel()
170-
// throw createError({
171-
// statusCode: 413,
172-
// message: 'Image too large.',
173-
// })
174-
// }
175-
176-
// if (!response.body) {
177-
// throw createError({
178-
// statusCode: 502,
179-
// message: 'No response body from upstream.',
180-
// })
181-
// }
182-
183-
// // Do not forward upstream Content-Length since we may truncate the stream
184-
// // at MAX_SIZE, which would cause a mismatch with the declared length.
185-
// setResponseHeaders(event, {
186-
// 'Content-Type': contentType,
187-
// 'Cache-Control': `public, max-age=${CACHE_MAX_AGE_ONE_DAY}, s-maxage=${CACHE_MAX_AGE_ONE_DAY}`,
188-
// // Security headers - prevent content sniffing and restrict usage
189-
// 'X-Content-Type-Options': 'nosniff',
190-
// 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
191-
// })
192-
193-
// // Stream the response with a size limit to prevent memory exhaustion.
194-
// // Uses pipe-based backpressure so the upstream pauses when the consumer is slow.
195-
// let bytesRead = 0
196-
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
197-
// const upstream = Readable.fromWeb(response.body as any)
198-
// const limited = new Readable({
199-
// read() {
200-
// // Resume the upstream when the consumer is ready for more data
201-
// upstream.resume()
202-
// },
203-
// })
204-
205-
// upstream.on('data', (chunk: Buffer) => {
206-
// bytesRead += chunk.length
207-
// if (bytesRead > MAX_SIZE) {
208-
// upstream.destroy()
209-
// limited.destroy(new Error('Image too large'))
210-
// } else {
211-
// // Respect backpressure: if push() returns false, pause the upstream
212-
// // until the consumer calls read() again
213-
// if (!limited.push(chunk)) {
214-
// upstream.pause()
215-
// }
216-
// }
217-
// })
218-
// upstream.on('end', () => limited.push(null))
219-
// upstream.on('error', (err: Error) => limited.destroy(err))
220-
221-
// return sendStream(event, limited)
222-
// } catch (error: unknown) {
223-
// // Re-throw H3 errors
224-
// if (error && typeof error === 'object' && 'statusCode' in error) {
225-
// throw error
226-
// }
227-
228-
// throw createError({
229-
// statusCode: 502,
230-
// message: 'Failed to proxy image.',
231-
// })
232-
// }
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+
}
233236
})

0 commit comments

Comments
 (0)