@@ -2,6 +2,7 @@ import crypto from 'crypto';
22import fs from 'fs/promises' ;
33import os from 'os' ;
44import path from 'path' ;
5+ import { Readable } from 'stream' ;
56import { promisify } from 'util' ;
67import zlib from 'zlib' ;
78
@@ -43,6 +44,7 @@ interface RestoreEvidenceOptions {
4344 timestamp ?: string ;
4445 smokeChecksPassed ?: boolean ;
4546 s3Client ?: Pick < S3Client , 'send' > ;
47+ extractionLimits ?: Partial < ArchiveExtractionLimits > ;
4648}
4749
4850interface RestoreEvidenceReport {
@@ -71,6 +73,32 @@ interface RestoreEvidenceResult {
7173}
7274
7375const TAR_BLOCK_SIZE = 512 ;
76+ const MIB = 1024 * 1024 ;
77+ const SAFE_ARCHIVE_FILENAME = / ^ [ A - Z a - z 0 - 9 ] [ A - Z a - z 0 - 9 . _ - ] { 0 , 99 } $ / ;
78+
79+ export interface ArchiveExtractionLimits {
80+ maxCompressedArchiveBytes : number ;
81+ maxInflatedArchiveBytes : number ;
82+ maxMembers : number ;
83+ maxMemberBytes : number ;
84+ maxAggregatePayloadBytes : number ;
85+ }
86+
87+ export const DEFAULT_ARCHIVE_EXTRACTION_LIMITS : Readonly < ArchiveExtractionLimits > = Object . freeze ( {
88+ maxCompressedArchiveBytes : 128 * MIB ,
89+ maxInflatedArchiveBytes : 256 * MIB ,
90+ maxMembers : 128 ,
91+ maxMemberBytes : 64 * MIB ,
92+ maxAggregatePayloadBytes : 192 * MIB ,
93+ } ) ;
94+
95+ class ArchiveBoundaryError extends Error {
96+ constructor ( message : string ) {
97+ super ( message ) ;
98+ this . name = 'ArchiveBoundaryError' ;
99+ }
100+ }
101+
74102const gzip = promisify ( zlib . gzip ) ;
75103const gunzip = promisify ( zlib . gunzip ) ;
76104const RESTORE_SMOKE_CHECKS = [
@@ -126,6 +154,52 @@ function parseS3Uri(uri: string): { bucket: string; key: string } | null {
126154 return { bucket : match [ 1 ] , key : match [ 2 ] } ;
127155}
128156
157+ function resolveArchiveExtractionLimits (
158+ overrides ?: Partial < ArchiveExtractionLimits > ,
159+ ) : ArchiveExtractionLimits {
160+ const resolved = { ...DEFAULT_ARCHIVE_EXTRACTION_LIMITS } ;
161+
162+ for ( const [ key , value ] of Object . entries ( overrides || { } ) as Array < [ keyof ArchiveExtractionLimits , unknown ] > ) {
163+ if ( value === undefined ) continue ;
164+ if (
165+ ! Number . isSafeInteger ( value )
166+ || Number ( value ) <= 0
167+ || Number ( value ) > DEFAULT_ARCHIVE_EXTRACTION_LIMITS [ key ]
168+ ) {
169+ throw new ArchiveBoundaryError (
170+ `Archive extraction limit ${ key } must be at or below the named default` ,
171+ ) ;
172+ }
173+ resolved [ key ] = Number ( value ) ;
174+ }
175+
176+ return resolved ;
177+ }
178+
179+ async function readBoundedChunks (
180+ chunks : AsyncIterable < Uint8Array > ,
181+ maxBytes : number ,
182+ ) : Promise < Buffer > {
183+ const collected : Buffer [ ] = [ ] ;
184+ let size = 0 ;
185+
186+ try {
187+ for await ( const chunk of chunks ) {
188+ const bytes = Buffer . isBuffer ( chunk ) ? chunk : Buffer . from ( chunk ) ;
189+ size += bytes . length ;
190+ if ( size > maxBytes ) {
191+ throw new ArchiveBoundaryError ( 'Archive exceeds the compressed-size limit' ) ;
192+ }
193+ collected . push ( bytes ) ;
194+ }
195+ } catch ( error ) {
196+ if ( error instanceof ArchiveBoundaryError ) throw error ;
197+ throw new ArchiveBoundaryError ( 'Archive could not be read' ) ;
198+ }
199+
200+ return Buffer . concat ( collected , size ) ;
201+ }
202+
129203function archiveKeyFromUri ( uri : string ) : string {
130204 const s3 = parseS3Uri ( uri ) ;
131205 if ( s3 ) return s3 . key ;
@@ -171,66 +245,213 @@ async function createExportArchive(exportDir: string, manifest: Manifest): Promi
171245 return gzip ( Buffer . concat ( blocks ) ) ;
172246}
173247
174- async function extractExportArchive ( archiveBuffer : Buffer , outputDir : string ) : Promise < void > {
175- await fs . mkdir ( outputDir , { recursive : true } ) ;
176- const tar = await gunzip ( archiveBuffer ) ;
248+ async function extractExportArchive (
249+ archiveBuffer : Buffer ,
250+ outputDir : string ,
251+ limitOverrides ?: Partial < ArchiveExtractionLimits > ,
252+ ) : Promise < void > {
253+ const limits = resolveArchiveExtractionLimits ( limitOverrides ) ;
254+ if ( archiveBuffer . length > limits . maxCompressedArchiveBytes ) {
255+ throw new ArchiveBoundaryError ( 'Archive exceeds the compressed-size limit' ) ;
256+ }
257+
258+ let tar : Buffer ;
259+ try {
260+ tar = await gunzip ( archiveBuffer , { maxOutputLength : limits . maxInflatedArchiveBytes } ) ;
261+ } catch ( error ) {
262+ if ( ( error as NodeJS . ErrnoException ) . code === 'ERR_BUFFER_TOO_LARGE' ) {
263+ throw new ArchiveBoundaryError ( 'Archive exceeds the inflated-size limit' ) ;
264+ }
265+ throw new ArchiveBoundaryError ( 'Archive stream is truncated or malformed' ) ;
266+ }
267+ if ( tar . length > limits . maxInflatedArchiveBytes ) {
268+ throw new ArchiveBoundaryError ( 'Archive exceeds the inflated-size limit' ) ;
269+ }
270+
177271 let offset = 0 ;
272+ let memberOrdinal = 0 ;
273+ let aggregateSize = 0 ;
274+ const members : Array < { name : string ; content : Buffer } > = [ ] ;
275+ const seenNames = new Set < string > ( ) ;
178276
179277 while ( offset + TAR_BLOCK_SIZE <= tar . length ) {
180278 const header = tar . subarray ( offset , offset + TAR_BLOCK_SIZE ) ;
181- offset += TAR_BLOCK_SIZE ;
182- if ( header . every ( ( byte ) => byte === 0 ) ) break ;
279+
280+ if ( header . every ( ( byte ) => byte === 0 ) ) {
281+ const trailing = tar . subarray ( offset + TAR_BLOCK_SIZE ) ;
282+ if (
283+ trailing . length < TAR_BLOCK_SIZE
284+ || ! trailing . subarray ( 0 , TAR_BLOCK_SIZE ) . every ( ( byte ) => byte === 0 )
285+ ) {
286+ throw new ArchiveBoundaryError ( 'Archive is truncated' ) ;
287+ }
288+ if ( trailing . subarray ( TAR_BLOCK_SIZE ) . some ( ( byte ) => byte !== 0 ) ) {
289+ throw new ArchiveBoundaryError ( 'Archive contains nonzero trailing data' ) ;
290+ }
291+
292+ await fs . mkdir ( outputDir , { recursive : true } ) ;
293+ for ( const member of members ) {
294+ await fs . writeFile ( path . join ( outputDir , member . name ) , member . content ) ;
295+ }
296+ return ;
297+ }
298+
299+ memberOrdinal += 1 ;
300+ if ( memberOrdinal > limits . maxMembers ) {
301+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } exceeds the member-count limit` ) ;
302+ }
303+
183304 validateTarHeaderChecksum ( header ) ;
305+ offset += TAR_BLOCK_SIZE ;
184306
185- const rawName = header . subarray ( 0 , 100 ) . toString ( 'utf8' ) . replace ( / \0 .* $ / , '' ) ;
186- const filename = path . basename ( rawName ) ;
187- if ( ! filename || filename !== rawName ) {
188- throw new Error ( `Archive contains unsafe path: ${ rawName } ` ) ;
307+ const nameField = header . subarray ( 0 , 100 ) ;
308+ const nameEnd = nameField . indexOf ( 0 ) ;
309+ const rawName = nameEnd >= 0
310+ ? nameField . subarray ( 0 , nameEnd ) . toString ( 'utf8' )
311+ : '' ;
312+ if (
313+ nameEnd < 0
314+ || nameField . subarray ( nameEnd + 1 ) . some ( ( byte ) => byte !== 0 )
315+ || ! SAFE_ARCHIVE_FILENAME . test ( rawName )
316+ ) {
317+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } has an unsafe flat name` ) ;
318+ }
319+ if ( header . subarray ( 345 , 500 ) . some ( ( byte ) => byte !== 0 ) ) {
320+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } has an unsafe flat name` ) ;
189321 }
190322
191323 const sizeText = header . subarray ( 124 , 136 ) . toString ( 'ascii' ) . replace ( / \0 .* $ / , '' ) . trim ( ) ;
192- const size = Number . parseInt ( sizeText || '0' , 8 ) ;
193- if ( ! Number . isFinite ( size ) || size < 0 ) {
194- throw new Error ( `Archive contains invalid size for ${ filename } ` ) ;
324+ const size = / ^ [ 0 - 7 ] * $ / . test ( sizeText ) ? Number . parseInt ( sizeText || '0' , 8 ) : Number . NaN ;
325+ if ( ! Number . isSafeInteger ( size ) || size < 0 ) {
326+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } has an invalid size` ) ;
327+ }
328+ if ( size > limits . maxMemberBytes ) {
329+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } exceeds the member-size limit` ) ;
330+ }
331+ aggregateSize += size ;
332+ if ( aggregateSize > limits . maxAggregatePayloadBytes ) {
333+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } exceeds the aggregate-payload limit` ) ;
195334 }
196335 if ( offset + size > tar . length ) {
197- throw new Error ( `Archive member ${ filename } is truncated` ) ;
336+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } is truncated` ) ;
198337 }
199338
200- const content = tar . subarray ( offset , offset + size ) ;
201- await fs . writeFile ( path . join ( outputDir , filename ) , content ) ;
339+ const typeFlag = header . subarray ( 156 , 157 ) . toString ( 'ascii' ) ;
340+ if ( typeFlag !== '0' ) {
341+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } has an unsupported type` ) ;
342+ }
343+ const collisionKey = rawName . toLocaleLowerCase ( 'en-US' ) ;
344+ if ( seenNames . has ( collisionKey ) ) {
345+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } duplicates a flat filename` ) ;
346+ }
347+ seenNames . add ( collisionKey ) ;
348+
349+ members . push ( { name : rawName , content : tar . subarray ( offset , offset + size ) } ) ;
202350 offset += size ;
203351 const padding = ( TAR_BLOCK_SIZE - ( size % TAR_BLOCK_SIZE ) ) % TAR_BLOCK_SIZE ;
352+ if ( offset + padding > tar . length ) {
353+ throw new ArchiveBoundaryError ( `Archive member ${ memberOrdinal } is truncated` ) ;
354+ }
204355 offset += padding ;
205356 }
357+
358+ throw new ArchiveBoundaryError ( 'Archive is truncated' ) ;
206359}
207360
208361function validateTarHeaderChecksum ( header : Buffer ) : void {
209362 const storedText = header . subarray ( 148 , 156 ) . toString ( 'ascii' ) . replace ( / \0 .* $ / , '' ) . trim ( ) ;
210- const stored = Number . parseInt ( storedText || '' , 8 ) ;
211- if ( ! Number . isFinite ( stored ) ) throw new Error ( 'Archive contains an invalid tar header checksum' ) ;
363+ const stored = / ^ [ 0 - 7 ] + $ / . test ( storedText ) ? Number . parseInt ( storedText , 8 ) : Number . NaN ;
364+ if ( ! Number . isFinite ( stored ) ) throw new ArchiveBoundaryError ( 'Archive contains an invalid tar header checksum' ) ;
212365 const copy = Buffer . from ( header ) ;
213366 copy . fill ( ' ' , 148 , 156 ) ;
214367 let calculated = 0 ;
215368 for ( const byte of copy ) calculated += byte ;
216- if ( stored !== calculated ) throw new Error ( 'Archive tar header checksum mismatch ' ) ;
369+ if ( stored !== calculated ) throw new ArchiveBoundaryError ( 'Archive contains an invalid tar header checksum' ) ;
217370}
218371
219- async function readArchiveUri ( uri : string , s3Client : Pick < S3Client , 'send' > = new S3Client ( { } ) ) : Promise < Buffer > {
372+ async function readLocalArchive ( filePath : string , maxBytes : number ) : Promise < Buffer > {
373+ let handle ;
374+ try {
375+ handle = await fs . open ( filePath , 'r' ) ;
376+ const stats = await handle . stat ( ) ;
377+ if ( stats . size > maxBytes ) {
378+ throw new ArchiveBoundaryError ( 'Archive exceeds the compressed-size limit' ) ;
379+ }
380+
381+ return await readBoundedChunks ( handle . createReadStream ( ) , maxBytes ) ;
382+ } catch ( error ) {
383+ if ( error instanceof ArchiveBoundaryError ) throw error ;
384+ throw new ArchiveBoundaryError ( 'Archive could not be read' ) ;
385+ } finally {
386+ await handle ?. close ( ) . catch ( ( ) => undefined ) ;
387+ }
388+ }
389+
390+ async function readS3Archive (
391+ s3Client : Pick < S3Client , 'send' > ,
392+ bucket : string ,
393+ key : string ,
394+ maxBytes : number ,
395+ ) : Promise < Buffer > {
396+ let response ;
397+ try {
398+ response = await s3Client . send ( new GetObjectCommand ( { Bucket : bucket , Key : key } ) ) ;
399+ } catch {
400+ throw new ArchiveBoundaryError ( 'Archive could not be read' ) ;
401+ }
402+
403+ if ( response . ContentLength !== undefined ) {
404+ if (
405+ ! Number . isSafeInteger ( response . ContentLength )
406+ || response . ContentLength < 0
407+ || response . ContentLength > maxBytes
408+ ) {
409+ throw new ArchiveBoundaryError ( 'Archive exceeds the compressed-size limit' ) ;
410+ }
411+ }
412+
413+ const body = response . Body ;
414+ try {
415+ if ( body instanceof Readable ) {
416+ return await readBoundedChunks ( body , maxBytes ) ;
417+ }
418+
419+ if ( typeof body === 'object' && body !== null && Symbol . asyncIterator in body ) {
420+ return await readBoundedChunks ( body as AsyncIterable < Uint8Array > , maxBytes ) ;
421+ }
422+
423+ if ( typeof Blob !== 'undefined' && body instanceof Blob ) {
424+ if ( body . size > maxBytes ) {
425+ throw new ArchiveBoundaryError ( 'Archive exceeds the compressed-size limit' ) ;
426+ }
427+ return await readBoundedChunks ( body . stream ( ) as unknown as AsyncIterable < Uint8Array > , maxBytes ) ;
428+ }
429+
430+ const streamBody = body as { stream ?: ( ) => unknown } ;
431+ if ( typeof streamBody ?. stream === 'function' ) {
432+ return await readBoundedChunks ( streamBody . stream ( ) as AsyncIterable < Uint8Array > , maxBytes ) ;
433+ }
434+
435+ throw new ArchiveBoundaryError ( 'Archive body is not bounded-readable' ) ;
436+ } catch ( error ) {
437+ if ( error instanceof ArchiveBoundaryError ) throw error ;
438+ throw new ArchiveBoundaryError ( 'Archive body could not be read' ) ;
439+ }
440+ }
441+
442+ async function readArchiveUri (
443+ uri : string ,
444+ s3Client : Pick < S3Client , 'send' > = new S3Client ( { } ) ,
445+ limitOverrides ?: Partial < ArchiveExtractionLimits > ,
446+ ) : Promise < Buffer > {
447+ const limits = resolveArchiveExtractionLimits ( limitOverrides ) ;
220448 const s3 = parseS3Uri ( uri ) ;
221449 if ( ! s3 ) {
222450 const filePath = uri . startsWith ( 'file://' ) ? uri . slice ( 'file://' . length ) : uri ;
223- return fs . readFile ( filePath ) ;
451+ return readLocalArchive ( filePath , limits . maxCompressedArchiveBytes ) ;
224452 }
225453
226- const response = await s3Client . send ( new GetObjectCommand ( { Bucket : s3 . bucket , Key : s3 . key } ) ) ;
227- const body = response . Body as unknown as {
228- transformToByteArray ?: ( ) => Promise < Uint8Array > ;
229- } ;
230- if ( ! body || typeof body . transformToByteArray !== 'function' ) {
231- throw new Error ( 'S3 archive response body is not readable' ) ;
232- }
233- return Buffer . from ( await body . transformToByteArray ( ) ) ;
454+ return readS3Archive ( s3Client , s3 . bucket , s3 . key , limits . maxCompressedArchiveBytes ) ;
234455}
235456
236457async function writeLocalArchive ( localArchiveDir : string , archiveKey : string , content : Buffer ) : Promise < string > {
@@ -306,15 +527,15 @@ async function writeRestoreEvidence(options: RestoreEvidenceOptions): Promise<Re
306527 if ( ! / ^ s h a 2 5 6 : [ a - f 0 - 9 ] { 64 } $ / . test ( options . expectedArchiveChecksum ) ) {
307528 throw new Error ( 'Restore evidence requires an expected sha256 archive checksum' ) ;
308529 }
309- const archiveBuffer = await readArchiveUri ( options . archiveUri , options . s3Client ) ;
530+ const archiveBuffer = await readArchiveUri ( options . archiveUri , options . s3Client , options . extractionLimits ) ;
310531 const calculatedArchiveChecksum = sha256Bytes ( archiveBuffer ) ;
311532 if ( calculatedArchiveChecksum !== options . expectedArchiveChecksum ) {
312533 throw new Error ( 'Archive checksum mismatch' ) ;
313534 }
314535 await fs . mkdir ( options . outputDir , { recursive : true } ) ;
315536 const timestamp = options . timestamp || new Date ( ) . toISOString ( ) ;
316537 const extractedDir = path . join ( options . outputDir , `extracted-${ timestamp . replace ( / [: .] / g, '-' ) } ` ) ;
317- await extractExportArchive ( archiveBuffer , extractedDir ) ;
538+ await extractExportArchive ( archiveBuffer , extractedDir , options . extractionLimits ) ;
318539
319540 const manifest = JSON . parse ( await fs . readFile ( path . join ( extractedDir , 'manifest.json' ) , 'utf8' ) ) as Manifest ;
320541 const validation = await validatePortableExport ( extractedDir ) ;
0 commit comments