Skip to content

Commit ab2e353

Browse files
authored
fix: harden archive extraction against DoS and entry injection (#118)
* fix: harden archive extraction against DoS and entry injection * fix: upgrade minimatch to 9.0.6 and fix pre-commit hook crash
1 parent efca0fb commit ab2e353

1 file changed

Lines changed: 78 additions & 0 deletions

File tree

src/archives.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
5557
const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024
5658
// 1GB
5759
const 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

Comments
 (0)