@@ -42,195 +42,198 @@ 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- } )
45+ // throw createError({
46+ // statusCode: 400,
47+ // message: 'Missing required "url" query parameter.',
48+ // })
49+ return { place : 'url' , url, sig, reqUrl : event . node . req . url , reqOrigUrl : event . node . req . originalUrl }
4950 }
5051
5152 if ( ! sig ) {
52- throw createError ( {
53- statusCode : 400 ,
54- message : 'Missing required "sig" query parameter.' ,
55- } )
53+ // throw createError({
54+ // statusCode: 400,
55+ // message: 'Missing required "sig" query parameter.',
56+ // })
57+ return { place : 'sig' , url, sig, reqUrl : event . node . req . url , reqOrigUrl : event . node . req . originalUrl }
5658 }
5759
60+ return { place : 'both' , url, sig, reqUrl : event . node . req . url , reqOrigUrl : event . node . req . originalUrl }
5861 // 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- }
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+ // }
236239} )
0 commit comments