11import { createError , getQuery , setResponseHeaders , sendStream } from 'h3'
22import { Readable } from 'node:stream'
33import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
4- import { isAllowedImageUrl } from '#server/utils/image-proxy'
4+ import { isAllowedImageUrl , resolveAndValidateHost } from '#server/utils/image-proxy'
5+
6+ /** Fetch timeout in milliseconds to prevent slow-drip resource exhaustion */
7+ const FETCH_TIMEOUT_MS = 15_000
8+
9+ /** Maximum image size in bytes (10 MB) */
10+ const MAX_SIZE = 10 * 1024 * 1024
511
612/**
713 * Image proxy endpoint to prevent privacy leaks from README images.
@@ -28,14 +34,23 @@ export default defineEventHandler(async event => {
2834 } )
2935 }
3036
31- // Validate URL
37+ // Validate URL syntactically
3238 if ( ! isAllowedImageUrl ( url ) ) {
3339 throw createError ( {
3440 statusCode : 400 ,
3541 message : 'Invalid or disallowed image URL.' ,
3642 } )
3743 }
3844
45+ // Resolve hostname via DNS and validate the resolved IP is not private.
46+ // This prevents DNS rebinding attacks where a hostname resolves to a private IP.
47+ if ( ! ( await resolveAndValidateHost ( url ) ) ) {
48+ throw createError ( {
49+ statusCode : 400 ,
50+ message : 'Invalid or disallowed image URL.' ,
51+ } )
52+ }
53+
3954 try {
4055 const response = await fetch ( url , {
4156 headers : {
@@ -44,6 +59,7 @@ export default defineEventHandler(async event => {
4459 'Accept' : 'image/*' ,
4560 } ,
4661 redirect : 'follow' ,
62+ signal : AbortSignal . timeout ( FETCH_TIMEOUT_MS ) ,
4763 } )
4864
4965 // Validate final URL after any redirects to prevent SSRF bypass
@@ -54,6 +70,14 @@ export default defineEventHandler(async event => {
5470 } )
5571 }
5672
73+ // Also validate the resolved IP of the redirect target
74+ if ( response . url !== url && ! ( await resolveAndValidateHost ( response . url ) ) ) {
75+ throw createError ( {
76+ statusCode : 400 ,
77+ message : 'Redirect to disallowed URL.' ,
78+ } )
79+ }
80+
5781 if ( ! response . ok ) {
5882 throw createError ( {
5983 statusCode : response . status === 404 ? 404 : 502 ,
@@ -63,16 +87,16 @@ export default defineEventHandler(async event => {
6387
6488 const contentType = response . headers . get ( 'content-type' ) || 'application/octet-stream'
6589
66- // Only allow image content types
67- if ( ! contentType . startsWith ( 'image/' ) ) {
90+ // Only allow raster/vector image content types, but block SVG to prevent
91+ // embedded JavaScript execution (SVGs can contain <script> tags, event handlers, etc.)
92+ if ( ! contentType . startsWith ( 'image/' ) || contentType . includes ( 'svg' ) ) {
6893 throw createError ( {
6994 statusCode : 400 ,
70- message : 'URL does not point to an image.' ,
95+ message : 'URL does not point to an allowed image type .' ,
7196 } )
7297 }
7398
7499 // Check Content-Length header if present (may be absent or dishonest)
75- const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
76100 const contentLength = response . headers . get ( 'content-length' )
77101 if ( contentLength && Number . parseInt ( contentLength , 10 ) > MAX_SIZE ) {
78102 throw createError ( {
@@ -97,13 +121,14 @@ export default defineEventHandler(async event => {
97121 } )
98122
99123 // Stream the response with a size limit to prevent memory exhaustion.
100- // This avoids buffering the entire image into memory before sending .
124+ // Uses pipe-based backpressure so the upstream pauses when the consumer is slow .
101125 let bytesRead = 0
102126 // eslint-disable-next-line @typescript-eslint/no-explicit-any
103127 const upstream = Readable . fromWeb ( response . body as any )
104128 const limited = new Readable ( {
105129 read ( ) {
106- /* pulling is driven by upstream push */
130+ // Resume the upstream when the consumer is ready for more data
131+ upstream . resume ( )
107132 } ,
108133 } )
109134
@@ -113,7 +138,11 @@ export default defineEventHandler(async event => {
113138 upstream . destroy ( )
114139 limited . destroy ( new Error ( 'Image too large' ) )
115140 } else {
116- limited . push ( chunk )
141+ // Respect backpressure: if push() returns false, pause the upstream
142+ // until the consumer calls read() again
143+ if ( ! limited . push ( chunk ) ) {
144+ upstream . pause ( )
145+ }
117146 }
118147 } )
119148 upstream . on ( 'end' , ( ) => limited . push ( null ) )
0 commit comments