CVE-2024-28863: Insufficient Information (NVD-CWE-noinfo)
value: node-tar is a Tar for Node.js. node-tar prior to version 6.2.1 has no limit on the number of sub-folders created in the folder creation process. An attacker who generates a large number of sub-folders can consume memory on the system running node-tar and even crash the Node.js client within few seconds of running it using a path with too many sub-folders inside. Version 6.2.1 fixes this issue by preventing extraction in excessively deep sub-folders.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-21T23:15Z
Repository: https://github.com/isaacs/node-tar
Language: JavaScript
Files changed: README.md, lib/unpack.js, test/parse.js, test/unpack.js
Commit date: 2024-03-21 14:15:00 -0700
Commit message: prevent extraction in excessively deep subfolders

This sets the limit at 1024 subfolders nesting by default, but that can
be dropped down, or set to Infinity to remove the limitation.

Diff: diff --git a/README.md b/README.md
index 7cb09da..f620568 100644
--- a/README.md
+++ b/README.md
@@ -113,10 +113,12 @@ Handlers receive 3 arguments:
 
 * `TAR_ENTRY_ERROR` The entry appears to be a valid tar archive entry, but
   encountered an error which prevented it from being unpacked.  This occurs
   when:
   - an unrecoverable fs error happens during unpacking,
+  - an entry is trying to extract into an excessively deep
+    location (by default, limited to 1024 subfolders),
   - an entry has `..` in the path and `preservePaths` is not set, or
   - an entry is extracting through a symbolic link, when `preservePaths` is
     not set.
 
 * `TAR_ENTRY_UNSUPPORTED`  An indication that a given entry is
@@ -425,10 +427,14 @@ The following options are supported:
 - `noChmod` Set to true to omit calling `fs.chmod()` to ensure that the
   extracted file matches the entry mode.  This also suppresses the call to
   `process.umask()` to determine the default umask value, since tar will
   extract with whatever mode is provided, and let the process `umask` apply
   normally.
+- `maxDepth` The maximum depth of subfolders to extract into. This
+  defaults to 1024. Anything deeper than the limit will raise a
+  warning and skip the entry. Set to `Infinity` to remove the
+  limitation.
 
 The following options are mostly internal, but can be modified in some
 advanced use cases, such as re-using caches between runs.
 
 - `maxReadSize` The maximum buffer size for `fs.read()` operations.
@@ -747,10 +753,14 @@ Most unpack errors will cause a `warn` event to be emitted.  If the
 - `noChmod` Set to true to omit calling `fs.chmod()` to ensure that the
   extracted file matches the entry mode.  This also suppresses the call to
   `process.umask()` to determine the default umask value, since tar will
   extract with whatever mode is provided, and let the process `umask` apply
   normally.
+- `maxDepth` The maximum depth of subfolders to extract into. This
+  defaults to 1024. Anything deeper than the limit will raise a
+  warning and skip the entry. Set to `Infinity` to remove the
+  limitation.
 
 ### class tar.Unpack.Sync
 
 Synchronous version of `tar.Unpack`.
 
diff --git a/lib/unpack.js b/lib/unpack.js
index fa46611..03172e2 100644
--- a/lib/unpack.js
+++ b/lib/unpack.js
@@ -46,10 +46,11 @@ const GID = Symbol('gid')
 const CHECKED_CWD = Symbol('checkedCwd')
 const crypto = require('crypto')
 const getFlag = require('./get-write-flag.js')
 const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform
 const isWindows = platform === 'win32'
+const DEFAULT_MAX_DEPTH = 1024
 
 // Unlinks on Windows are not atomic.
 //
 // This means that if you have a file entry, followed by another
 // file entry with an identical name, and you cannot re-use the file
