1- import { createError , getQuery , setResponseHeaders } from 'h3'
1+ import { createError , getQuery , setResponseHeaders , sendStream } from 'h3'
2+ import { Readable } from 'node:stream'
23import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
34import { isAllowedImageUrl } from '#shared/utils/image-proxy'
45
@@ -15,105 +16,119 @@ import { isAllowedImageUrl } from '#shared/utils/image-proxy'
1516 *
1617 * Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
1718 */
18- export default defineCachedEventHandler (
19- async event => {
20- const query = getQuery ( event )
21- const url = query . url as string | undefined
19+ export default defineEventHandler ( async event => {
20+ const query = getQuery ( event )
21+ const rawUrl = query . url
22+ const url = ( Array . isArray ( rawUrl ) ? rawUrl [ 0 ] : rawUrl ) as string | undefined
2223
23- if ( ! url ) {
24+ if ( ! url ) {
25+ throw createError ( {
26+ statusCode : 400 ,
27+ message : 'Missing required "url" query parameter.' ,
28+ } )
29+ }
30+
31+ // Validate URL
32+ if ( ! isAllowedImageUrl ( url ) ) {
33+ throw createError ( {
34+ statusCode : 400 ,
35+ message : 'Invalid or disallowed image URL.' ,
36+ } )
37+ }
38+
39+ try {
40+ const response = await fetch ( url , {
41+ headers : {
42+ // Use a generic User-Agent to avoid leaking server info
43+ 'User-Agent' : 'npmx-image-proxy/1.0' ,
44+ 'Accept' : 'image/*' ,
45+ } ,
46+ redirect : 'follow' ,
47+ } )
48+
49+ // Validate final URL after any redirects to prevent SSRF bypass
50+ if ( response . url !== url && ! isAllowedImageUrl ( response . url ) ) {
2451 throw createError ( {
2552 statusCode : 400 ,
26- message : 'Missing required "url" query parameter .' ,
53+ message : 'Redirect to disallowed URL .' ,
2754 } )
2855 }
2956
30- // Validate URL
31- if ( ! isAllowedImageUrl ( url ) ) {
57+ if ( ! response . ok ) {
3258 throw createError ( {
33- statusCode : 400 ,
34- message : 'Invalid or disallowed image URL.' ,
59+ statusCode : response . status === 404 ? 404 : 502 ,
60+ message : `Failed to fetch image: ${ response . status } ` ,
3561 } )
3662 }
3763
38- try {
39- const response = await fetch ( url , {
40- headers : {
41- // Use a generic User-Agent to avoid leaking server info
42- 'User-Agent' : 'npmx-image-proxy/1.0' ,
43- 'Accept' : 'image/*' ,
44- } ,
45- // Prevent redirects to non-HTTP protocols
46- redirect : 'follow' ,
47- } )
48-
49- if ( ! response . ok ) {
50- throw createError ( {
51- statusCode : response . status === 404 ? 404 : 502 ,
52- message : `Failed to fetch image: ${ response . status } ` ,
53- } )
54- }
55-
56- const contentType = response . headers . get ( 'content-type' ) || 'application/octet-stream'
64+ const contentType = response . headers . get ( 'content-type' ) || 'application/octet-stream'
5765
58- // Only allow image content types
59- if (
60- ! contentType . startsWith ( 'image/' ) &&
61- ! contentType . startsWith ( 'application/octet-stream' )
62- ) {
63- throw createError ( {
64- statusCode : 400 ,
65- message : 'URL does not point to an image.' ,
66- } )
67- }
66+ // Only allow image content types
67+ if ( ! contentType . startsWith ( 'image/' ) ) {
68+ throw createError ( {
69+ statusCode : 400 ,
70+ message : 'URL does not point to an image.' ,
71+ } )
72+ }
6873
69- // Enforce a maximum size of 10 MB to prevent abuse
70- const contentLength = response . headers . get ( 'content-length' )
71- const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
72- if ( contentLength && Number . parseInt ( contentLength , 10 ) > MAX_SIZE ) {
73- throw createError ( {
74- statusCode : 413 ,
75- message : 'Image too large.' ,
76- } )
77- }
74+ // Check Content-Length header if present (may be absent or dishonest)
75+ const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
76+ const contentLength = response . headers . get ( 'content-length' )
77+ if ( contentLength && Number . parseInt ( contentLength , 10 ) > MAX_SIZE ) {
78+ throw createError ( {
79+ statusCode : 413 ,
80+ message : 'Image too large.' ,
81+ } )
82+ }
7883
79- const imageBuffer = await response . arrayBuffer ( )
84+ if ( ! response . body ) {
85+ throw createError ( {
86+ statusCode : 502 ,
87+ message : 'No response body from upstream.' ,
88+ } )
89+ }
8090
81- // Check actual size
82- if ( imageBuffer . byteLength > MAX_SIZE ) {
83- throw createError ( {
84- statusCode : 413 ,
85- message : 'Image too large. ' ,
86- } )
87- }
91+ setResponseHeaders ( event , {
92+ 'Content-Type' : contentType ,
93+ 'Cache-Control' : `public, max-age= ${ CACHE_MAX_AGE_ONE_DAY } , s-maxage= ${ CACHE_MAX_AGE_ONE_DAY } ` ,
94+ // Security headers - prevent content sniffing and restrict usage
95+ 'X-Content-Type-Options' : 'nosniff ' ,
96+ 'Content-Security-Policy' : "default-src 'none'; style-src 'unsafe-inline'" ,
97+ } )
8898
89- setResponseHeaders ( event , {
90- 'Content-Type' : contentType ,
91- 'Content-Length' : imageBuffer . byteLength . toString ( ) ,
92- 'Cache-Control' : `public, max-age=${ CACHE_MAX_AGE_ONE_DAY } , s-maxage=${ CACHE_MAX_AGE_ONE_DAY } ` ,
93- // Security headers - prevent content sniffing and restrict usage
94- 'X-Content-Type-Options' : 'nosniff' ,
95- 'Content-Security-Policy' : "default-src 'none'; style-src 'unsafe-inline'" ,
96- } )
99+ // Stream the response with a size limit to prevent memory exhaustion.
100+ // This avoids buffering the entire image into memory before sending.
101+ let bytesRead = 0
102+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103+ const upstream = Readable . fromWeb ( response . body as any )
104+ const limited = new Readable ( {
105+ read ( ) {
106+ /* pulling is driven by upstream push */
107+ } ,
108+ } )
97109
98- return Buffer . from ( imageBuffer )
99- } catch ( error : unknown ) {
100- // Re-throw H3 errors
101- if ( error && typeof error === 'object' && 'statusCode' in error ) {
102- throw error
110+ upstream . on ( 'data' , ( chunk : Buffer ) => {
111+ bytesRead += chunk . length
112+ if ( bytesRead > MAX_SIZE ) {
113+ upstream . destroy ( )
114+ limited . destroy ( new Error ( 'Image too large' ) )
115+ } else {
116+ limited . push ( chunk )
103117 }
118+ } )
119+ upstream . on ( 'end' , ( ) => limited . push ( null ) )
120+ upstream . on ( 'error' , ( err : Error ) => limited . destroy ( err ) )
104121
105- throw createError ( {
106- statusCode : 502 ,
107- message : 'Failed to proxy image.' ,
108- } )
122+ return sendStream ( event , limited )
123+ } catch ( error : unknown ) {
124+ // Re-throw H3 errors
125+ if ( error && typeof error === 'object' && 'statusCode' in error ) {
126+ throw error
109127 }
110- } ,
111- {
112- maxAge : CACHE_MAX_AGE_ONE_DAY ,
113- swr : true ,
114- getKey : event => {
115- const query = getQuery ( event )
116- return `image-proxy:${ query . url } `
117- } ,
118- } ,
119- )
128+
129+ throw createError ( {
130+ statusCode : 502 ,
131+ message : 'Failed to proxy image.' ,
132+ } )
133+ }
134+ } )
0 commit comments