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
40 changes: 23 additions & 17 deletions apps/sim/app/api/tools/sftp/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ import { sftpDownloadContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { createSftpConnection, getSftp, isPathSafe, sanitizePath } from '@/app/api/tools/sftp/utils'
import {
createSftpConnection,
getSftp,
isPathSafe,
MAX_SFTP_READ_BYTES,
readSftpFileCapped,
sanitizePath,
} from '@/app/api/tools/sftp/utils'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -73,30 +81,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
})

const maxSize = 50 * 1024 * 1024
if (stats.size > maxSize) {
if (stats.size > MAX_SFTP_READ_BYTES) {
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
return NextResponse.json(
{ success: false, error: `File size (${sizeMB}MB) exceeds download limit of 50MB` },
{ status: 400 }
{ status: 413 }
)
}

logger.info(`[${requestId}] Downloading file ${remotePath} (${stats.size} bytes)`)

const chunks: Buffer[] = []
await new Promise<void>((resolve, reject) => {
const readStream = sftp.createReadStream(remotePath)

readStream.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})

readStream.on('end', () => resolve())
readStream.on('error', reject)
})

const buffer = Buffer.concat(chunks)
const buffer = await readSftpFileCapped(
sftp,
remotePath,
MAX_SFTP_READ_BYTES,
'SFTP download'
)
const fileName = path.basename(remotePath)
const extension = getFileExtension(fileName)
const mimeType = getMimeTypeFromExtension(extension)
Expand Down Expand Up @@ -129,6 +129,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error occurred')

if (isPayloadSizeLimitError(error)) {
logger.warn(`[${requestId}] SFTP download aborted: ${errorMessage}`)
return NextResponse.json({ success: false, error: errorMessage }, { status: 413 })
}

logger.error(`[${requestId}] SFTP download failed:`, error)

return NextResponse.json({ error: `SFTP download failed: ${errorMessage}` }, { status: 500 })
Expand Down
72 changes: 72 additions & 0 deletions apps/sim/app/api/tools/sftp/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @vitest-environment node
*/
import { Readable } from 'stream'
import type { SFTPWrapper } from 'ssh2'
import { describe, expect, it, vi } from 'vitest'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { MAX_SFTP_READ_BYTES, readSftpFileCapped } from '@/app/api/tools/sftp/utils'

/**
* Builds a fake SFTP wrapper whose read stream emits `chunkCount` chunks of
* `chunkSize` bytes — the shape of a malicious server that understates the
* file size in its stat reply and then streams unbounded data.
*/
function fakeSftp(chunkSize: number, chunkCount: number) {
let emitted = 0
const stream = new Readable({
read() {
if (emitted >= chunkCount) {
this.push(null)
return
}
emitted++
this.push(Buffer.alloc(chunkSize, 0x41))
},
})
const createReadStream = vi.fn(() => stream)
return { sftp: { createReadStream } as unknown as SFTPWrapper, stream, createReadStream }
}

describe('readSftpFileCapped', () => {
it('resolves with the full contents when under the cap', async () => {
const { sftp, createReadStream } = fakeSftp(4, 3)

const buffer = await readSftpFileCapped(sftp, '/file', 1024, 'file')

expect(buffer.toString()).toBe('A'.repeat(12))
expect(createReadStream).toHaveBeenCalledWith('/file')
})

it('rejects and destroys the stream once received bytes exceed the cap', async () => {
const { sftp, stream } = fakeSftp(8, 1_000_000)

await expect(readSftpFileCapped(sftp, '/bomb', 16, 'file')).rejects.toSatisfy(
isPayloadSizeLimitError
)
expect(stream.destroyed).toBe(true)
})

it('enforces the cap on actual bytes even when the file was reported as tiny', async () => {
const { sftp, stream } = fakeSftp(1024, 1_000_000)

await expect(readSftpFileCapped(sftp, '/bomb', 4096, 'file')).rejects.toThrow(
/exceeds maximum size of 4096 bytes/
)
expect(stream.destroyed).toBe(true)
})

it('survives the late stream error ssh2 emits when the channel closes after an abort', async () => {
const { sftp, stream } = fakeSftp(8, 1_000_000)

await expect(readSftpFileCapped(sftp, '/bomb', 16, 'file')).rejects.toSatisfy(
isPayloadSizeLimitError
)

expect(() => stream.emit('error', new Error('No response from server'))).not.toThrow()
})

it('caps remote reads at 50MB', () => {
expect(MAX_SFTP_READ_BYTES).toBe(50 * 1024 * 1024)
})
})
31 changes: 31 additions & 0 deletions apps/sim/app/api/tools/sftp/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { toError } from '@sim/utils/errors'
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import { readNodeStreamToBufferWithLimit } from '@/lib/core/utils/stream-limits'

