CVE-2021-21298: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: Node-Red is a low-code programming for event-driven applications built using nodejs. Node-RED 1.2.7 and earlier has a vulnerability which allows arbitrary path traversal via the Projects API. If the Projects feature is enabled, a user with `projects.read` permission is able to access any file via the Projects API. The issue has been patched in Node-RED 1.2.8. The vulnerability applies only to the Projects feature which is not enabled by default in Node-RED. The primary workaround is not give untrusted users read access to the Node-RED editor.
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: 6.5
Published: 2021-02-26T17:15Z
Repository: https://github.com/node-red/node-red
Language: JavaScript
Files changed: packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/Project.js, packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js
Commit date: 2021-02-01 13:39:39 +0000
Commit message: Restrict project file access to inside the project directory

Diff: diff --git a/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/Project.js b/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/Project.js
index 0fbf89de..4cef78b0 100644
--- a/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/Project.js
+++ b/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/Project.js
@@ -303,10 +303,13 @@ Project.prototype.update = function (user, data) {
             // or it does exist and we need to load it.
             if (!/package\.json$/.test(data.files.package)) {
                 return Promise.reject("Invalid package file: "+data.files.package)
             }
             var root = data.files.package.substring(0,data.files.package.length-12);
+            if (/^\.\./.test(fspath.relative(this.path,fspath.join(this.path,data.files.package)))) {
+                return Promise.reject("Invalid package file: "+data.files.package)
+            }
             this.paths.root = root;
             this.paths['package.json'] = data.files.package;
             globalProjectSettings.projects[this.name].rootPath = root;
             saveSettings = true;
             // 1. check if it exists
@@ -320,16 +323,22 @@ Project.prototype.update = function (user, data) {
             reloadProject = true;
             flowFilesChanged = true;
         }
 
         if (data.files.hasOwnProperty('flow') && this.package['node-red'].settings.flowFile !== data.files.flow.substring(this.paths.root.length)) {
+            if (/^\.\./.test(fspath.relative(this.path,fspath.join(this.path,data.files.flow)))) {
+                return Promise.reject("Invalid flow file: "+data.files.flow)
+            }
             this.paths.flowFile = data.files.flow;
             this.package['node-red'].settings.flowFile = data.files.flow.substring(this.paths.root.length);
             savePackage = true;
             flowFilesChanged = true;
         }
         if (data.files.hasOwnProperty('credentials') && this.package['node-red'].settings.credentialsFile !== data.files.credentials.substring(this.paths.root.length)) {
+            if (/^\.\./.test(fspath.relative(this.path,fspath.join(this.path,data.files.credentials)))) {
+                return Promise.reject("Invalid credentials file: "+data.files.credentials)
+            }
             this.paths.credentialsFile = data.files.credentials;
             this.package['node-red'].settings.credentialsFile = data.files.credentials.substring(this.paths.root.length);
             // Don't know if the credSecret is invalid or not so clear the flag
             delete this.credentialSecretInvalid;
             savePackage = true;
@@ -488,10 +497,14 @@ Project.prototype.getCommit = function(sha) {
 
 Project.prototype.getFile = function (filePath,treeish) {
     if (treeish !== "_") {
         return gitTools.getFile(this.path, filePath, treeish);
     } else {
+        let fullPath = fspath.join(this.path,filePath);
+        if (/^\.\./.test(fspath.relative(this.path,fullPath))) {
+            throw new Error("Invalid file name")
+        }
         return fs.readFile(fspath.join(this.path,filePath),"utf8");
     }
 };
 
 Project.prototype.revertFile = function (filePath) {
@@ -637,10 +650,15 @@ Project.prototype.pull = function (user,remoteBranchName,setRemote,allowUnrelate
     }
 };
 
 Project.prototype.resolveMerge = function (file,resolutions) {
     var filePath = fspath.join(this.path,file);
+
+    if (/^\.\./.test(fspath.relative(this.path,filePath))) {
+        throw new Error("Invalid file name")
+    }
+
     var self = this;
     if (typeof resolutions === 'string') {
         return util.writeFile(filePath, resolutions).then(function() {
             return self.stageFile(file);
         })
@@ -1060,11 +1078,11 @@ function loadProject(projectPath) {
     });
 }
 function init(_settings, _runtime) {
     settings = _settings;
     runtime = _runtime;
-    projectsDir = fspath.join(settings.userDir,"projects");
+    projectsDir = fspath.resolve(fspath.join(settings.userDir,"projects"));
     authCache.init();
 }
 
 module.exports = {
     init: init,
diff --git a/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js b/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js
index 5e1d4f15..24f18b09 100644
--- a/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js
+++ b/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js
@@ -108,11 +108,11 @@ function init(_settings, _runtime) {
                     }
                 } else {
                     globalGitUser = gitConfig.user;
                     Projects.init(settings,runtime);
                     sshTools.init(settings);
-                    projectsDir = fspath.join(settings.userDir,"projects");
+                    projectsDir = fspath.resolve(fspath.join(settings.userDir,"projects"));
                     if (!settings.readOnly) {
                         return fs.ensureDir(projectsDir)
                         //TODO: this is accessing settings from storage directly as settings
                         //      has not yet been initialised. That isn't ideal - can this be deferred?
                         .then(storageSettings.getSettings)
@@ -205,13 +205,20 @@ function getBackupFilename(filename) {
     var ffDir = fspath.dirname(filename);
     return fspath.join(ffDir,"."+ffName+".backup");
 }
 
 function loadProject(name) {
+    let fullPath = fspath.resolve(fspath.join(projectsDir,name));
     var projectPath = name;
     if (projectPath.indexOf(fspath.sep) === -1) {
-        projectPath = fspath.join(projectsDir,name);
+        projectPath = fullPath;
+    } else {
+        // Ensure this project dir is under projectsDir;
+        let relativePath = fspath.relative(projectsDir,fullPath);
+        if (/^\.\./.test(relativePath)) {
+            throw new Error("Invalid project name")
+        }
     }
     return Projects.load(projectPath).then(function(project) {
         activeProject = project;
         flowsFullPath = project.getFlowFile();
         flowsFileBackup = project.getFlowFileBackup();
@@ -232,10 +239,14 @@ function deleteProject(user, name) {
         var e = new Error("NLS: Can't delete the active project");
         e.code = "cannot_delete_active_project";
         throw e;
     }
     var projectPath = fspath.join(projectsDir,name);
+    let relativePath = fspath.relative(projectsDir,projectPath);
+    if (/^\.\./.test(relativePath)) {
+        throw new Error("Invalid project name")
+    }
     return Projects.delete(user, projectPath);
 }
 
 function checkActiveProject(project) {
     if (!activeProject || activeProject.name !== project) {
@@ -390,10 +401,14 @@ function createProject(user, metadata) {
         metadata.files.oldFlow = flowsFullPath;
         metadata.files.oldCredentials = credentialsFile;
         metadata.files.credentialSecret = currentEncryptionKey;
     }
     metadata.path = fspath.join(projectsDir,metadata.name);
+    if (/^\.\./.test(fspath.relative(projectsDir,metadata.path))) {
+        throw new Error("Invalid project name")
+    }
+
     return Projects.create(user, metadata).then(function(p) {
         return setActiveProject(user, p.name);
     }).then(function() {
         return getProject(user, metadata.name);
     })

Vulnerable code:     projectsDir = fspath.join(settings.userDir,"projects");
                    projectsDir = fspath.join(settings.userDir,"projects");
        projectPath = fspath.join(projectsDir,name);
Fixed code:             if (/^\.\./.test(fspath.relative(this.path,fspath.join(this.path,data.files.package)))) {
                return Promise.reject("Invalid package file: "+data.files.package)
            }
            if (/^\.\./.test(fspath.relative(this.path,fspath.join(this.path,data.files.flow)))) {
                return Promise.reject("Invalid flow file: "+data.files.flow)
            }
            if (/^\.\./.test(fspath.relative(this.path,fspath.join(this.path,data.files.credentials)))) {
                return Promise.reject("Invalid credentials file: "+data.files.credentials)
            }
        let fullPath = fspath.join(this.path,filePath);
        if (/^\.\./.test(fspath.relative(this.path,fullPath))) {
            throw new Error("Invalid file name")
        }

    if (/^\.\./.test(fspath.relative(this.path,filePath))) {
        throw new Error("Invalid file name")
    }

    projectsDir = fspath.resolve(fspath.join(settings.userDir,"projects"));
                    projectsDir = fspath.resolve(fspath.join(settings.userDir,"projects"));
    let fullPath = fspath.resolve(fspath.join(projectsDir,name));
        projectPath = fullPath;
    } else {
        // Ensure this project dir is under projectsDir;
        let relativePath = fspath.relative(projectsDir,fullPath);
        if (/^\.\./.test(relativePath)) {
            throw new Error("Invalid project name")
        }
    let relativePath = fspath.relative(projectsDir,projectPath);
    if (/^\.\./.test(relativePath)) {
        throw new Error("Invalid project name")
    }
    if (/^\.\./.test(fspath.relative(projectsDir,metadata.path))) {
        throw new Error("Invalid project name")
    }