@@ -179,10 +180,16 @@ class Unpack extends Parser {
     this.processUid = (this.preserveOwner || this.setOwner) && process.getuid ?
       process.getuid() : null
     this.processGid = (this.preserveOwner || this.setOwner) && process.getgid ?
       process.getgid() : null
 
+    // prevent excessively deep nesting of subfolders
+    // set to `Infinity` to remove this restriction
+    this.maxDepth = typeof opt.maxDepth === 'number'
+      ? opt.maxDepth
+      : DEFAULT_MAX_DEPTH
+
     // mostly just for testing, but useful in some cases.
     // Forcibly trigger a chown on every entry, no matter what
     this.forceChown = opt.forceChown === true
 
     // turn ><?| in filenames into 0xf000-higher encoded forms
@@ -236,30 +243,40 @@ class Unpack extends Parser {
       this.emit('end')
     }
   }
 
   [CHECKPATH] (entry) {
+    const p = normPath(entry.path)
+    const parts = p.split('/')
+
     if (this.strip) {
-      const parts = normPath(entry.path).split('/')
       if (parts.length < this.strip) {
         return false
       }
-      entry.path = parts.slice(this.strip).join('/')
-
       if (entry.type === 'Link') {
         const linkparts = normPath(entry.linkpath).split('/')
         if (linkparts.length >= this.strip) {
           entry.linkpath = linkparts.slice(this.strip).join('/')
         } else {
           return false
         }
       }
+      parts.splice(0, this.strip)
+      entry.path = parts.join('/')
+    }
+
+    if (isFinite(this.maxDepth) && parts.length > this.maxDepth) {
+      this.warn('TAR_ENTRY_ERROR', 'path excessively deep', {
+        entry,
+        path: p,
+        depth: parts.length,
+        maxDepth: this.maxDepth,
+      })
+      return false
     }
 
     if (!this.preservePaths) {
-      const p = normPath(entry.path)
-      const parts = p.split('/')
       if (parts.includes('..') || isWindows && /^[a-z]:\.\.$/i.test(parts[0])) {
         this.warn('TAR_ENTRY_ERROR', `path contains '..'`, {
           entry,
           path: p,
         })
diff --git a/test/fixtures/excessively-deep.tar b/test/fixtures/excessively-deep.tar
new file mode 100644
index 0000000..e42aa3d
Binary files /dev/null and b/test/fixtures/excessively-deep.tar differ
diff --git a/test/parse.js b/test/parse.js
index 549b370..2cc6878 100644
--- a/test/parse.js
+++ b/test/parse.js
@@ -644,11 +644,11 @@ t.test('truncated gzip input', t => {
     p.write(trunc)
     p.write(trunc)
     p.write(tgz.slice(split))
     p.end()
     t.equal(aborted, true, 'aborted writing')
-    t.same(warnings, ['zlib: incorrect data check'])
+    t.match(warnings, [/^zlib: /])
     t.end()
   })
 
   t.end()
 })
diff --git a/test/unpack.js b/test/unpack.js
index 399ae0e..2f1d302 100644
--- a/test/unpack.js
+++ b/test/unpack.js
@@ -20,10 +20,11 @@ const { promisify } = require('util')
 const rimraf = promisify(require('rimraf'))
 const mkdirp = require('mkdirp')
 const mutateFS = require('mutate-fs')
 const eos = require('end-of-stream')
 const normPath = require('../lib/normalize-windows-path.js')
+const ReadEntry = require('../lib/read-entry.js')
 
 // On Windows in particular, the "really deep folder path" file
 // often tends to cause problems, which don't indicate a failure
 // of this library, it's just what happens on Windows with super
 // long file paths.
@@ -3233,5 +3234,65 @@ t.test('recognize C:.. as a dot path part', t => {
     check(path, warnings, t)
   })
 
   t.end()
 })
