Skip to content

Commit 4f4d9a7

Browse files
committed
chore: check signature validation
1 parent 433494c commit 4f4d9a7

1 file changed

Lines changed: 176 additions & 174 deletions

File tree

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

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

0 commit comments

Comments
 (0)