@@ -3,12 +3,18 @@ import { createLogger } from '@sim/logger'
33import { getErrorMessage } from '@sim/utils/errors'
44import { sleep } from '@sim/utils/helpers'
55import OpenAI , { toFile } from 'openai'
6+ import {
7+ secureFetchWithPinnedIP ,
8+ validateUrlWithDNS ,
9+ } from '@/lib/core/security/input-validation.server'
10+ import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits'
611import type { StorageContext } from '@/lib/uploads'
712import { StorageService } from '@/lib/uploads'
813import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
914import { verifyFileAccess } from '@/app/api/files/authorization'
1015import type { UserFile } from '@/executor/types'
1116import {
17+ getProviderAttachmentMaxBytes ,
1218 getProviderFileStrategy ,
1319 inferAttachmentMimeType ,
1420 shouldUseLargeFilePath ,
@@ -54,6 +60,16 @@ export async function attachLargeFileRemoteUrls(
5460
5561 for ( const file of files ) {
5662 if ( ! file . key || ! shouldUseLargeFilePath ( file , providerId ) ) continue
63+
64+ const maxBytes = getProviderAttachmentMaxBytes ( providerId )
65+ if ( Number . isFinite ( file . size ) && file . size > maxBytes ) {
66+ const sizeMB = ( file . size / ( 1024 * 1024 ) ) . toFixed ( 2 )
67+ const maxMB = ( maxBytes / ( 1024 * 1024 ) ) . toFixed ( 0 )
68+ throw new Error (
69+ `File "${ file . name } " (${ sizeMB } MB) exceeds the ${ maxMB } MB agent attachment limit for provider "${ providerId } "`
70+ )
71+ }
72+
5773 if ( ! StorageService . hasCloudStorage ( ) ) {
5874 logger . warn (
5975 `[${ ctx . requestId } ] "${ file . name } " exceeds the inline limit for "${ providerId } " but cloud storage is unavailable; it cannot be sent`
@@ -95,15 +111,16 @@ export async function uploadLargeFilesToProvider(
95111 const groups = groupUploadableFiles ( request . messages )
96112 if ( groups . length === 0 ) return
97113
114+ const maxBytes = getProviderAttachmentMaxBytes ( providerId )
98115 const openai = providerId === 'openai' ? new OpenAI ( { apiKey : request . apiKey } ) : null
99116 const ai = providerId === 'google' ? new GoogleGenAI ( { apiKey : request . apiKey } ) : null
100117
101118 for ( const group of groups ) {
102119 const [ representative ] = group
103120 if ( openai ) {
104- await uploadOpenAIFile ( representative , openai , request . abortSignal )
121+ await uploadOpenAIFile ( representative , openai , maxBytes , request . abortSignal )
105122 } else if ( ai ) {
106- await uploadGeminiFile ( representative , ai , request . abortSignal )
123+ await uploadGeminiFile ( representative , ai , maxBytes , request . abortSignal )
107124 }
108125 for ( const file of group ) {
109126 file . providerFileId = representative . providerFileId
@@ -130,21 +147,48 @@ function groupUploadableFiles(messages: Message[] | undefined): UserFile[][] {
130147 return [ ...groups . values ( ) ]
131148}
132149
133- async function fetchRemoteFileBlob ( file : UserFile , signal ?: AbortSignal ) : Promise < Blob > {
134- const response = await fetch ( file . remoteUrl as string , { signal } )
150+ /**
151+ * Downloads the file from its signed URL with DNS validation and IP pinning so a URL that
152+ * somehow resolves to an internal address can never be fetched (SSRF defense for every
153+ * caller, not just the agent path). Bounded by the provider's attachment ceiling.
154+ */
155+ async function fetchRemoteFileBlob (
156+ file : UserFile ,
157+ maxBytes : number ,
158+ signal ?: AbortSignal
159+ ) : Promise < Blob > {
160+ const url = file . remoteUrl as string
161+ const validation = await validateUrlWithDNS ( url , 'fileUrl' )
162+ if ( ! validation . isValid || ! validation . resolvedIP ) {
163+ throw new Error (
164+ `Cannot download "${ file . name } " for upload: ${ validation . error || 'invalid URL' } `
165+ )
166+ }
167+
168+ const response = await secureFetchWithPinnedIP ( url , validation . resolvedIP , {
169+ maxResponseBytes : maxBytes ,
170+ signal,
171+ } )
135172 if ( ! response . ok ) {
136173 throw new Error ( `Failed to download "${ file . name } " for upload (status ${ response . status } )` )
137174 }
138- return response . blob ( )
175+
176+ const buffer = await readResponseToBufferWithLimit ( response , {
177+ maxBytes,
178+ label : 'provider file upload' ,
179+ signal,
180+ } )
181+ return new Blob ( [ buffer ] , { type : file . type || inferAttachmentMimeType ( file ) } )
139182}
140183
141184async function uploadOpenAIFile (
142185 file : UserFile ,
143186 client : OpenAI ,
187+ maxBytes : number ,
144188 signal ?: AbortSignal
145189) : Promise < void > {
146190 const mimeType = inferAttachmentMimeType ( file )
147- const blob = await fetchRemoteFileBlob ( file , signal )
191+ const blob = await fetchRemoteFileBlob ( file , maxBytes , signal )
148192
149193 const uploaded = await client . files . create (
150194 {
@@ -162,10 +206,11 @@ async function uploadOpenAIFile(
162206async function uploadGeminiFile (
163207 file : UserFile ,
164208 ai : GoogleGenAI ,
209+ maxBytes : number ,
165210 signal ?: AbortSignal
166211) : Promise < void > {
167212 const mimeType = inferAttachmentMimeType ( file )
168- const blob = await fetchRemoteFileBlob ( file , signal )
213+ const blob = await fetchRemoteFileBlob ( file , maxBytes , signal )
169214
170215 let uploaded = await ai . files . upload ( { file : blob , config : { mimeType, abortSignal : signal } } )
171216
0 commit comments