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