Skip to content

Commit cb354ce

Browse files
committed
chore: check urls
1 parent e6a53ca commit cb354ce

1 file changed

Lines changed: 193 additions & 192 deletions

File tree

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

Lines changed: 193 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -41,196 +41,197 @@ 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-
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-
}
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+
// }
236237
})

0 commit comments

Comments
 (0)