Skip to content

Commit 7833db6

Browse files
committed
fix: check more failing place
1 parent 9326096 commit 7833db6

1 file changed

Lines changed: 192 additions & 184 deletions

File tree

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

Lines changed: 192 additions & 184 deletions
Original file line numberDiff line numberDiff line change
@@ -42,198 +42,206 @@ 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+
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+
return {place: 'sig', url, sig, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl, imageProxySecret}
62+
// throw createError({
63+
// statusCode: 403,
64+
// message: 'Invalid signature.',
65+
// })
66+
}
67+
68+
// Validate URL syntactically
69+
if (!isAllowedImageUrl(url)) {
70+
return {place: 'isAllowedImageUrl', url, sig, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl}
4571
// throw createError({
4672
// statusCode: 400,
47-
// message: 'Missing required "url" query parameter.',
73+
// message: 'Invalid or disallowed image URL.',
4874
// })
49-
return {place: 'url', url, sig, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl}
5075
}
5176

52-
if (!sig) {
77+
// Resolve hostname via DNS and validate the resolved IP is not private.
78+
// This prevents DNS rebinding attacks where a hostname resolves to a private IP.
79+
if (!(await resolveAndValidateHost(url))) {
80+
return {place: 'resolveAndValidateHost', url, sig, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl}
5381
// throw createError({
5482
// statusCode: 400,
55-
// message: 'Missing required "sig" query parameter.',
83+
// message: 'Invalid or disallowed image URL.',
5684
// })
57-
return {place: 'sig', url, sig, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl}
5885
}
5986

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

0 commit comments

Comments
 (0)