@@ -30,20 +30,41 @@ const DownloadToWorkspaceFileResultSchema = z.object({
3030type DownloadToWorkspaceFileArgs = z . infer < typeof DownloadToWorkspaceFileArgsSchema >
3131type DownloadToWorkspaceFileResult = z . infer < typeof DownloadToWorkspaceFileResultSchema >
3232
33+ const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB
34+
35+ function isPrivateIPv4 ( a : number , b : number ) : boolean {
36+ if ( a === 0 || a === 127 || a === 10 ) return true
37+ if ( a === 172 && b >= 16 && b <= 31 ) return true
38+ if ( a === 192 && b === 168 ) return true
39+ if ( a === 169 && b === 254 ) return true // link-local + cloud metadata
40+ return false
41+ }
42+
3343function isPrivateUrl ( url : string ) : boolean {
3444 try {
3545 const { hostname, protocol } = new URL ( url )
3646 if ( protocol !== 'https:' && protocol !== 'http:' ) return true
37- if ( hostname === 'localhost' || hostname === '::1' ) return true
47+ if ( hostname === 'localhost' ) return true
48+
49+ // Plain IPv4
3850 const ipv4 = hostname . match ( / ^ ( \d + ) \. ( \d + ) \. ( \d + ) \. ( \d + ) $ / )
3951 if ( ipv4 ) {
40- const [ a , b ] = [ Number ( ipv4 [ 1 ] ) , Number ( ipv4 [ 2 ] ) ]
41- // Loopback, RFC 1918, link-local (including cloud metadata 169.254.169.254)
42- if ( a === 127 || a === 10 || a === 0 ) return true
43- if ( a === 172 && b >= 16 && b <= 31 ) return true
44- if ( a === 192 && b === 168 ) return true
45- if ( a === 169 && b === 254 ) return true
52+ return isPrivateIPv4 ( Number ( ipv4 [ 1 ] ) , Number ( ipv4 [ 2 ] ) )
4653 }
54+
55+ // IPv6: block loopback, link-local (fe80::/10), unique local (fc00::/7),
56+ // and IPv4-mapped (::ffff:a.b.c.d) that resolve to private IPv4
57+ if ( hostname . includes ( ':' ) ) {
58+ const h = hostname . toLowerCase ( )
59+ if ( h === '::1' ) return true
60+ if ( h . startsWith ( 'fe8' ) || h . startsWith ( 'fe9' ) || h . startsWith ( 'fea' ) || h . startsWith ( 'feb' ) )
61+ return true // fe80::/10 link-local
62+ if ( h . startsWith ( 'fc' ) || h . startsWith ( 'fd' ) ) return true // fc00::/7 unique local
63+ const mapped = h . match ( / ^ : : f f f f : ( \d + ) \. ( \d + ) \. ( \d + ) \. ( \d + ) $ / )
64+ if ( mapped ) return isPrivateIPv4 ( Number ( mapped [ 1 ] ) , Number ( mapped [ 2 ] ) )
65+ return false
66+ }
67+
4768 return false
4869 } catch {
4970 return true
@@ -166,13 +187,29 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool<
166187 signal : context . abortSignal ,
167188 } )
168189
190+ // Block SSRF via redirect (e.g. initial URL passes check but redirects to internal IP)
191+ if ( response . url && response . url !== params . url && isPrivateUrl ( response . url ) ) {
192+ return {
193+ success : false ,
194+ message : 'Downloading from private or internal URLs is not allowed' ,
195+ }
196+ }
197+
169198 if ( ! response . ok ) {
170199 return {
171200 success : false ,
172201 message : `Download failed with status ${ response . status } ${ response . statusText } ` ,
173202 }
174203 }
175204
205+ const contentLength = Number ( response . headers . get ( 'content-length' ) ?? Number . NaN )
206+ if ( ! Number . isNaN ( contentLength ) && contentLength > MAX_DOWNLOAD_BYTES ) {
207+ return {
208+ success : false ,
209+ message : `File too large (limit ${ MAX_DOWNLOAD_BYTES / 1024 / 1024 } MB)` ,
210+ }
211+ }
212+
176213 const mimeType = resolveMimeType (
177214 response . headers . get ( 'content-type' ) ,
178215 params . fileName ,
@@ -190,6 +227,13 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool<
190227 const arrayBuffer = await response . arrayBuffer ( )
191228 const fileBuffer = Buffer . from ( arrayBuffer )
192229
230+ if ( fileBuffer . length > MAX_DOWNLOAD_BYTES ) {
231+ return {
232+ success : false ,
233+ message : `File too large (limit ${ MAX_DOWNLOAD_BYTES / 1024 / 1024 } MB)` ,
234+ }
235+ }
236+
193237 if ( fileBuffer . length === 0 ) {
194238 return { success : false , message : 'Downloaded file is empty' }
195239 }
0 commit comments