@@ -42,6 +42,8 @@ export interface ExtractOptions {
4242 quiet ?: boolean
4343 /** Strip leading path components (like tar --strip-components) */
4444 strip ?: number
45+ /** Maximum number of entries to extract (default: 100,000) */
46+ maxEntries ?: number
4547 /** Maximum size of a single extracted file in bytes (default: 100MB) */
4648 maxFileSize ?: number
4749 /** Maximum total extracted size in bytes (default: 1GB) */
@@ -55,6 +57,8 @@ export interface ExtractOptions {
5557const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024
5658// 1GB
5759const DEFAULT_MAX_TOTAL_SIZE = 1024 * 1024 * 1024
60+ // Maximum number of entries to prevent inode exhaustion DoS.
61+ const DEFAULT_MAX_ENTRIES = 100_000
5862
5963/**
6064 * Validate that a resolved path is within the target directory.
@@ -123,6 +127,7 @@ export async function extractTar(
123127 options : ExtractOptions = { } ,
124128) : Promise < void > {
125129 const {
130+ maxEntries = DEFAULT_MAX_ENTRIES ,
126131 maxFileSize = DEFAULT_MAX_FILE_SIZE ,
127132 maxTotalSize = DEFAULT_MAX_TOTAL_SIZE ,
128133 strip = 0 ,
@@ -133,6 +138,7 @@ export async function extractTar(
133138 await safeMkdir ( normalizedOutputDir )
134139
135140 let totalExtractedSize = 0
141+ let entryCount = 0
136142
137143 let destroyScheduled = false
138144
@@ -143,6 +149,33 @@ export async function extractTar(
143149 return header
144150 }
145151
152+ // Check entry count to prevent inode exhaustion DoS.
153+ entryCount += 1
154+ if ( entryCount > maxEntries ) {
155+ destroyScheduled = true
156+ process . nextTick ( ( ) => {
157+ extractStream . destroy (
158+ new Error (
159+ `Archive has too many entries: exceeded limit of ${ maxEntries } ` ,
160+ ) ,
161+ )
162+ } )
163+ return header
164+ }
165+
166+ // Reject entries with null bytes in names (defense in depth).
167+ if ( header . name . includes ( '\0' ) ) {
168+ destroyScheduled = true
169+ process . nextTick ( ( ) => {
170+ extractStream . destroy (
171+ new Error (
172+ `Invalid null byte in archive entry name: ${ header . name } ` ,
173+ ) ,
174+ )
175+ } )
176+ return header
177+ }
178+
146179 // Check for symlinks
147180 if ( header . type === 'symlink' || header . type === 'link' ) {
148181 destroyScheduled = true
@@ -219,6 +252,7 @@ export async function extractTarGz(
219252 options : ExtractOptions = { } ,
220253) : Promise < void > {
221254 const {
255+ maxEntries = DEFAULT_MAX_ENTRIES ,
222256 maxFileSize = DEFAULT_MAX_FILE_SIZE ,
223257 maxTotalSize = DEFAULT_MAX_TOTAL_SIZE ,
224258 strip = 0 ,
@@ -229,6 +263,7 @@ export async function extractTarGz(
229263 await safeMkdir ( normalizedOutputDir )
230264
231265 let totalExtractedSize = 0
266+ let entryCount = 0
232267
233268 let destroyScheduled = false
234269
@@ -239,6 +274,33 @@ export async function extractTarGz(
239274 return header
240275 }
241276
277+ // Check entry count to prevent inode exhaustion DoS.
278+ entryCount += 1
279+ if ( entryCount > maxEntries ) {
280+ destroyScheduled = true
281+ process . nextTick ( ( ) => {
282+ extractStream . destroy (
283+ new Error (
284+ `Archive has too many entries: exceeded limit of ${ maxEntries } ` ,
285+ ) ,
286+ )
287+ } )
288+ return header
289+ }
290+
291+ // Reject entries with null bytes in names (defense in depth).
292+ if ( header . name . includes ( '\0' ) ) {
293+ destroyScheduled = true
294+ process . nextTick ( ( ) => {
295+ extractStream . destroy (
296+ new Error (
297+ `Invalid null byte in archive entry name: ${ header . name } ` ,
298+ ) ,
299+ )
300+ } )
301+ return header
302+ }
303+
242304 // Check for symlinks
243305 if ( header . type === 'symlink' || header . type === 'link' ) {
244306 destroyScheduled = true
@@ -315,6 +377,7 @@ export async function extractZip(
315377 options : ExtractOptions = { } ,
316378) : Promise < void > {
317379 const {
380+ maxEntries = DEFAULT_MAX_ENTRIES ,
318381 maxFileSize = DEFAULT_MAX_FILE_SIZE ,
319382 maxTotalSize = DEFAULT_MAX_TOTAL_SIZE ,
320383 strip = 0 ,
@@ -329,13 +392,28 @@ export async function extractZip(
329392
330393 // Pre-validate all entries for security
331394 const entries = zip . getEntries ( )
395+
396+ // Check entry count to prevent inode exhaustion DoS.
397+ if ( entries . length > maxEntries ) {
398+ throw new Error (
399+ `Archive has too many entries: ${ entries . length } (limit: ${ maxEntries } )` ,
400+ )
401+ }
402+
332403 let totalExtractedSize = 0
333404
334405 for ( const entry of entries ) {
335406 if ( entry . isDirectory ) {
336407 continue
337408 }
338409
410+ // Reject entries with null bytes in names (defense in depth).
411+ if ( entry . entryName . includes ( '\0' ) ) {
412+ throw new Error (
413+ `Invalid null byte in archive entry name: ${ entry . entryName } ` ,
414+ )
415+ }
416+
339417 // Check individual file size
340418 const uncompressedSize = entry . header . size
341419 if ( uncompressedSize > maxFileSize ) {
0 commit comments