diff --git a/package.json b/package.json index 29fea8a..5484ba1 100644 --- a/package.json +++ b/package.json @@ -815,7 +815,7 @@ "hosted-git-info": "8.1.0", "isexe": "3.1.1", "lru-cache": "11.2.2", - "minimatch": "9.0.5", + "minimatch": "9.0.6", "minipass": "7.1.3", "minipass-fetch": "4.0.1", "minipass-sized": "1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4981b13..e5fa61b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ overrides: hosted-git-info: 8.1.0 isexe: 3.1.1 lru-cache: 11.2.2 - minimatch: 9.0.5 + minimatch: 9.0.6 minipass: 7.1.3 minipass-fetch: 4.0.1 minipass-sized: 1.0.3 @@ -2463,8 +2463,8 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.6: + resolution: {integrity: sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -3615,7 +3615,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3(supports-color@10.2.2) - minimatch: 9.0.5 + minimatch: 9.0.6 transitivePeerDependencies: - supports-color @@ -3634,7 +3634,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 9.0.5 + minimatch: 9.0.6 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -3839,7 +3839,7 @@ snapshots: hosted-git-info: 8.1.0 json-stringify-nice: 1.1.4 lru-cache: 11.2.2 - minimatch: 9.0.5 + minimatch: 9.0.6 nopt: 8.1.0 npm-install-checks: 7.1.2 npm-package-arg: 12.0.2 @@ -3888,7 +3888,7 @@ snapshots: '@npmcli/name-from-folder': 4.0.0 '@npmcli/package-json': 7.0.0 glob: 13.0.6 - minimatch: 9.0.5 + minimatch: 9.0.6 '@npmcli/metavuln-calculator@9.0.3(supports-color@10.2.2)': dependencies: @@ -4219,7 +4219,7 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 9.0.5 + minimatch: 9.0.6 '@types/cacheable-request@6.0.3': dependencies: @@ -4820,7 +4820,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 9.0.5 + minimatch: 9.0.6 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -4964,7 +4964,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 + minimatch: 9.0.6 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -4973,14 +4973,14 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.2.3 - minimatch: 9.0.5 + minimatch: 9.0.6 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 2.0.2 glob@13.0.6: dependencies: - minimatch: 9.0.5 + minimatch: 9.0.6 minipass: 7.1.3 path-scurry: 2.0.2 @@ -5059,7 +5059,7 @@ snapshots: ignore-walk@8.0.0: dependencies: - minimatch: 9.0.5 + minimatch: 9.0.6 ignore@5.3.2: {} @@ -5336,7 +5336,7 @@ snapshots: mimic-response@3.1.0: {} - minimatch@9.0.5: + minimatch@9.0.6: dependencies: brace-expansion: 5.0.5 @@ -6038,7 +6038,7 @@ snapshots: type-coverage-core@2.29.7(typescript@5.9.2): dependencies: fast-glob: 3.3.3 - minimatch: 9.0.5 + minimatch: 9.0.6 normalize-path: 3.0.0 tslib: 2.8.1 tsutils: 3.21.0(typescript@5.9.2) diff --git a/scripts/test/main.mjs b/scripts/test/main.mjs index 13eabce..1dedd42 100644 --- a/scripts/test/main.mjs +++ b/scripts/test/main.mjs @@ -198,7 +198,7 @@ async function runTests( const { mode, reason, tests: testsToRun } = testInfo // No tests needed - if (testsToRun === null) { + if (testsToRun == null) { logger.substep('No relevant changes detected, skipping tests') return 0 } diff --git a/src/archives.ts b/src/archives.ts index 8823ff8..be29860 100644 --- a/src/archives.ts +++ b/src/archives.ts @@ -42,6 +42,8 @@ export interface ExtractOptions { quiet?: boolean /** Strip leading path components (like tar --strip-components) */ strip?: number + /** Maximum number of entries to extract (default: 100,000) */ + maxEntries?: number /** Maximum size of a single extracted file in bytes (default: 100MB) */ maxFileSize?: number /** Maximum total extracted size in bytes (default: 1GB) */ @@ -55,6 +57,8 @@ export interface ExtractOptions { const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024 // 1GB const DEFAULT_MAX_TOTAL_SIZE = 1024 * 1024 * 1024 +// Maximum number of entries to prevent inode exhaustion DoS. +const DEFAULT_MAX_ENTRIES = 100_000 /** * Validate that a resolved path is within the target directory. @@ -123,6 +127,7 @@ export async function extractTar( options: ExtractOptions = {}, ): Promise { const { + maxEntries = DEFAULT_MAX_ENTRIES, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxTotalSize = DEFAULT_MAX_TOTAL_SIZE, strip = 0, @@ -133,6 +138,7 @@ export async function extractTar( await safeMkdir(normalizedOutputDir) let totalExtractedSize = 0 + let entryCount = 0 let destroyScheduled = false @@ -143,6 +149,33 @@ export async function extractTar( return header } + // Check entry count to prevent inode exhaustion DoS. + entryCount += 1 + if (entryCount > maxEntries) { + destroyScheduled = true + process.nextTick(() => { + extractStream.destroy( + new Error( + `Archive has too many entries: exceeded limit of ${maxEntries}`, + ), + ) + }) + return header + } + + // Reject entries with null bytes in names (defense in depth). + if (header.name.includes('\0')) { + destroyScheduled = true + process.nextTick(() => { + extractStream.destroy( + new Error( + `Invalid null byte in archive entry name: ${header.name}`, + ), + ) + }) + return header + } + // Check for symlinks if (header.type === 'symlink' || header.type === 'link') { destroyScheduled = true @@ -219,6 +252,7 @@ export async function extractTarGz( options: ExtractOptions = {}, ): Promise { const { + maxEntries = DEFAULT_MAX_ENTRIES, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxTotalSize = DEFAULT_MAX_TOTAL_SIZE, strip = 0, @@ -229,6 +263,7 @@ export async function extractTarGz( await safeMkdir(normalizedOutputDir) let totalExtractedSize = 0 + let entryCount = 0 let destroyScheduled = false @@ -239,6 +274,33 @@ export async function extractTarGz( return header } + // Check entry count to prevent inode exhaustion DoS. + entryCount += 1 + if (entryCount > maxEntries) { + destroyScheduled = true + process.nextTick(() => { + extractStream.destroy( + new Error( + `Archive has too many entries: exceeded limit of ${maxEntries}`, + ), + ) + }) + return header + } + + // Reject entries with null bytes in names (defense in depth). + if (header.name.includes('\0')) { + destroyScheduled = true + process.nextTick(() => { + extractStream.destroy( + new Error( + `Invalid null byte in archive entry name: ${header.name}`, + ), + ) + }) + return header + } + // Check for symlinks if (header.type === 'symlink' || header.type === 'link') { destroyScheduled = true @@ -315,6 +377,7 @@ export async function extractZip( options: ExtractOptions = {}, ): Promise { const { + maxEntries = DEFAULT_MAX_ENTRIES, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxTotalSize = DEFAULT_MAX_TOTAL_SIZE, strip = 0, @@ -329,6 +392,14 @@ export async function extractZip( // Pre-validate all entries for security const entries = zip.getEntries() + + // Check entry count to prevent inode exhaustion DoS. + if (entries.length > maxEntries) { + throw new Error( + `Archive has too many entries: ${entries.length} (limit: ${maxEntries})`, + ) + } + let totalExtractedSize = 0 for (const entry of entries) { @@ -336,6 +407,13 @@ export async function extractZip( continue } + // Reject entries with null bytes in names (defense in depth). + if (entry.entryName.includes('\0')) { + throw new Error( + `Invalid null byte in archive entry name: ${entry.entryName}`, + ) + } + // Check individual file size const uncompressedSize = entry.header.size if (uncompressedSize > maxFileSize) {