@@ -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. `&` is decoded
334364 * last so sequences like `&lt;` resolve to `<` 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