A gzip bomb inflates a kilobyte-sized tarball into hundreds of gigabytes during extraction. pnpm v12 bounds what an archive may unpack to with maxTarballUnpackedSize since pnpm/pnpm#17054; pnpm v11 had no such bound, so an install could still exhaust the disk. pnpm v11 now adds up the sizes that the regular-file entries of a package archive declare in their tar headers and fails the extraction with ERR_PNPM_TARBALL_TOO_LARGE once the total passes maxTarballUnpackedSize (default 4 GiB). Header sizes stand in for written bytes: the tar parser never yields more payload than a header declares, so their sum bounds what can reach the store, and checking it before the payload is read keeps the crossing entry out of the store. Entries the ignore filter drops count as well. The error is deterministic for a given archive, so the fetch retry loop does not retry it. The zip archives that runtimes are installed from are held to the same limit. The setting name, default, error code, message and hint match pnpm v12. pnpm v12's zip extractor bounded each entry by its central-directory size but not their sum. Every stored zip entry now counts against the payload ceiling of the TarLimits that tarballs use. The "Added in" line in the v11 docs assumes the next release is 11.28.7. Completes https://github.com/pnpm/tasks/issues/81. --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
98 lines
3.7 KiB
TypeScript
98 lines
3.7 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { gzipSync } from 'node:zlib'
|
|
|
|
import { expect, test } from '@jest/globals'
|
|
|
|
import { addTarballToStore } from '../src/addToStore.js'
|
|
|
|
function createSource (): { temporaryDirectory: string, tarballFile: string, storeDir: string, integrity: string } {
|
|
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-tarball-file-'))
|
|
const tarballFile = path.join(temporaryDirectory, 'archive.tgz')
|
|
const data = gzipSync(Buffer.alloc(1024))
|
|
fs.writeFileSync(tarballFile, data)
|
|
return {
|
|
temporaryDirectory,
|
|
tarballFile,
|
|
storeDir: path.join(temporaryDirectory, 'store'),
|
|
integrity: `sha512-${crypto.hash('sha512', data, 'base64')}`,
|
|
}
|
|
}
|
|
|
|
test('file-backed tarballs are verified before extraction', async () => {
|
|
const source = createSource()
|
|
try {
|
|
const result = await addTarballToStore({
|
|
type: 'extract',
|
|
tarballFile: source.tarballFile,
|
|
storeDir: source.storeDir,
|
|
filesIndexFile: 'test',
|
|
integrity: `sha512-${crypto.hash('sha512', 'wrong', 'base64')}`,
|
|
maxTarballUnpackedSize: 4294967296,
|
|
})
|
|
expect(result).toMatchObject({ status: 'error', error: { type: 'integrity_validation_failed', found: source.integrity } })
|
|
expect(fs.existsSync(source.storeDir)).toBe(false)
|
|
} finally {
|
|
fs.rmSync(source.temporaryDirectory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test.each([false, true])('file-backed extraction retains the tarball integrity (pinned: %s)', async (pinned) => {
|
|
const source = createSource()
|
|
try {
|
|
const result = await addTarballToStore({
|
|
type: 'extract',
|
|
tarballFile: source.tarballFile,
|
|
storeDir: source.storeDir,
|
|
filesIndexFile: 'test',
|
|
pkgId: 'test@1.0.0',
|
|
integrity: pinned ? source.integrity : undefined,
|
|
maxTarballUnpackedSize: 4294967296,
|
|
})
|
|
expect(result).toMatchObject({ status: 'success', value: { integrity: source.integrity } })
|
|
expect('indexWrites' in result && result.indexWrites).toHaveLength(pinned ? 1 : 2)
|
|
} finally {
|
|
fs.rmSync(source.temporaryDirectory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
function createTarEntry (name: string, content: string): Buffer {
|
|
const header = Buffer.alloc(512)
|
|
header.write(name, 0)
|
|
header.write('0000644\0', 100)
|
|
header.write(content.length.toString(8).padStart(11, '0') + '\0', 124)
|
|
header.fill(' ', 148, 156)
|
|
header[156] = '0'.charCodeAt(0)
|
|
header.write(`${header.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, '0')}\0 `, 148)
|
|
const body = Buffer.alloc(Math.ceil(content.length / 512) * 512)
|
|
body.write(content)
|
|
return Buffer.concat([header, body])
|
|
}
|
|
|
|
test.each([false, true])('an archive that unpacks past maxTarballUnpackedSize is rejected with a hint (file-backed: %s)', async (fileBacked) => {
|
|
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-tarball-limit-'))
|
|
try {
|
|
const buffer = gzipSync(Buffer.concat([
|
|
createTarEntry('package/package.json', '{"name":"limited","version":"1.0.0"}'),
|
|
createTarEntry('package/index.js', 'x'.repeat(100)),
|
|
Buffer.alloc(1024),
|
|
]))
|
|
const tarballFile = path.join(temporaryDirectory, 'archive.tgz')
|
|
fs.writeFileSync(tarballFile, buffer)
|
|
await expect(addTarballToStore({
|
|
type: 'extract',
|
|
...(fileBacked ? { tarballFile } : { buffer }),
|
|
storeDir: path.join(temporaryDirectory, 'store'),
|
|
filesIndexFile: 'test',
|
|
maxTarballUnpackedSize: 100,
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_TARBALL_TOO_LARGE',
|
|
hint: 'If the package is legitimately this large, raise the maxTarballUnpackedSize setting.',
|
|
})
|
|
} finally {
|
|
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
|
|
}
|
|
})
|