@@ -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