@@ -41,196 +41,197 @@ 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- 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- }
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+ // }
236237} )
0 commit comments