Skip to content

Commit 48f79a4

Browse files
committed
fix(providers): harden large-file path (SSRF fetch, ceiling gate, per-file UI limit)
- Download files for OpenAI/Gemini uploads via validateUrlWithDNS + IP-pinned fetch so a forged URL can't reach internal addresses (covers all callers). - Reject files above the provider ceiling before downloading/uploading. - UI now validates each file against the provider's per-file ceiling instead of summing all files against it, matching server-side per-file validation. - Lower Anthropic ceiling to 50MB (documented 32MB request cap / page limits).
1 parent 7978031 commit 48f79a4

3 files changed

Lines changed: 55 additions & 15 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -292,24 +292,19 @@ export function FileUpload({
292292
const files = e.target.files
293293
if (!files || files.length === 0) return
294294

295-
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
296-
const existingTotalSize = existingFiles.reduce((sum, file) => sum + file.size, 0)
297-
298295
const validFiles: File[] = []
299-
let totalNewSize = 0
300296
let sizeExceededFile: string | null = null
301297

302298
for (let i = 0; i < files.length; i++) {
303299
const file = files[i]
304-
if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) {
305-
const errorMessage = `Adding ${file.name} would exceed the maximum size limit of ${maxSizeLabel}`
300+
if (file.size > maxSizeInBytes) {
301+
const errorMessage = `${file.name} exceeds the maximum file size of ${maxSizeLabel}`
306302
logger.error(errorMessage, activeWorkflowId)
307303
if (!sizeExceededFile) {
308304
sizeExceededFile = errorMessage
309305
}
310306
} else {
311307
validFiles.push(file)
312-
totalNewSize += file.size
313308
}
314309
}
315310

apps/sim/providers/file-attachments.server.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@ import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { sleep } from '@sim/utils/helpers'
55
import 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'
611
import type { StorageContext } from '@/lib/uploads'
712
import { StorageService } from '@/lib/uploads'
813
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
914
import { verifyFileAccess } from '@/app/api/files/authorization'
1015
import type { UserFile } from '@/executor/types'
1116
import {
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

141184
async 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(
162206
async 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

apps/sim/providers/models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
657657
},
658658
anthropic: {
659659
id: 'anthropic',
660-
fileAttachment: { maxBytes: 100 * 1024 * 1024, strategy: 'remote-url' },
660+
fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' },
661661
name: 'Anthropic',
662662
description: "Anthropic's Claude models",
663663
defaultModel: 'claude-sonnet-4-6',

0 commit comments

Comments
 (0)