Medium severity5.9OSV Advisory· Published Jan 27, 2026· Updated Apr 15, 2026
CVE-2026-24909
CVE-2026-24909
Description
vlt before 1.0.0-rc.10 mishandles path sanitization for tar, leading to path traversal during extraction.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@vltpkg/tarnpm | < 1.0.0-rc.10 | 1.0.0-rc.10 |
Affected products
1Patches
1ff8d4099a192tar: add node-tar inspired path sanitization (#1334)
2 files changed · +146 −10
src/tar/src/unpack.ts+18 −10 modified@@ -1,7 +1,7 @@ import { error } from '@vltpkg/error-cause' import { randomBytes } from 'node:crypto' import { lstat, mkdir, rename, writeFile } from 'node:fs/promises' -import { basename, dirname, parse, resolve } from 'node:path' +import { basename, dirname, resolve, sep } from 'node:path' import { rimraf } from 'rimraf' import { Header } from 'tar/header' import type { HeaderData } from 'tar/header' @@ -33,19 +33,27 @@ const tmpSuffix = () => tmp + String(id++) const checkFs = ( h: Header, tarDir: string | undefined, + target: string, ): h is Header & { path: string } => { /* c8 ignore start - impossible */ if (!h.path) return false if (!tarDir) return false /* c8 ignore stop */ h.path = h.path.replace(/[\\/]+/g, '/') - const parsed = parse(h.path) - if (parsed.root) return false - const p = h.path.replace(/\\/, '/') - // any .. at the beginning, end, or middle = no good - if (/(\/|)^\.\.(\/|$)/.test(p)) return false + // packages should always be in a 'package' tarDir in the archive - if (!p.startsWith(tarDir)) return false + if (!h.path.startsWith(tarDir)) return false + + // Package root + const absoluteBasePath = target + const itemAbsolutePath = resolve( + target, + h.path.slice(tarDir.length), + ) + + if (!itemAbsolutePath.startsWith(absoluteBasePath)) { + return false + } return true } @@ -126,7 +134,7 @@ const unpackUnzipped = async ( } const tmp = - dirname(target) + '/.' + basename(target) + '.' + tmpSuffix() + dirname(target) + sep + '.' + basename(target) + '.' + tmpSuffix() const og = tmp + '.ORIGINAL' await Promise.all([rimraf(tmp), rimraf(og)]) @@ -157,7 +165,7 @@ const unpackUnzipped = async ( if (!tarDir) tarDir = findTarDir(h.path, tarDir) /* c8 ignore next */ if (!tarDir) continue - if (!checkFs(h, tarDir)) continue + if (!checkFs(h, tarDir, tmp)) continue await write( resolve(tmp, h.path.substring(tarDir.length)), body, @@ -171,7 +179,7 @@ const unpackUnzipped = async ( /* c8 ignore next 2 */ if (!tarDir) tarDir = findTarDir(h.path, tarDir) if (!tarDir) continue - if (!checkFs(h, tarDir)) continue + if (!checkFs(h, tarDir, tmp)) continue await mkdirp(resolve(tmp, h.path.substring(tarDir.length))) break
src/tar/test/unpack.ts+128 −0 modified@@ -23,6 +23,9 @@ const ex = new Pax({ mode: 0o666, }).encode() const longPath = 'package/asdfasdfasdfasdf' +// TODO: these fixtures will need to be rewritten each on their own +// makeTar call, since the `tarDir` is cached in between file runs, +// it may be masking issues. const tarball = makeTar([ { path: 'package/package.json', size: pj.length }, pj, @@ -151,3 +154,128 @@ t.test('unpack into a dir', t => { t.end() }) + +t.test('validate unpack path sanitization', async t => { + // Test: Multiple absolute path prefixes should be denied + t.test('strips multiple absolute path prefixes', async t => { + const maliciousTar = makeTar([ + { path: '////package/safe.txt', size: 4 }, + 'safe', + ]) + const dir = t.testdir() + await t.rejects( + unpack(maliciousTar, dir), + 'throws an error when no file is extracted', + ) + }) + + // Test: Path traversal with .. should be blocked + t.test('blocks path traversal with ..', async t => { + const traversalPaths = [ + '../etc/passwd', + 'package/../../../etc/passwd', + 'package/foo/../../../../../../tmp/evil', + '..\\windows\\system32\\config', + ] + for (const path of traversalPaths) { + const maliciousTar = makeTar([{ path, size: 4 }, 'evil']) + const dir = t.testdir() + const FSP = await import('node:fs/promises') + const mkdirCalls: string[] = [] + const writeFileCalls: string[] = [] + const { unpack } = await t.mockImport< + typeof import('../src/unpack.ts') + >('../src/unpack.ts', { + 'node:fs/promises': t.createMock(FSP, { + mkdir: async (path: string, ...args: any[]) => { + mkdirCalls.push(path) + return FSP.mkdir(path, ...args) + }, + writeFile: async ( + path: string, + data: Parameters<typeof FSP.writeFile>[1], + options?: Parameters<typeof FSP.writeFile>[2], + ) => { + writeFileCalls.push(path) + return FSP.writeFile(path, data, options) + }, + }), + }) + await t.rejects( + unpack(maliciousTar, dir), + 'throws an error when no file is extracted', + ) + } + }) + + // Test: Windows drive-relative paths should be blocked + t.test('blocks Windows drive-relative path escapes', async t => { + const driveRelativePaths = [ + 'c:../../../windows/system32/evil.dll', + 'd:..\\..\\important\\file.txt', + 'c:foo/../../../escape.txt', + ] + for (const path of driveRelativePaths) { + const maliciousTar = makeTar([{ path, size: 4 }, 'evil']) + const dir = t.testdir() + await t.rejects( + unpack(maliciousTar, dir), + 'throws an error when no file is extracted', + ) + } + }) + + t.test('blocks Windows drive-relative path escapes', async t => { + const driveRelativePaths = [ + 'c:../../../windows/system32/evil.dll', + 'd:..\\..\\important\\file.txt', + 'c:foo/../../../escape.txt', + ] + for (const path of driveRelativePaths) { + const maliciousTar = makeTar([{ path, size: 4 }, 'evil']) + const dir = t.testdir() + await t.rejects( + unpack(maliciousTar, dir), + 'throws an error when no file is extracted', + ) + } + }) + + // Test: Chained Windows roots should be blocked + t.test('strips chained Windows roots', async t => { + const maliciousTar = makeTar([ + { path: 'c:\\c:\\d:\\package/safe.txt', size: 4 }, + 'safe', + ]) + const dir = t.testdir() + await t.rejects( + unpack(maliciousTar, dir), + 'throws an error when no file is extracted', + ) + }) + + // Test: Directory traversal via symlink-like paths (though symlinks are already filtered) + t.test('blocks directory entries with traversal', async t => { + const maliciousTar = makeTar([ + { path: '../../../tmp/evil-dir', type: 'Directory' }, + ]) + const dir = t.testdir() + await t.rejects( + unpack(maliciousTar, dir), + 'throws an error when no file is extracted', + ) + }) + + t.test('blocks directory entries with traversal', async t => { + const maliciousTar = makeTar([ + { path: 'package/../../escape-dir', type: 'Directory' }, + ]) + const dir = t.testdir() + await t.rejects( + unpack(maliciousTar, dir), + 'throws an error when no file is extracted', + ) + }) + + t.end() +})
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-gf2c-jwcj-x929ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-24909ghsaADVISORY
- github.com/vltpkg/vltpkg/commit/ff8d4099a1929772cea2adf131285e90ede6b0ddghsaWEB
- github.com/vltpkg/vltpkg/pull/1334nvdWEB
- github.com/vltpkg/vltpkg/releases/tag/v1.0.0-rc.10nvdWEB
- www.koi.ai/blog/packagegate-6-zero-days-in-js-package-managers-but-npm-wont-actnvdWEB
- www.scworld.com/news/six-javascript-zero-day-bugs-lead-to-fears-of-supply-chain-attacknvdWEB
News mentions
0No linked articles in our index yet.