Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/sim/app/api/files/presigned/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,9 @@ describe('/api/files/presigned', () => {

const response = await POST(request)
expect(response.status).toBe(200)
expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png')
expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png', {
allowArchives: true,
})
expect(mockValidateFileType).not.toHaveBeenCalled()
})

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/files/presigned/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}

const fileValidationError = validateAttachmentFileType(fileName)
const fileValidationError = validateAttachmentFileType(fileName, { allowArchives: true })
if (fileValidationError) {
throw new ValidationError(fileValidationError.message)
}
Expand Down
9 changes: 6 additions & 3 deletions apps/sim/app/api/files/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { StorageContext } from '@/lib/uploads/config'
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
import { isArchiveFileName, isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
import {
SUPPORTED_ATTACHMENT_EXTENSIONS,
SUPPORTED_IMAGE_EXTENSIONS,
Expand All @@ -34,9 +34,12 @@ import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils'

const ALLOWED_EXTENSIONS = new Set<string>(SUPPORTED_ATTACHMENT_EXTENSIONS)

function validateFileExtension(filename: string): boolean {
function validateFileExtension(filename: string, context: StorageContext): boolean {
const extension = filename.split('.').pop()?.toLowerCase()
if (!extension) return false
// Archives are only extractable in the mothership copilot flow; every other
// context keeps rejecting them up front instead of failing downstream.
if (context === 'mothership' && isArchiveFileName(filename)) return true
return ALLOWED_EXTENSIONS.has(extension)
}

Expand Down Expand Up @@ -150,7 +153,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
for (const file of files) {
const originalName = file.name || 'untitled.md'

if (!validateFileExtension(originalName)) {
if (!validateFileExtension(originalName, context)) {
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
throw new InvalidRequestError(
`File type '${extension}' is not allowed. Allowed types: ${Array.from(ALLOWED_EXTENSIONS).join(', ')}`
Expand Down
243 changes: 35 additions & 208 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Buffer, isUtf8 } from 'buffer'
import type { Readable } from 'stream'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
Expand All @@ -21,6 +20,12 @@ import {
ShareValidationError,
upsertFileShare,
} from '@/lib/public-shares/share-manager'
import {
ArchiveError,
type DecompressResult,
decompressArchiveBufferToWorkspaceFiles,
MAX_ARCHIVE_BYTES,
} from '@/lib/uploads/archive'
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
fetchWorkspaceFileBuffer,
Expand Down Expand Up @@ -203,102 +208,6 @@ const uniqueZipEntryName = (name: string, usedNames: Set<string>): string => {
return candidate
}

/** Input archive download cap for the decompress operation. */
const MAX_DECOMPRESS_ARCHIVE_BYTES = 100 * 1024 * 1024
/** Maximum number of entries extracted from a single archive. */
const MAX_DECOMPRESS_ENTRIES = 1000
/** Maximum uncompressed size for any single archive entry. */
const MAX_DECOMPRESS_ENTRY_BYTES = 100 * 1024 * 1024
/** Maximum total uncompressed size across all entries, to bound zip-bomb expansion. */
const MAX_DECOMPRESS_TOTAL_BYTES = 200 * 1024 * 1024

const S_IFMT = 0o170000
const S_IFLNK = 0o120000

/**
* Read a zip entry's declared uncompressed size without materializing it. This
* value comes straight from the (attacker-controlled) ZIP metadata, so it is only
* usable as a cheap fast-reject for honestly-declared archives — never as the
* authoritative cap. {@link inflateEntryWithinCaps} enforces the real limit on the
* inflated byte stream.
*/
const readEntryUncompressedSize = (entry: JSZip.JSZipObject): number | undefined => {
const data = (entry as JSZip.JSZipObject & { _data?: { uncompressedSize?: number } })._data
const size = data?.uncompressedSize
return typeof size === 'number' && Number.isFinite(size) ? size : undefined
}

type InflateResult = { ok: true; buffer: Buffer } | { ok: false; reason: 'entry' | 'total' }

/**
* Inflate a single zip entry through a streaming counting sink, tearing the
* stream down the moment cumulative output would exceed the per-entry cap or the
* remaining total budget. The declared uncompressed size in the ZIP header is
* attacker-controlled and is NOT trusted here: a forged-small or absent size
* cannot cause the full (potentially gigabyte-scale) entry to be materialized in
* memory, because enforcement happens on the actual inflated bytes as they
* arrive. Peak memory is bounded by the cap plus one DEFLATE chunk.
*/
const inflateEntryWithinCaps = (
entry: JSZip.JSZipObject,
remainingTotalBudget: number
): Promise<InflateResult> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let size = 0
let settled = false
const stream = entry.nodeStream() as Readable

const settle = (result: InflateResult) => {
if (settled) return
settled = true
stream.destroy()
resolve(result)
}

stream.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > MAX_DECOMPRESS_ENTRY_BYTES) {
settle({ ok: false, reason: 'entry' })
return
}
if (size > remainingTotalBudget) {
settle({ ok: false, reason: 'total' })
return
}
chunks.push(chunk)
})
stream.on('end', () => settle({ ok: true, buffer: Buffer.concat(chunks, size) }))
stream.on('error', (error) => {
if (settled) return
settled = true
stream.destroy()
reject(error)
})
})

/** True when a zip entry's unix mode marks it as a symlink (never extracted). */
const isSymlinkEntry = (entry: JSZip.JSZipObject): boolean => {
const mode = (entry as JSZip.JSZipObject & { unixPermissions?: number | null }).unixPermissions
return typeof mode === 'number' && (mode & S_IFMT) === S_IFLNK
}

/**
* Normalize a zip entry path into safe workspace folder segments, guarding against
* zip-slip. Returns null for traversal (`..`), so the entry is skipped rather than
* written outside its intended location.
*/
const sanitizeArchiveEntryPath = (rawPath: string): string[] | null => {
const segments = rawPath
.replace(/\\/g, '/')
.split('/')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0 && segment !== '.')

if (segments.length === 0 || segments.includes('..')) return null
return segments
}

