Skip to content

Commit c5f73a7

Browse files
committed
Fix SSRF bypass, IPv6 coverage, download size cap, and missing deps
- Validate post-redirect URL to block SSRF via open redirectors - Expand IPv6 private range blocking: fe80::/10, fc00::/7, ::ffff: mapped - Add 50 MB download cap (Content-Length pre-check + post-buffer check) - Add refetchOnWindowFocus: 'always' to useWorkspaceFileBinary - Add workspaceId to PptxPreview useEffect dependency array
1 parent cc214cd commit c5f73a7

File tree

3 files changed

+53
-8
lines changed

3 files changed

+53
-8
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ function PptxPreview({
610610
return () => {
611611
cancelled = true
612612
}
613-
}, [fileData, dataUpdatedAt, streamingContent, cacheKey])
613+
}, [fileData, dataUpdatedAt, streamingContent, cacheKey, workspaceId])
614614

615615
const error = fetchError
616616
? fetchError instanceof Error

apps/sim/hooks/queries/workspace-files.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key:
119119
queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal),
120120
enabled: !!workspaceId && !!fileId && !!key,
121121
staleTime: 30 * 1000,
122+
refetchOnWindowFocus: 'always',
122123
})
123124
}
124125

apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,41 @@ const DownloadToWorkspaceFileResultSchema = z.object({
3030
type DownloadToWorkspaceFileArgs = z.infer<typeof DownloadToWorkspaceFileArgsSchema>
3131
type 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+
3343
function 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(/^::ffff:(\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

Comments
 (0)