CVE-2018-20834 (published 2019-04-30T19:29Z)

A vulnerability was found in node-tar before version 4.4.2 (excluding version 2.2.2). An Arbitrary File Overwrite issue exists when extracting a tarball containing a hardlink to a file that already exists on the system, in conjunction with a later plain file with the same name as the hardlink. This plain file content replaces the existing file content. A patch has been applied to node-tar v2.2.2).

Severity: MEDIUM
CVSS v2 base score: 6.4 (AV:N/AC:L/Au:N/C:N/I:P/A:P)
CVSS v3 base score: 7.5, HIGH (CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N)

Weakness information:
cwe_id: CWE-59
cwe_name: Improper Link Resolution Before File Access ('Link Following')
description: The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/npm/node-tar
commit_details: repo_url: https://github.com/npm/node-tar
author: isaacs
author_date: 2018-04-30 11:32:28-07:00
msg: unpack: only reuse file fs entries if nlink = 1

This matches behavior in bsdtar to prevent overwriting a hardlink with
contents from a File archive entry.
repository: repo_url: https://github.com/npm/node-tar
repo_name: isaacs/node-tar
description: tar for node
repo_language: JavaScript
owner: npm
file_changes: filename: unpack.js
change_type: ModificationType.MODIFY
diff: @@ -12,6 +12,7 @@ const wc = require('./winchars.js')
 
 const ONENTRY = Symbol('onEntry')
 const CHECKFS = Symbol('checkFs')
+const ISREUSABLE = Symbol('isReusable')
 const MAKEFS = Symbol('makeFs')
 const FILE = Symbol('file')
 const DIRECTORY = Symbol('directory')
@@ -351,6 +352,17 @@ class Unpack extends Parser {
     entry.resume()
   }
 
+  // Check if we can reuse an existing filesystem entry safely and
+  // overwrite it, rather than unlinking and recreating
+  // Windows doesn't report a useful nlink, so we just never reuse entries
+  [ISREUSABLE] (entry, st) {
+    return entry.type === 'File' &&
+      !this.unlink &&
+      st.isFile() &&
+      st.nlink <= 1 &&
+      process.platform !== 'win32'
+  }
+
   // check if a thing is there, and if so, try to clobber it
   [CHECKFS] (entry) {
     this[PEND]()
@@ -360,7 +372,7 @@ class Unpack extends Parser {
       fs.lstat(entry.absolute, (er, st) => {
         if (st && (this.keep || this.newer && st.mtime > entry.mtime))
           this[SKIP](entry)
-        else if (er || (entry.type === 'File' && !this.unlink && st.isFile()))
+        else if (er || this[ISREUSABLE](entry, st))
           this[MAKEFS](null, entry)
         else if (st.isDirectory()) {
           if (entry.type === 'Directory') {
@@ -422,7 +434,7 @@ class UnpackSync extends Unpack {
       const st = fs.lstatSync(entry.absolute)
       if (this.keep || this.newer && st.mtime > entry.mtime)
         return this[SKIP](entry)
-      else if (entry.type === 'File' && !this.unlink && st.isFile())
+      else if (this[ISREUSABLE](entry, st))
         return this[MAKEFS](null, entry)
       else {
         try {

programming_language: CoffeeScript
filename: unpack.js
change_type: ModificationType.MODIFY
diff: @@ -2286,3 +2286,66 @@ t.test('onentry option is preserved', t => {
 
   t.end()
 })
+
+t.test('do not reuse hardlinks, only nlink=1 files', t => {
+  const basedir = path.resolve(unpackdir, 'hardlink-reuse')
+  mkdirp.sync(basedir)
+  t.teardown(() => rimraf.sync(basedir))
+
+  const now = new Date('2018-04-30T18:30:39.025Z')
+
+  const data = makeTar([
+    {
+      path: 'overwriteme',
+      type: 'File',
+      size: 4,
+      mode: 0o644,
+      mtime: now
+    },
+    'foo\n',
+    {
+      path: 'link',
+      linkpath: 'overwriteme',
+      type: 'Link',
+      mode: 0o644,
+      mtime: now
+    },
+    {
+      path: 'link',
+      type: 'File',
+      size: 4,
+      mode: 0o644,
+      mtime: now
+    },
+    'bar\n',
+    '',
+    ''
+  ])
+
+  const checks = {
+    'link': 'bar\n',
+    'overwriteme': 'foo\n'
+  }
+
+  const check = t => {
+    for (let f in checks) {
+      t.equal(fs.readFileSync(basedir + '/' + f, 'utf8'), checks[f], f)
+      t.equal(fs.statSync(basedir + '/' + f).nlink, 1, f)
+    }
+    t.end()
+  }
+
+  t.test('async', t => {
+    const u = new Unpack({ cwd: basedir })
+    u.on('close', () => check(t))
+    u.end(data)
+  })
+
+  t.test('sync', t => {
+    const u = new UnpackSync({ cwd: basedir })
+    u.end(data)
+    check(t)
+  })
+
+  t.end()
+})

programming_language: JavaScript