Skip to content

Commit f87d05d

Browse files
committed
fix(connectors): s3 streaming size cap for chunked responses without content-length
1 parent 597d408 commit f87d05d

1 file changed

Lines changed: 42 additions & 5 deletions

File tree

  • apps/sim/connectors/s3

apps/sim/connectors/s3/s3.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,36 @@ function buildListQueryString(params: Record<string, string>): string {
329329
.join('&')
330330
}
331331

332+
/**
333+
* Reads a response body as UTF-8 text while enforcing a hard byte cap. The
334+
* declared `content-length` header cannot be trusted as the sole guard:
335+
* S3-compatible stores (MinIO, Cloudflare R2) may use chunked transfer
336+
* encoding and omit the header entirely. Bytes are accumulated from the
337+
* stream and reading aborts as soon as the cap is exceeded, so an oversized
338+
* body is never fully buffered. Returns null when the cap is exceeded.
339+
*/
340+
async function readBodyWithLimit(response: Response, maxBytes: number): Promise<string | null> {
341+
if (!response.body) {
342+
const text = await response.text()
343+
return Buffer.byteLength(text) > maxBytes ? null : text
344+
}
345+
346+
const reader = response.body.getReader()
347+
const chunks: Uint8Array[] = []
348+
let total = 0
349+
while (true) {
350+
const { done, value } = await reader.read()
351+
if (done) break
352+
total += value.byteLength
353+
if (total > maxBytes) {
354+
await reader.cancel().catch(() => {})
355+
return null
356+
}
357+
chunks.push(value)
358+
}
359+
return Buffer.concat(chunks).toString('utf-8')
360+
}
361+
332362
/**
333363
* Decodes XML entities found in S3 response text values. `&amp;` is decoded
334364
* last so sequences like `&amp;lt;` resolve to `&lt;` rather than `<`.
@@ -626,21 +656,28 @@ export const s3Connector: ConnectorConfig = {
626656

627657
const etag = normalizeEtag(response.headers.get('etag') ?? '')
628658
const lastModified = response.headers.get('last-modified') ?? ''
629-
const contentLength = Number(response.headers.get('content-length') ?? '0')
659+
const declaredLength = Number(response.headers.get('content-length') ?? '')
630660

631-
if (contentLength > MAX_FILE_SIZE) {
632-
logger.warn('Skipping oversized S3 object', { key, size: contentLength })
661+
if (declaredLength > MAX_FILE_SIZE) {
662+
logger.warn('Skipping oversized S3 object', { key, size: declaredLength })
633663
return null
634664
}
635665

636-
const content = await response.text()
666+
const content = await readBodyWithLimit(response, MAX_FILE_SIZE)
667+
if (content === null) {
668+
logger.warn('Skipping oversized S3 object (size cap exceeded while streaming)', { key })
669+
return null
670+
}
637671
if (!content.trim()) return null
638672

639673
const entry: S3ObjectEntry = {
640674
key,
641675
etag,
642676
lastModified,
643-
size: Number.isNaN(contentLength) ? 0 : contentLength,
677+
size:
678+
Number.isNaN(declaredLength) || declaredLength <= 0
679+
? Buffer.byteLength(content)
680+
: declaredLength,
644681
}
645682
const stub = objectToStub(ctx, entry)
646683
return { ...stub, content, contentDeferred: false }

0 commit comments

Comments
 (0)