const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0)

/**
Expand Down Expand Up @@ -896,135 +805,53 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (denied) return denied

const archiveBuffer = await downloadFileFromStorage(archive, requestId, logger, {
maxBytes: MAX_DECOMPRESS_ARCHIVE_BYTES,
maxBytes: MAX_ARCHIVE_BYTES,
})

let zip: JSZip
let result: DecompressResult
try {
zip = await JSZip.loadAsync(archiveBuffer)
} catch {
return NextResponse.json(
{ success: false, error: `"${archive.name}" is not a valid .zip archive` },
{ status: 400 }
)
}

const entries = Object.values(zip.files).filter(
(entry) => !entry.dir && !isSymlinkEntry(entry)
)
if (entries.length > MAX_DECOMPRESS_ENTRIES) {
return NextResponse.json(
{
success: false,
error: `Archive has too many entries to extract. Maximum is ${MAX_DECOMPRESS_ENTRIES}.`,
},
{ status: 413 }
)
}

const entryTooLargeResponse = (name: string) =>
NextResponse.json(
{
success: false,
error: `Archive entry "${name}" is too large to extract. Maximum is ${
MAX_DECOMPRESS_ENTRY_BYTES / (1024 * 1024)
} MB per file.`,
},
{ status: 413 }
)
const totalTooLargeResponse = () =>
NextResponse.json(
{
success: false,
error: `Archive expands to more than the ${
MAX_DECOMPRESS_TOTAL_BYTES / (1024 * 1024)
} MB extraction limit.`,
},
{ status: 413 }
)

// Resolve which entries are safe to extract first, so unsafe entries
// (skipped below) never count toward the size caps.
const safeEntries: Array<{ entry: JSZip.JSZipObject; segments: string[] }> = []
let skippedCount = 0
for (const entry of entries) {
const segments = sanitizeArchiveEntryPath(entry.name)
if (!segments) {
skippedCount += 1
logger.warn('Skipping unsafe archive entry', { name: entry.name })
continue
}
safeEntries.push({ entry, segments })
}

let declaredTotal = 0
for (const { entry } of safeEntries) {
const declaredSize = readEntryUncompressedSize(entry)
if (declaredSize === undefined) continue
if (declaredSize > MAX_DECOMPRESS_ENTRY_BYTES) return entryTooLargeResponse(entry.name)
declaredTotal += declaredSize
if (declaredTotal > MAX_DECOMPRESS_TOTAL_BYTES) return totalTooLargeResponse()
}

const pending: Array<{ segments: string[]; buffer: Buffer }> = []
let totalBytes = 0
for (const { entry, segments } of safeEntries) {
const result = await inflateEntryWithinCaps(
entry,
MAX_DECOMPRESS_TOTAL_BYTES - totalBytes
)
if (!result.ok) {
return result.reason === 'entry'
? entryTooLargeResponse(entry.name)
: totalTooLargeResponse()
result = await decompressArchiveBufferToWorkspaceFiles(archiveBuffer, {
workspaceId,
userId,
})
} catch (archiveError) {
if (archiveError instanceof ArchiveError) {
// The error message is single-sourced in ArchiveError (caps included);
// only the HTTP status is mapped here.
const status = archiveError.reason === 'invalid' ? 400 : 413
return NextResponse.json(
{ success: false, error: `"${archive.name}": ${archiveError.message}` },
{ status }
)
}
totalBytes += result.buffer.length
pending.push({ segments, buffer: result.buffer })
throw archiveError
}

if (pending.length === 0) {
if (result.extracted.length === 0) {
return NextResponse.json(
{
success: false,
error: `No files could be extracted from "${archive.name}".`,
},
{ success: false, error: `No files could be extracted from "${archive.name}".` },
{ status: 422 }
)
}

const folderIdCache = new Map<string, string | null>()
const extractedFiles: UserFile[] = []
for (const { segments, buffer } of pending) {
const leafName = segments[segments.length - 1]
const folderSegments = segments.slice(0, -1)
const folderKey = folderSegments.join('/')
let folderId = folderIdCache.get(folderKey)
if (folderId === undefined) {
folderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments: folderSegments,
})
folderIdCache.set(folderKey, folderId)
}
const extractedFiles = result.extracted.map((file) => ({
...file,
url: ensureAbsoluteUrl(file.url),
}))

const mimeType = getMimeTypeFromExtension(getFileExtension(leafName))
const uploaded = await uploadWorkspaceFile(
workspaceId,
userId,
buffer,
leafName,
mimeType,
{ folderId }
)
extractedFiles.push({ ...uploaded, url: ensureAbsoluteUrl(uploaded.url) })
if (result.skippedUnsafePaths.length > 0) {
logger.warn('Skipped unsafe archive entries', {
fileId: archive.id,
name: archive.name,
entryNames: result.skippedUnsafePaths,
})
}

logger.info('Archive decompressed', {
fileId: archive.id,
name: archive.name,
extractedCount: extractedFiles.length,
skippedCount,
skippedCount: result.skipped,
})

return NextResponse.json({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import {
AnimatedPlaceholderEffect,
Expand Down Expand Up @@ -597,7 +597,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
type='file'
onChange={handleFileChange}
className='hidden'
accept={CHAT_ACCEPT_ATTRIBUTE}
accept={MOTHERSHIP_ACCEPT_ATTRIBUTE}
multiple
/>

Expand Down
29 changes: 20 additions & 9 deletions apps/sim/lib/copilot/chat/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags'
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils'
import { stripVersionSuffix } from '@/tools/utils'

const logger = createLogger('CopilotChatPayload')
Expand Down Expand Up @@ -343,15 +344,25 @@ export async function buildCopilotRequestPayload(
} catch {
encodedUploadName = displayName
}
const lines = [
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${encodedUploadName}")`,
`To save permanently: materialize_file(fileName: "${displayName}")`,
]
if (displayName.endsWith('.json')) {
lines.push(
`To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
)
let lines: string[]
if (isArchiveFileName(displayName)) {
// A .zip is stored in uploads/ but its contents aren't readable until
// the agent extracts it once into workspace files/ (explicit step).
lines = [
`Archive "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
buildArchiveExtractGuidance(displayName),
]
} else {
lines = [
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${encodedUploadName}")`,
`To save permanently: materialize_file(fileName: "${displayName}")`,
]
if (displayName.endsWith('.json')) {
lines.push(
`To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
)
}
}
uploadContexts.push({
type: 'uploaded_file',
Expand Down
Loading
Loading