Skip to content

Commit 9326096

Browse files
committed
fix: check failing place
1 parent e403cdd commit 9326096

1 file changed

Lines changed: 188 additions & 185 deletions

File tree

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

Lines changed: 188 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -42,195 +42,198 @@ 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-
})
45+
// throw createError({
46+
// statusCode: 400,
47+
// message: 'Missing required "url" query parameter.',
48+
// })
49+
return {place: 'url', url, sig, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl}
4950
}
5051

5152
if (!sig) {
52-
throw createError({
53-
statusCode: 400,
54-
message: 'Missing required "sig" query parameter.',
55-
})
53+
// throw createError({
54+
// statusCode: 400,
55+
// message: 'Missing required "sig" query parameter.',
56+
// })
57+
return {place: 'sig', url, sig, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl}
5658
}
5759

60+
return {place: 'both', url, sig, reqUrl: event.node.req.url, reqOrigUrl: event.node.req.originalUrl}
5861
// 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-
}
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+
// }
236239
})

0 commit comments

Comments
 (0)