const S_IFMT = 0o170000
const S_IFDIR = 0o040000
Expand Down Expand Up @@ -172,6 +173,36 @@ export function getSftp(client: Client): Promise<SFTPWrapper> {
})
}

/** Maximum bytes a route will buffer from a remote SFTP file. */
export const MAX_SFTP_READ_BYTES = 50 * 1024 * 1024

/**
* Reads a remote file into memory, enforcing the cap on the bytes actually
* received rather than on the `stat()` size the remote server reports.
* A caller-supplied SSH server can understate the size in its `SSH_FXP_STAT`
* reply and then stream unbounded data, so the stream is destroyed as soon as
* the running total exceeds `maxBytes`. Rejects with a `PayloadSizeLimitError`.
*/
export function readSftpFileCapped(
sftp: SFTPWrapper,
remotePath: string,
maxBytes: number,
label: string
): Promise<Buffer> {
const stream = sftp.createReadStream(remotePath)

/**
* Closing the SSH client rejects every still-pending SFTP request with
* "No response from server", which lands as a late `error` on a stream the
* limiter has already detached from once it destroyed it. An `error` event
* with no listener is an uncaught exception, so keep one attached for the
* stream's whole life; the limiter's own handler still settles the promise.
*/
stream.on('error', () => {})

return readNodeStreamToBufferWithLimit(stream, { maxBytes, label })
}

