fix(ssh/sftp): cap remote file reads on received bytes, not stat() size (#6168)

The SFTP/SSH download routes buffered a remote file into memory with the
only size guard being sftp.stat().size — a value the caller-supplied SSH
server controls. A server that reports a tiny size and then streams
endlessly drove unbounded heap growth until OOM.

Read through the existing readNodeStreamToBufferWithLimit limiter, which
enforces the cap on bytes actually received and destroys the stream on
breach, via a small readSftpFileCapped wrapper in sftp/utils. All four
SFTP-reading tool routes now share it — download, download-file,
read-file-content, and the append path of write-file-content, which had
no cap at all.

The wrapper keeps a no-op error listener on the stream for its whole
life: ssh2 rejects still-pending SFTP requests when the channel closes,
which arrives as a late error event after the limiter has detached its
own handlers, and an error event with no listener would take the process
down. Too-large responses now return 413 to match how the rest of the
routes surface PayloadSizeLimitError, and responses report the actual
byte count rather than the server-reported stat size.
This commit is contained in:
Waleed
2026-08-01 16:53:46 -07:00
committed by GitHub
parent 18214158b9
commit 47e8f1eb5b
6 changed files with 178 additions and 80 deletions
+23 -17
View File
@@ -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'
@@ -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)
@@ -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 })
+72
View File
@@ -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
View File
@@ -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
@@ -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.
@@ -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')
@@ -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)
@@ -108,12 +99,12 @@ 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 {
@@ -121,6 +112,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
} 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(
@@ -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')
@@ -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(
@@ -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')
@@ -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
}
@@ -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(