Skip to content

Commit e403cdd

Browse files
committed
fix: allow query for api image-proxy
1 parent cb354ce commit e403cdd

2 files changed

Lines changed: 193 additions & 194 deletions

File tree

nuxt.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export default defineNuxtConfig({
107107
allowQuery: ['color', 'labelColor', 'label', 'name', 'style'],
108108
},
109109
},
110-
'/api/registry/image-proxy/**': {
110+
'/api/registry/image-proxy': {
111111
isr: {
112112
expiration: 60 * 60 /* one hour */,
113113
passQuery: true,

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

Lines changed: 192 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -41,197 +41,196 @@ export default defineEventHandler(async event => {
4141
const url = (Array.isArray(rawUrl) ? rawUrl[0] : rawUrl) as string | undefined
4242
const sig = (Array.isArray(query.sig) ? query.sig[0] : query.sig) as string | undefined
4343

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

0 commit comments

Comments
 (0)