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 , resolveAndValidateHost } from '#server/utils/image-proxy'
4+ import {
5+ isAllowedImageUrl ,
6+ resolveAndValidateHost ,
7+ verifyImageUrl ,
8+ } from '#server/utils/image-proxy'
59
610/** Fetch timeout in milliseconds to prevent slow-drip resource exhaustion */
711const FETCH_TIMEOUT_MS = 15_000
812
913/** Maximum image size in bytes (10 MB) */
1014const MAX_SIZE = 10 * 1024 * 1024
1115
16+ /** Maximum number of redirects to follow manually */
17+ const MAX_REDIRECTS = 5
18+
19+ /** HTTP status codes that indicate a redirect */
20+ const REDIRECT_STATUSES = new Set ( [ 301 , 302 , 303 , 307 , 308 ] )
21+
1222/**
1323 * Image proxy endpoint to prevent privacy leaks from README images.
1424 *
@@ -18,14 +28,18 @@ const MAX_SIZE = 10 * 1024 * 1024
1828 *
1929 * Similar to GitHub's camo proxy: https://github.blog/2014-01-28-proxying-user-images/
2030 *
21- * Usage: /api/registry/image-proxy?url=https://example.com/image.png
31+ * Usage: /api/registry/image-proxy?url=https://example.com/image.png&sig=<hmac>
32+ *
33+ * The `sig` parameter is an HMAC-SHA256 signature of the URL, generated server-side
34+ * during README rendering. This prevents the endpoint from being used as an open proxy.
2235 *
2336 * Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
2437 */
2538export default defineEventHandler ( async event => {
2639 const query = getQuery ( event )
2740 const rawUrl = query . url
2841 const url = ( Array . isArray ( rawUrl ) ? rawUrl [ 0 ] : rawUrl ) as string | undefined
42+ const sig = ( Array . isArray ( query . sig ) ? query . sig [ 0 ] : query . sig ) as string | undefined
2943
3044 if ( ! url ) {
3145 throw createError ( {
@@ -34,6 +48,22 @@ export default defineEventHandler(async event => {
3448 } )
3549 }
3650
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+
3767 // Validate URL syntactically
3868 if ( ! isAllowedImageUrl ( url ) ) {
3969 throw createError ( {
@@ -52,33 +82,72 @@ export default defineEventHandler(async event => {
5282 }
5383
5484 try {
55- const response = await fetch ( url , {
56- headers : {
57- // Use a generic User-Agent to avoid leaking server info
58- 'User-Agent' : 'npmx-image-proxy/1.0' ,
59- 'Accept' : 'image/*' ,
60- } ,
61- redirect : 'follow' ,
62- signal : AbortSignal . timeout ( FETCH_TIMEOUT_MS ) ,
63- } )
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+ } )
64100
65- // Validate final URL after any redirects to prevent SSRF bypass
66- if ( response . url !== url && ! isAllowedImageUrl ( response . url ) ) {
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 ) {
67134 throw createError ( {
68- statusCode : 400 ,
69- message : 'Redirect to disallowed URL .' ,
135+ statusCode : 502 ,
136+ message : 'Failed to fetch image .' ,
70137 } )
71138 }
72139
73- // Also validate the resolved IP of the redirect target
74- if ( response . url !== url && ! ( await resolveAndValidateHost ( response . url ) ) ) {
140+ // Check if we exhausted the redirect limit
141+ if ( REDIRECT_STATUSES . has ( response . status ) ) {
142+ await response . body ?. cancel ( )
75143 throw createError ( {
76- statusCode : 400 ,
77- message : 'Redirect to disallowed URL .' ,
144+ statusCode : 502 ,
145+ message : 'Too many redirects .' ,
78146 } )
79147 }
80148
81149 if ( ! response . ok ) {
150+ await response . body ?. cancel ( )
82151 throw createError ( {
83152 statusCode : response . status === 404 ? 404 : 502 ,
84153 message : `Failed to fetch image: ${ response . status } ` ,
@@ -90,6 +159,7 @@ export default defineEventHandler(async event => {
90159 // Only allow raster/vector image content types, but block SVG to prevent
91160 // embedded JavaScript execution (SVGs can contain <script> tags, event handlers, etc.)
92161 if ( ! contentType . startsWith ( 'image/' ) || contentType . includes ( 'svg' ) ) {
162+ await response . body ?. cancel ( )
93163 throw createError ( {
94164 statusCode : 400 ,
95165 message : 'URL does not point to an allowed image type.' ,
@@ -99,6 +169,7 @@ export default defineEventHandler(async event => {
99169 // Check Content-Length header if present (may be absent or dishonest)
100170 const contentLength = response . headers . get ( 'content-length' )
101171 if ( contentLength && Number . parseInt ( contentLength , 10 ) > MAX_SIZE ) {
172+ await response . body ?. cancel ( )
102173 throw createError ( {
103174 statusCode : 413 ,
104175 message : 'Image too large.' ,
@@ -112,6 +183,8 @@ export default defineEventHandler(async event => {
112183 } )
113184 }
114185
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.
115188 setResponseHeaders ( event , {
116189 'Content-Type' : contentType ,
117190 'Cache-Control' : `public, max-age=${ CACHE_MAX_AGE_ONE_DAY } , s-maxage=${ CACHE_MAX_AGE_ONE_DAY } ` ,
0 commit comments