+
+t.test('excessively deep subfolder nesting', async t => {
+  const tf = path.resolve(fixtures, 'excessively-deep.tar')
+  const data = fs.readFileSync(tf)
+  const warnings = []
+  const onwarn = (c, w, { entry, path, depth, maxDepth }) =>
+    warnings.push([c, w, { entry, path, depth, maxDepth }])
+
+  const check = (t, maxDepth = 1024) => {
+    t.match(warnings, [
+      ['TAR_ENTRY_ERROR',
+        'path excessively deep',
+        {
+          entry: ReadEntry,
+          path: /^\.(\/a){1024,}\/foo.txt$/,
+          depth: 222372,
+          maxDepth,
+        }
+      ]
+    ])
+    warnings.length = 0
+    t.end()
+  }
+
+  t.test('async', t => {
+    const cwd = t.testdir()
+    new Unpack({
+      cwd,
+      onwarn
+    }).on('end', () => check(t)).end(data)
+  })
+
+  t.test('sync', t => {
+    const cwd = t.testdir()
+    new UnpackSync({
+      cwd,
+      onwarn
+    }).end(data)
+    check(t)
+  })
+
+  t.test('async set md', t => {
+    const cwd = t.testdir()
+    new Unpack({
+      cwd,
+      onwarn,
+      maxDepth: 64,
+    }).on('end', () => check(t, 64)).end(data)
+  })
+
+  t.test('sync set md', t => {
+    const cwd = t.testdir()
+    new UnpackSync({
+      cwd,
+      onwarn,
+      maxDepth: 64,
+    }).end(data)
+    check(t, 64)
+  })
+})

Vulnerable code:       const parts = normPath(entry.path).split('/')
      entry.path = parts.slice(this.strip).join('/')

      const p = normPath(entry.path)
      const parts = p.split('/')
    t.same(warnings, ['zlib: incorrect data check'])
Fixed code:   - an entry is trying to extract into an excessively deep
    location (by default, limited to 1024 subfolders),
- `maxDepth` The maximum depth of subfolders to extract into. This
  defaults to 1024. Anything deeper than the limit will raise a
  warning and skip the entry. Set to `Infinity` to remove the
  limitation.
- `maxDepth` The maximum depth of subfolders to extract into. This
  defaults to 1024. Anything deeper than the limit will raise a
  warning and skip the entry. Set to `Infinity` to remove the
  limitation.
const DEFAULT_MAX_DEPTH = 1024
    // prevent excessively deep nesting of subfolders
    // set to `Infinity` to remove this restriction
    this.maxDepth = typeof opt.maxDepth === 'number'
      ? opt.maxDepth
      : DEFAULT_MAX_DEPTH

    const p = normPath(entry.path)
    const parts = p.split('/')

      parts.splice(0, this.strip)
      entry.path = parts.join('/')
    }

    if (isFinite(this.maxDepth) && parts.length > this.maxDepth) {
      this.warn('TAR_ENTRY_ERROR', 'path excessively deep', {
        entry,
        path: p,
        depth: parts.length,
        maxDepth: this.maxDepth,
      })
      return false
    t.match(warnings, [/^zlib: /])
const ReadEntry = require('../lib/read-entry.js')

t.test('excessively deep subfolder nesting', async t => {
  const tf = path.resolve(fixtures, 'excessively-deep.tar')
  const data = fs.readFileSync(tf)
  const warnings = []
  const onwarn = (c, w, { entry, path, depth, maxDepth }) =>
    warnings.push([c, w, { entry, path, depth, maxDepth }])

  const check = (t, maxDepth = 1024) => {
    t.match(warnings, [
      ['TAR_ENTRY_ERROR',
        'path excessively deep',
        {
          entry: ReadEntry,
          path: /^\.(\/a){1024,}\/foo.txt$/,
          depth: 222372,
          maxDepth,
        }
      ]
    ])
    warnings.length = 0
    t.end()
  }

  t.test('async', t => {
    const cwd = t.testdir()
    new Unpack({
      cwd,
      onwarn
    }).on('end', () => check(t)).end(data)
  })

  t.test('sync', t => {
    const cwd = t.testdir()
    new UnpackSync({
      cwd,
      onwarn
    }).end(data)
    check(t)
  })

  t.test('async set md', t => {
    const cwd = t.testdir()
    new Unpack({
      cwd,
      onwarn,
      maxDepth: 64,
    }).on('end', () => check(t, 64)).end(data)
  })

  t.test('sync set md', t => {
    const cwd = t.testdir()
    new UnpackSync({
      cwd,
      onwarn,
      maxDepth: 64,
    }).end(data)
    check(t, 64)
  })
})