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