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