Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected]
Control: affects -1 + src:node-tar
User: [email protected]
Usertags: pu

[ Reason ]
node-tar is vulnerable to 6 CVE. The more important is the possibility
to points to a file outside the extraction root, enabling arbitrary file
read and write as the extracting user.

 - CVE-2026-23745: sanitize absolute linkpaths properly
 - CVE-2026-23950: normalize out unicode ligatures
 - CVE-2026-29786: parse root off paths before sanitizing parts
 - CVE-2026-26960: do not write linkpaths through symlinks
   (Closes: #1129378)
 - CVE-2026-24842: properly sanitize hard links containing '..'
 - CVE-2026-31802: prevent escaping symlinks with drive-relative paths

The 2 lasts are regressions introduced by CVE-2026-23745 patch

[ Impact ]
Medium security issues

[ Tests ]
Test pass

[ Risks ]
Medium risk, test pass and test coverage looks good

[ Checklist ]
  [X] *all* changes are documented in the d/changelog
  [X] I reviewed all changes and I approve them
  [X] attach debdiff against the package in (old)stable
  [X] the issue is verified as fixed in unstable

Best regards,
Xavier
diff --git a/debian/changelog b/debian/changelog
index 32e118b..968ebc5 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,11 @@
+node-tar (6.2.1+~cs7.0.8-1+deb13u1) trixie; urgency=medium
+
+  * Team upload
+  * Add patches for 6 CVEs: CVE-2026-23745, CVE-2026-23950, CVE-2026-24842,
+    CVE-2026-26960, CVE-2026-29786, CVE-2026-31802 (Closes: #1129378)
+
+ -- Xavier Guimard <[email protected]>  Tue, 24 Mar 2026 12:34:05 +0100
+
 node-tar (6.2.1+~cs7.0.8-1) unstable; urgency=medium
 
   * New upstream version
diff --git a/debian/patches/CVE-2026-23745.patch 
b/debian/patches/CVE-2026-23745.patch
new file mode 100644
index 0000000..146fb83
--- /dev/null
+++ b/debian/patches/CVE-2026-23745.patch
@@ -0,0 +1,145 @@
+Description: sanitize absolute linkpaths properly
+Author: isaacs <[email protected]>
+Origin: upstream, https://github.com/isaacs/node-tar/commit/340eb285
+Bug: https://github.com/isaacs/node-tar/security/advisories/GHSA-8qq5-rm4j-mr97
+Forwarded: not-needed
+Applied-Upstream: 7.5.3, commit:340eb285
+Reviewed-By: Xavier Guimard <[email protected]>
+Last-Update: 2026-01-17
+
+--- a/lib/unpack.js
++++ b/lib/unpack.js
+@@ -32,6 +32,7 @@
+ const HARDLINK = Symbol('hardlink')
+ const UNSUPPORTED = Symbol('unsupported')
+ const CHECKPATH = Symbol('checkPath')
++const STRIPABSOLUTEPATH = Symbol('stripAbsolutePath')
+ const MKDIR = Symbol('mkdir')
+ const ONERROR = Symbol('onError')
+ const PENDING = Symbol('pending')
+@@ -244,6 +245,43 @@
+     }
+   }
+ 
++  // return false if we need to skip this file
++  // return true if the field was successfully sanitized
++  [STRIPABSOLUTEPATH]( entry, field ) {
++    const path = entry[field]
++    if (!path || this.preservePaths) return true
++
++    const parts = path.split('/')
++    if (
++      parts.includes('..') ||
++      /* c8 ignore next */
++      (isWindows && /^[a-z]:\.\.$/i.test(parts[0] ?? ''))
++    ) {
++      this.warn('TAR_ENTRY_ERROR', `${field} contains '..'`, {
++        entry,
++        [field]: path,
++      })
++      // not ok!
++      return false
++    }
++
++    // strip off the root
++    const [root, stripped] = stripAbsolutePath(path)
++    if (root) {
++      // ok, but triggers warning about stripping root
++      entry[field] = String(stripped)
++      this.warn(
++        'TAR_ENTRY_INFO',
++        `stripping ${root} from absolute ${field}`,
++        {
++          entry,
++          [field]: path,
++        },
++      )
++    }
++    return true
++  }
++
+   [CHECKPATH] (entry) {
+     const p = normPath(entry.path)
+     const parts = p.split('/')
+@@ -274,24 +312,11 @@
+       return false
+     }
+ 
+-    if (!this.preservePaths) {
+-      if (parts.includes('..') || isWindows && 
/^[a-z]:\.\.$/i.test(parts[0])) {
+-        this.warn('TAR_ENTRY_ERROR', `path contains '..'`, {
+-          entry,
+-          path: p,
+-        })
+-        return false
+-      }
+-
+-      // strip off the root
+-      const [root, stripped] = stripAbsolutePath(p)
+-      if (root) {
+-        entry.path = stripped
+-        this.warn('TAR_ENTRY_INFO', `stripping ${root} from absolute path`, {
+-          entry,
+-          path: p,
+-        })
+-      }
++    if (
++      !this[STRIPABSOLUTEPATH](entry, 'path') ||
++      !this[STRIPABSOLUTEPATH](entry, 'linkpath')
++    ) {
++      return false
+     }
+ 
+     if (path.isAbsolute(entry.path)) {
+--- /dev/null
++++ b/test/ghsa-8qq5-rm4j-mr97.js
+@@ -0,0 +1,49 @@
++const { readFileSync, readlinkSync, writeFileSync } = require('fs')
++const { resolve } = require('path')
++const t = require('tap')
++const Header = require('../lib/header.js')
++const x = require('../lib/extract.js')
++
++const targetSym = '/some/absolute/path'
++
++const getExploitTar = () => {
++  const exploitTar = Buffer.alloc(512 + 512 + 1024)
++
++  new Header({
++    path: 'exploit_hard',
++    type: 'Link',
++    size: 0,
++    linkpath: resolve(t.testdirName, 'secret.txt'),
++  }).encode(exploitTar, 0)
++
++  new Header({
++    path: 'exploit_sym',
++    type: 'SymbolicLink',
++    size: 0,
++    linkpath: targetSym,
++  }).encode(exploitTar, 512)
++
++  return exploitTar
++}
++
++const dir = t.testdir({
++  'secret.txt': 'ORIGINAL DATA',
++  'exploit.tar': getExploitTar(),
++  out_repro: {},
++})
++
++const out = resolve(dir, 'out_repro')
++const tarFile = resolve(dir, 'exploit.tar')
++
++t.test('verify that linkpaths get sanitized properly', async t => {
++  await x({
++    cwd: out,
++    file: tarFile,
++    preservePaths: false,
++  })
++
++  writeFileSync(resolve(out, 'exploit_hard'), 'OVERWRITTEN')
++  t.equal(readFileSync(resolve(dir, 'secret.txt'), 'utf8'), 'ORIGINAL DATA')
++
++  t.not(readlinkSync(resolve(out, 'exploit_sym')), targetSym)
++})
diff --git a/debian/patches/CVE-2026-23950.patch 
b/debian/patches/CVE-2026-23950.patch
new file mode 100644
index 0000000..ab164b4
--- /dev/null
+++ b/debian/patches/CVE-2026-23950.patch
@@ -0,0 +1,132 @@
+Description: normalize out unicode ligatures
+Author: Yadd <[email protected]>
+Origin: upstream, https://github.com/isaacs/node-tar/commit/3b1abfae
+Bug: https://github.com/isaacs/node-tar/security/advisories/GHSA-r6q2-hw4h-h46w
+Forwarded: not-needed
+Applied-Upstream: 7.5.4, commit:3b1abfae
+Reviewed-By: Xavier Guimard <[email protected]>
+Last-Update: 2026-01-22
+
+--- a/lib/normalize-unicode.js
++++ b/lib/normalize-unicode.js
+@@ -6,7 +6,11 @@
+ const { hasOwnProperty } = Object.prototype
+ module.exports = s => {
+   if (!hasOwnProperty.call(normalizeCache, s)) {
+-    normalizeCache[s] = s.normalize('NFD')
++    // shake out identical accents and ligatures
++    normalizeCache[s] = s
++      .normalize('NFD')
++      .toLocaleLowerCase('en')
++      .toLocaleUpperCase('en')
+   }
+   return normalizeCache[s]
+ }
+--- a/lib/path-reservations.js
++++ b/lib/path-reservations.js
+@@ -123,7 +123,7 @@
+     // effectively removing all parallelization on windows.
+     paths = isWindows ? ['win32 parallelization disabled'] : paths.map(p => {
+       // don't need normPath, because we skip this entirely for windows
+-      return stripSlashes(join(normalize(p))).toLowerCase()
++      return stripSlashes(join(normalize(p)))
+     })
+ 
+     const dirs = new Set(
+--- a/tap-snapshots/test/normalize-unicode.js.test.cjs
++++ b/tap-snapshots/test/normalize-unicode.js.test.cjs
+@@ -6,25 +6,25 @@
+  */
+ 'use strict'
+ exports[`test/normalize-unicode.js TAP normalize with strip slashes 
"1/4foo.txt" > normalized 1`] = `
+-1/4foo.txt
++1/4FOO.TXT
+ `
+ 
+ exports[`test/normalize-unicode.js TAP normalize with strip slashes 
"\\\\a\\\\b\\\\c\\\\d\\\\" > normalized 1`] = `
+-/a/b/c/d
++/A/B/C/D
+ `
+ 
+ exports[`test/normalize-unicode.js TAP normalize with strip slashes 
"¼foo.txt" > normalized 1`] = `
+-¼foo.txt
++¼FOO.TXT
+ `
+ 
+ exports[`test/normalize-unicode.js TAP normalize with strip slashes 
"﹨aaaa﹨dddd﹨" > normalized 1`] = `
+-﹨aaaa﹨dddd﹨
++﹨AAAA﹨DDDD﹨
+ `
+ 
+ exports[`test/normalize-unicode.js TAP normalize with strip slashes 
"\bbb\eee\" > normalized 1`] = `
+-\bbb\eee\
++\BBB\EEE\
+ `
+ 
+ exports[`test/normalize-unicode.js TAP normalize with strip slashes 
"\\\\\eee\\\\\\" > normalized 1`] = `
+-\\\\\eee\\\\\\
++\\\\\EEE\\\\\\
+ `
+--- /dev/null
++++ b/test/ghsa-r6q2-hw4h-h46w.js
+@@ -0,0 +1,49 @@
++const t = require('tap')
++const normalizeUnicode = require('../lib/normalize-unicode.js')
++const Header = require('../lib/header.js')
++const { resolve } = require('path')
++const { lstatSync, readFileSync, statSync } = require('fs')
++const extract = require('../lib/extract.js')
++
++// these characters are problems on macOS's APFS
++const chars = {
++  ['ff'.normalize('NFC')]: 'FF',
++  ['fi'.normalize('NFC')]: 'FI',
++  ['fl'.normalize('NFC')]: 'FL',
++  ['ffi'.normalize('NFC')]: 'FFI',
++  ['ffl'.normalize('NFC')]: 'FFL',
++  ['ſt'.normalize('NFC')]: 'ST',
++  ['st'.normalize('NFC')]: 'ST',
++  ['ẛ'.normalize('NFC')]: 'Ṡ',
++  ['ß'.normalize('NFC')]: 'SS',
++  ['ẞ'.normalize('NFC')]: 'SS',
++  ['ſ'.normalize('NFC')]: 'S',
++}
++
++for (const [c, n] of Object.entries(chars)) {
++  t.test(`${c} => ${n}`, async t => {
++    t.equal(normalizeUnicode(c), n)
++
++    t.test('link then file', async t => {
++      const tarball = Buffer.alloc(2048)
++      new Header({
++        path: c,
++        type: 'SymbolicLink',
++        linkpath: './target',
++      }).encode(tarball, 0)
++      new Header({
++        path: n,
++        type: 'File',
++        size: 1,
++      }).encode(tarball, 512)
++      tarball[1024] = 'x'.charCodeAt(0)
++
++      const cwd = t.testdir({ tarball })
++
++      await extract({ cwd, file: resolve(cwd, 'tarball') })
++
++      t.throws(() => statSync(resolve(cwd, 'target')))
++      t.equal(readFileSync(resolve(cwd, n), 'utf8'), 'x')
++    })
++  })
++}
+--- a/test/normalize-unicode.js
++++ b/test/normalize-unicode.js
+@@ -12,7 +12,7 @@
+ 
+ t.equal(normalize(cafe1), normalize(cafe2), 'matching unicodes')
+ t.equal(normalize(cafe1), normalize(cafe2), 'cached')
+-t.equal(normalize('foo'), 'foo', 'non-unicode string')
++t.equal(normalize('foo'), 'FOO', 'non-unicode string')
+ 
+ t.test('normalize with strip slashes', t => {
+   const paths = [
diff --git a/debian/patches/CVE-2026-24842.patch 
b/debian/patches/CVE-2026-24842.patch
new file mode 100644
index 0000000..f7f547b
--- /dev/null
+++ b/debian/patches/CVE-2026-24842.patch
@@ -0,0 +1,28 @@
+Description: properly sanitize hard links containing ..
+ The issue is that *hard* links are resolved relative to the unpack cwd,
+ so if they have `..`, they cannot possibly be valid. The loosening of
+ the '..' restriction for symbolic links should have been limited by type.
+Author: isaacs <[email protected]>
+Origin: upstream, https://github.com/isaacs/node-tar/commit/f4a7aa9b
+Bug: https://github.com/isaacs/node-tar/security/advisories/GHSA-34x7-hfp2-rc4v
+Forwarded: not-needed
+Applied-Upstream: 7.5.7, commit:f4a7aa9b
+Last-Update: 2026-03-24
+
+--- a/lib/unpack.js
++++ b/lib/unpack.js
+@@ -251,11 +251,13 @@
+   // return true if the field was successfully sanitized
+   [STRIPABSOLUTEPATH]( entry, field ) {
+     const path = entry[field]
++    const { type } = entry
+     if (!path || this.preservePaths) return true
+ 
+     const parts = path.split('/')
+     if (
+-      parts.includes('..') ||
++      (parts.includes('..') &&
++        (field === 'path' || type === 'Link')) ||
+       /* c8 ignore next */
+       (isWindows && /^[a-z]:\.\.$/i.test(parts[0] ?? ''))
+     ) {
diff --git a/debian/patches/CVE-2026-26960.patch 
b/debian/patches/CVE-2026-26960.patch
new file mode 100644
index 0000000..de59193
--- /dev/null
+++ b/debian/patches/CVE-2026-26960.patch
@@ -0,0 +1,139 @@
+From: isaacs <[email protected]>
+Date: Thu, 12 Feb 2026 20:50:19 -0800
+Subject: [PATCH] <short summary of the patch>
+Origin: upstream, https://github.com/isaacs/node-tar/commit/d18e4e1f
+Bug: https://github.com/isaacs/node-tar/security/advisories/GHSA-83g3-92jg-28cx
+Forwarded: not-needed
+Applied-Upstream: 7.5.7, commit:d18e4e1f
+Reviewed-By: Xavier Guimard <[email protected]>
+
+--- /dev/null
++++ b/lib/process-umask.js
+@@ -0,0 +1,4 @@
++// separate file so I stop getting nagged in vim about deprecated API
++module.exports = {
++  umask: () => process.umask()
++};
+--- a/lib/unpack.js
++++ b/lib/unpack.js
+@@ -18,6 +18,7 @@
+ const normPath = require('./normalize-windows-path.js')
+ const stripSlash = require('./strip-trailing-slashes.js')
+ const normalize = require('./normalize-unicode.js')
++const { umask } = require('./process-umask.js')
+ 
+ const ONENTRY = Symbol('onEntry')
+ const CHECKFS = Symbol('checkFs')
+@@ -30,6 +31,7 @@
+ const LINK = Symbol('link')
+ const SYMLINK = Symbol('symlink')
+ const HARDLINK = Symbol('hardlink')
++const ENSURE_NO_SYMLINK = Symbol('ensureNoSymlink')
+ const UNSUPPORTED = Symbol('unsupported')
+ const CHECKPATH = Symbol('checkPath')
+ const STRIPABSOLUTEPATH = Symbol('stripAbsolutePath')
+@@ -217,7 +219,7 @@
+     this.cwd = normPath(path.resolve(opt.cwd || process.cwd()))
+     this.strip = +opt.strip || 0
+     // if we're not chmodding, then we don't need the process umask
+-    this.processUmask = opt.noChmod ? 0 : process.umask()
++    this.processUmask = opt.noChmod ? 0 : umask()
+     this.umask = typeof opt.umask === 'number' ? opt.umask : this.processUmask
+ 
+     // default mode for dirs created as parents
+@@ -565,12 +567,64 @@
+   }
+ 
+   [SYMLINK] (entry, done) {
+-    this[LINK](entry, entry.linkpath, 'symlink', done)
++    const parts = normPath(
++      path.relative(
++        this.cwd,
++        path.resolve(
++          path.dirname(String(entry.absolute)),
++          String(entry.linkpath),
++        ),
++      ),
++    ).split('/')
++    this[ENSURE_NO_SYMLINK](
++      entry,
++      this.cwd,
++      parts,
++      () =>
++        this[LINK](entry, String(entry.linkpath), 'symlink', done),
++      er => {
++        this[ONERROR](er, entry)
++        done()
++      },
++    )
+   }
+ 
+   [HARDLINK] (entry, done) {
+     const linkpath = normPath(path.resolve(this.cwd, entry.linkpath))
+-    this[LINK](entry, linkpath, 'link', done)
++    const parts = normPath(String(entry.linkpath)).split(
++      '/',
++    )
++    this[ENSURE_NO_SYMLINK](
++      entry,
++      this.cwd,
++      parts,
++      () => this[LINK](entry, linkpath, 'link', done),
++      er => {
++        this[ONERROR](er, entry)
++        done()
++      },
++    )
++  }
++
++  [ENSURE_NO_SYMLINK](
++    entry,
++    cwd,
++    parts,
++    done,
++    onError,
++  ) {
++    const p = parts.shift()
++    if (this.preservePaths || p === undefined) return done()
++    const t = path.resolve(cwd, p)
++    fs.lstat(t, (er, st) => {
++      if (er) return done()
++      if (st?.isSymbolicLink()) {
++        return onError(
++          new SymlinkError(t, path.resolve(t, parts.join('/'))),
++        )
++      }
++      this[ENSURE_NO_SYMLINK](entry, t, parts, done, onError)
++    })
+   }
+ 
+   [PEND] () {
+@@ -935,6 +989,28 @@
+     }
+   }
+ 
++  [ENSURE_NO_SYMLINK](
++    _entry,
++    cwd,
++    parts,
++    done,
++    onError,
++  ) {
++    if (this.preservePaths || !parts.length) return done()
++    let t = cwd
++    for (const p of parts) {
++      t = path.resolve(t, p)
++      const [er, st] = callSync(() => fs.lstatSync(t))
++      if (er) return done()
++      if (st.isSymbolicLink()) {
++        return onError(
++          new SymlinkError(t, path.resolve(cwd, parts.join('/'))),
++        )
++      }
++    }
++    done()
++  }
++
+   [LINK] (entry, linkpath, link, done) {
+     try {
+       fs[link + 'Sync'](linkpath, entry.absolute)
diff --git a/debian/patches/CVE-2026-29786.patch 
b/debian/patches/CVE-2026-29786.patch
new file mode 100644
index 0000000..c261d51
--- /dev/null
+++ b/debian/patches/CVE-2026-29786.patch
@@ -0,0 +1,22 @@
+From: isaacs <[email protected]>
+Date: Wed, 4 Mar 2026 11:41:10 -0800
+Subject: [PATCH] parse root off paths before sanitizing .. parts
+Origin: upstream, https://github.com/isaacs/node-tar/commit/7bc755dd
+Bug: https://github.com/isaacs/node-tar/security/advisories/GHSA-qffp-2rhf-9h96
+Forwarded: not-needed
+Applied-Upstream: 7.5.10, commit:7bc755dd
+Reviewed-By: Xavier Guimard <[email protected]>
+
+--- a/lib/unpack.js
++++ b/lib/unpack.js
+@@ -284,7 +284,9 @@
+ 
+   [CHECKPATH] (entry) {
+     const p = normPath(entry.path)
+-    const parts = p.split('/')
++    // strip off the root
++    const [root, stripped] = stripAbsolutePath(p)
++    const parts = stripped.replace(/\\/g, '/').split('/')
+ 
+     if (this.strip) {
+       if (parts.length < this.strip) {
diff --git a/debian/patches/CVE-2026-31802.patch 
b/debian/patches/CVE-2026-31802.patch
new file mode 100644
index 0000000..d9aa7dc
--- /dev/null
+++ b/debian/patches/CVE-2026-31802.patch
@@ -0,0 +1,33 @@
+Description: prevent escaping symlinks with drive-relative paths
+ After stripping the drive letter root from paths like c:../../../foo,
+ re-check for '..' to prevent path traversal via drive-relative linkpaths.
+Author: isaacs <[email protected]>
+Origin: upstream, https://github.com/isaacs/node-tar/commit/f48b5fa3
+Bug: https://github.com/isaacs/node-tar/security/advisories/GHSA-9ppj-qmqm-q256
+Forwarded: not-needed
+Applied-Upstream: 7.5.11, commit:f48b5fa3
+Last-Update: 2026-03-24
+
+--- a/lib/unpack.js
++++ b/lib/unpack.js
+@@ -272,6 +272,20 @@
+     // strip off the root
+     const [root, stripped] = stripAbsolutePath(path)
+     if (root) {
++      // After stripping root, re-check for '..' in the stripped path
++      // This catches drive-relative paths like c:../../../foo where
++      // the initial check missed '..' because it was part of 'c:..'
++      const strippedParts = String(stripped).replace(/\\/g, '/').split('/')
++      if (
++        strippedParts.includes('..') &&
++        (field === 'path' || entry.type === 'Link')
++      ) {
++        this.warn('TAR_ENTRY_ERROR', `linkpath escapes extraction directory`, 
{
++          entry,
++          [field]: path,
++        })
++        return false
++      }
+       // ok, but triggers warning about stripping root
+       entry[field] = String(stripped)
+       this.warn(
diff --git a/debian/patches/series b/debian/patches/series
index b52771a..2eda2d6 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -1 +1,7 @@
 api-backward-compatibility.patch
+CVE-2026-23745.patch
+CVE-2026-23950.patch
+CVE-2026-29786.patch
+CVE-2026-26960.patch
+CVE-2026-24842.patch
+CVE-2026-31802.patch

Reply via email to