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

