CVE-2022-0436: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: Path Traversal in GitHub repository gruntjs/grunt prior to 1.5.2.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 5.5
Published: 2022-04-12T21:15Z
Repository: https://github.com/gruntjs/grunt
Language: JavaScript
Files changed: lib/grunt/file.js, package.json, test/grunt/file_test.js
Commit date: 2022-04-10 23:16:06 -0400
Commit message: Update dependencies, tests...

Diff: diff --git a/lib/grunt/file.js b/lib/grunt/file.js
index 7e0e2fb7..21f4ef29 100644
--- a/lib/grunt/file.js
+++ b/lib/grunt/file.js
@@ -290,12 +290,15 @@ file.write = function(filepath, contents, options) {
 };
 
 // Read a file, optionally processing its content, then write the output.
 // Or read a directory, recursively creating directories, reading files,
 // processing content, writing output.
+// Handles symlinks by coping them as files or directories.
 file.copy = function copy(srcpath, destpath, options) {
-  if (file.isDir(srcpath)) {
+  if (file._isSymbolicLink(srcpath)) {
+    file._copySymbolicLink(srcpath, destpath);
+  } else if (file.isDir(srcpath)) {
     // Copy a directory, recursively.
     // Explicitly create new dest directory.
     file.mkdir(destpath);
     // Iterate over all sub-files/dirs, recursing.
     fs.readdirSync(srcpath).forEach(function(filepath) {
@@ -447,10 +450,28 @@ file.isPathCwd = function() {
   } catch (e) {
     return false;
   }
 };
 
+file._isSymbolicLink = function() {
+  var filepath = path.join.apply(path, arguments);
+  return fs.lstatSync(filepath).isSymbolicLink();
+};
+
+file._copySymbolicLink = function(srcpath, destpath) {
+  var destdir = path.join(destpath, '..');
+  var fileBase = path.basename(srcpath);
+  // Use the correct relative path for the symlink
+  if (!grunt.file.isPathAbsolute(srcpath)) {
+    srcpath = path.relative(destdir, srcpath) || '.';
+  }
+  file.mkdir(destdir);
+  var mode = grunt.file.isDir(srcpath) ? 'dir' : 'file';
+  var destpath = path.join(destpath, fileBase);
+  return fs.symlinkSync(srcpath, destpath, mode);
+};
+
 // Test to see if a filepath is contained within the CWD.
 file.isPathInCwd = function() {
   var filepath = path.join.apply(path, arguments);
   try {
     return file.doesPathContain(fs.realpathSync(process.cwd()), fs.realpathSync(filepath));
diff --git a/package.json b/package.json
index 444f03d9..167372f8 100644
--- a/package.json
+++ b/package.json
@@ -40,11 +40,11 @@
     "dateformat": "~3.0.3",
     "eventemitter2": "~0.4.13",
     "exit": "~0.1.2",
     "findup-sync": "~0.3.0",
     "glob": "~7.1.6",
-    "grunt-cli": "~1.4.2",
+    "grunt-cli": "~1.4.3",
     "grunt-known-options": "~2.0.0",
     "grunt-legacy-log": "~3.0.0",
     "grunt-legacy-util": "~2.0.1",
     "iconv-lite": "~0.4.13",
     "js-yaml": "~3.14.0",
@@ -54,11 +54,11 @@
     "rimraf": "~3.0.2"
   },
   "devDependencies": {
     "difflet": "~1.0.1",
     "eslint-config-grunt": "~1.0.1",
-    "grunt-contrib-nodeunit": "~3.0.0",
+    "grunt-contrib-nodeunit": "~4.0.0",
     "grunt-contrib-watch": "~1.1.0",
     "grunt-eslint": "~18.1.0",
     "temporary": "~0.0.4",
     "through2": "~4.0.2"
   },
diff --git a/test/grunt/file_test.js b/test/grunt/file_test.js
index b192cad9..20c4aed4 100644
--- a/test/grunt/file_test.js
+++ b/test/grunt/file_test.js
@@ -891,7 +891,30 @@ exports.file = {
       test.expect(2);
       test.ok(grunt.file.isPathInCwd('deep'), 'subdirectory is in cwd');
       test.ok(grunt.file.isPathInCwd(path.resolve('deep')), 'subdirectory is in cwd');
       test.done();
     },
+    'symbolicLinkCopy': function(test) {
+      test.expect(4);
+      var srcfile = new Tempdir();
+      fs.symlinkSync(path.resolve('test/fixtures/octocat.png'), path.join(srcfile.path, 'octocat.png'), 'file');
+      // test symlink copy for files
+      var destdir = new Tempdir();
+      grunt.file.copy(path.join(srcfile.path, 'octocat.png'), destdir.path);
+      test.ok(fs.lstatSync(path.join(srcfile.path, 'octocat.png')).isSymbolicLink());
+      test.ok(fs.lstatSync(path.join(destdir.path, 'octocat.png')).isSymbolicLink());
+
+      // test symlink copy for directories
+      var srcdir = new Tempdir();
+      var destdir = new Tempdir();
+      var fixtures = path.resolve('test/fixtures');
+      var symlinkSource = path.join(srcdir.path, path.basename(fixtures));
+      console.log('symlinkSource', symlinkSource);
+      fs.symlinkSync(fixtures, symlinkSource, 'dir');
+
+      grunt.file.copy(symlinkSource, destdir.path);
+      test.ok(fs.lstatSync(symlinkSource).isSymbolicLink());
+      test.ok(fs.lstatSync(path.join(destdir.path, path.basename(fixtures))).isSymbolicLink());
+      test.done();
+    },
   }
 };

Vulnerable code:   if (file.isDir(srcpath)) {
    "grunt-cli": "~1.4.2",
    "grunt-contrib-nodeunit": "~3.0.0",
Fixed code: // Handles symlinks by coping them as files or directories.
  if (file._isSymbolicLink(srcpath)) {
    file._copySymbolicLink(srcpath, destpath);
  } else if (file.isDir(srcpath)) {
file._isSymbolicLink = function() {
  var filepath = path.join.apply(path, arguments);
  return fs.lstatSync(filepath).isSymbolicLink();
};

file._copySymbolicLink = function(srcpath, destpath) {
  var destdir = path.join(destpath, '..');
  var fileBase = path.basename(srcpath);
  // Use the correct relative path for the symlink
  if (!grunt.file.isPathAbsolute(srcpath)) {
    srcpath = path.relative(destdir, srcpath) || '.';
  }
  file.mkdir(destdir);
  var mode = grunt.file.isDir(srcpath) ? 'dir' : 'file';
  var destpath = path.join(destpath, fileBase);
  return fs.symlinkSync(srcpath, destpath, mode);
};

    "grunt-cli": "~1.4.3",
    "grunt-contrib-nodeunit": "~4.0.0",
    'symbolicLinkCopy': function(test) {
      test.expect(4);
      var srcfile = new Tempdir();
      fs.symlinkSync(path.resolve('test/fixtures/octocat.png'), path.join(srcfile.path, 'octocat.png'), 'file');
      // test symlink copy for files
      var destdir = new Tempdir();
      grunt.file.copy(path.join(srcfile.path, 'octocat.png'), destdir.path);
      test.ok(fs.lstatSync(path.join(srcfile.path, 'octocat.png')).isSymbolicLink());
      test.ok(fs.lstatSync(path.join(destdir.path, 'octocat.png')).isSymbolicLink());

      // test symlink copy for directories
      var srcdir = new Tempdir();
      var destdir = new Tempdir();
      var fixtures = path.resolve('test/fixtures');
      var symlinkSource = path.join(srcdir.path, path.basename(fixtures));
      console.log('symlinkSource', symlinkSource);
      fs.symlinkSync(fixtures, symlinkSource, 'dir');

      grunt.file.copy(symlinkSource, destdir.path);
      test.ok(fs.lstatSync(symlinkSource).isSymbolicLink());
      test.ok(fs.lstatSync(path.join(destdir.path, path.basename(fixtures))).isSymbolicLink());
      test.done();
    },