VYPR
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.

PackageAffected versionsPatched versions
@vltpkg/tarnpm
< 1.0.0-rc.101.0.0-rc.10

Affected products

1

Patches

1
ff8d4099a192

tar: add node-tar inspired path sanitization (#1334)

https://github.com/vltpkg/vltpkgRuy AdornoDec 9, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.