/**
* Sanitizes a remote path to prevent path traversal attacks.
* Removes null bytes, normalizes path separators, and collapses traversal sequences.
Expand Down
39 changes: 18 additions & 21 deletions apps/sim/app/api/tools/ssh/download-file/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import type { Client, SFTPWrapper } from 'ssh2'
import { sshDownloadFileContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { MAX_SFTP_READ_BYTES, readSftpFileCapped } from '@/app/api/tools/sftp/utils'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'

const logger = createLogger('SSHDownloadFileAPI')
Expand Down Expand Up @@ -67,31 +69,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
})

// Check file size limit (50MB to prevent memory exhaustion)
const maxSize = 50 * 1024 * 1024
if (stats.size > maxSize) {
if (stats.size > MAX_SFTP_READ_BYTES) {
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
return NextResponse.json(
{ error: `File size (${sizeMB}MB) exceeds download limit of 50MB` },
{ status: 400 }
{ status: 413 }
)
}

// Read file content
const content = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
const readStream = sftp.createReadStream(remotePath)

readStream.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})

readStream.on('end', () => {
resolve(Buffer.concat(chunks))
})

readStream.on('error', reject)
})
const content = await readSftpFileCapped(
sftp,
remotePath,
MAX_SFTP_READ_BYTES,
'SSH file download'
)

const fileName = path.basename(remotePath)
const extension = getFileExtension(fileName)
Expand All @@ -108,19 +99,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
name: fileName,
mimeType,
data: base64Content,
size: stats.size,
size: content.length,
},
content: base64Content,
fileName: fileName,
remotePath: remotePath,
size: stats.size,
size: content.length,
message: `File downloaded successfully from ${remotePath}`,
})
} finally {
client.end()
}
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error occurred')

if (isPayloadSizeLimitError(error)) {
logger.warn(`[${requestId}] SSH file download aborted: ${errorMessage}`)
return NextResponse.json({ error: errorMessage }, { status: 413 })
}

logger.error(`[${requestId}] SSH file download failed:`, error)

return NextResponse.json(
Expand Down
40 changes: 14 additions & 26 deletions apps/sim/app/api/tools/ssh/read-file-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type { Client, SFTPWrapper } from 'ssh2'
import { sshReadFileContentContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { readSftpFileCapped } from '@/app/api/tools/sftp/utils'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'

const logger = createLogger('SSHReadFileContentAPI')
Expand Down Expand Up @@ -68,51 +70,37 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (stats.size > maxBytes) {
return NextResponse.json(
{ error: `File size (${stats.size} bytes) exceeds maximum allowed (${maxBytes} bytes)` },
{ status: 400 }
{ status: 413 }
)
}

const content = await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = []
let totalBytes = 0
const readStream = sftp.createReadStream(filePath)

readStream.on('data', (chunk: Buffer) => {
totalBytes += chunk.length
if (totalBytes > maxBytes) {
readStream.destroy()
reject(new Error(`File exceeds maximum allowed size of ${params.maxSize}MB`))
return
}
chunks.push(chunk)
})

readStream.on('end', () => {
const buffer = Buffer.concat(chunks)
resolve(buffer.toString(params.encoding as BufferEncoding))
})

readStream.on('error', reject)
})
const buffer = await readSftpFileCapped(sftp, filePath, maxBytes, `File '${filePath}'`)
const content = buffer.toString(params.encoding as BufferEncoding)

const lines = content.split('\n').length

logger.info(
`[${requestId}] File content read successfully: ${stats.size} bytes, ${lines} lines`
`[${requestId}] File content read successfully: ${buffer.length} bytes, ${lines} lines`
)

return NextResponse.json({
content,
size: stats.size,
size: buffer.length,
lines,
path: filePath,
message: `File read successfully: ${stats.size} bytes, ${lines} lines`,
message: `File read successfully: ${buffer.length} bytes, ${lines} lines`,
})
} finally {
client.end()
}
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error occurred')

if (isPayloadSizeLimitError(error)) {
logger.warn(`[${requestId}] SSH read file content aborted: ${errorMessage}`)
return NextResponse.json({ error: errorMessage }, { status: 413 })
}

logger.error(`[${requestId}] SSH read file content failed:`, error)

return NextResponse.json(
Expand Down
36 changes: 20 additions & 16 deletions apps/sim/app/api/tools/ssh/write-file-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type { Client, SFTPWrapper } from 'ssh2'
import { sshWriteFileContentContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { MAX_SFTP_READ_BYTES, readSftpFileCapped } from '@/app/api/tools/sftp/utils'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'

const logger = createLogger('SSHWriteFileContentAPI')
Expand Down Expand Up @@ -73,22 +75,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// Handle append mode by reading existing content first
let finalContent = params.content
if (params.mode === 'append') {
const existingContent = await new Promise<string>((resolve) => {
const chunks: Buffer[] = []
const readStream = sftp.createReadStream(filePath)

readStream.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})

readStream.on('end', () => {
resolve(Buffer.concat(chunks).toString('utf-8'))
})

readStream.on('error', () => {
resolve('')
})
})
let existingContent = ''
try {
const existing = await readSftpFileCapped(
sftp,
filePath,
MAX_SFTP_READ_BYTES,
`Existing file '${filePath}'`
)
existingContent = existing.toString('utf-8')
} catch (error) {
if (isPayloadSizeLimitError(error)) throw error
}
finalContent = existingContent + params.content
}

Expand Down Expand Up @@ -124,6 +122,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error occurred')

if (isPayloadSizeLimitError(error)) {
logger.warn(`[${requestId}] SSH write file content aborted: ${errorMessage}`)
return NextResponse.json({ error: errorMessage }, { status: 413 })
}

logger.error(`[${requestId}] SSH write file content failed:`, error)

return NextResponse.json(
Expand Down
Loading