CVE-2021-27886: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') (CWE-78)
value: rakibtg Docker Dashboard before 2021-02-28 allows command injection in backend/utilities/terminal.js via shell metacharacters in the command parameter of an API request. NOTE: this is NOT a Docker, Inc. product.
CWE description: The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component.
CVSS v3 base score: 9.8
Published: 2021-03-02T00:15Z
Repository: https://github.com/rakibtg/docker-web-gui
Language: JavaScript
Files changed: backend/utilities/terminal.js
Commit date: 2021-02-28 17:16:54 +0600
Commit message: Fixed security issue with terminal by introducing controlled method that execute queries

Diff: diff --git a/backend/utilities/terminal.js b/backend/utilities/terminal.js
index 43d6057..0577f3d 100644
--- a/backend/utilities/terminal.js
+++ b/backend/utilities/terminal.js
@@ -1,8 +1,76 @@
-const child_process = require( 'child_process' )
+const child_process = require("child_process");
 
-exports.Terminal = command => new Promise((resolve, reject) => {
-  child_process.exec(command, {maxBuffer : 1500 * 1024}, function(error, stdout, stderr) {
-    if( !!error ) reject( error )
-    else resolve( stdout || stderr )
-  })
-})
\ No newline at end of file
+const isValidId = (id) => /^[0-9a-zA-Z]+$/.test(id.trim());
+const isValidString = (id) => /^[a-zA-Z]+$/.test(id.trim());
+
+const Terminal = (command) =>
+  new Promise((resolve, reject) => {
+    child_process.exec(
+      command,
+      { maxBuffer: 1500 * 1024 },
+      function (error, stdout, stderr) {
+        if (!!error) reject(error);
+        else resolve(stdout || stderr);
+      }
+    );
+  });
+
+exports.safeTerminal = {
+  installModules: async (backendPath) => {
+    await Terminal(`cd ${backendPath} && npm install`);
+  },
+  serve: async (backendPath) => {
+    await Terminal(`cd ${backendPath} && node index.js`);
+  },
+  allContainers: () => Terminal(`docker ps -q -a`),
+  inspectContainer: async (id) => {
+    if (isValidId(id)) {
+      return Terminal(`docker container inspect ${id}`);
+    } else {
+      throw new Error("The container id is invalid");
+    }
+  },
+  generic: async (task, id) => {
+    if (!isValidString(task)) {
+      throw new Error("The task command is invalid.");
+    }
+    if (!isValidId(id)) {
+      throw new Error("The container id is invalid");
+    }
+    return Terminal(`docker container ${task} ${id}`);
+  },
+  logs: async (id) => {
+    if (!isValidId(id)) {
+      throw new Error("The container id is invalid");
+    }
+    return Terminal(`docker container logs ${id} --tail 1500`);
+  },
+  stats: () =>
+    Terminal(
+      `docker container stats --no-stream --format '{"id": "{{.ID}}", "cpu_percentage": "{{.CPUPerc}}", "memory_usage": "{{.MemUsage}}", "network_io": "{{.NetIO}}"}'`
+    ),
+  prune: (pruneType) => {
+    if (!isValidString(pruneType)) {
+      throw new Error("The entity type is not valid");
+    }
+    return Terminal(`docker ${pruneType} prune -f`);
+  },
+  containerLs: () => Terminal(`docker container ls --format '{{json .}}'`),
+  formattedImages: () =>
+    Terminal(
+      `docker images --format '{"ID": "{{.ID}}", "Tag": "{{.Tag}}", "CreatedSince": "{{.CreatedSince}}", "Size": "{{.Size}}", "VirtualSize": "{{.VirtualSize}}", "Repository": "{{.Repository}}"}'`
+    ),
+  singleImage: (task, id) => {
+    if (!isValidString(task)) {
+      throw new Error("The task command is invalid.");
+    }
+    if (!isValidId(id)) {
+      throw new Error("The image id is invalid");
+    }
+    if (task == "run") {
+      return Terminal(`docker ${task} ${id}`);
+    } else {
+      return Terminal(`docker image ${task} ${id}`);
+    }
+  },
+};

Vulnerable code: const child_process = require( 'child_process' )
exports.Terminal = command => new Promise((resolve, reject) => {
  child_process.exec(command, {maxBuffer : 1500 * 1024}, function(error, stdout, stderr) {
    if( !!error ) reject( error )
    else resolve( stdout || stderr )
  })
})
Fixed code: const child_process = require("child_process");
const isValidId = (id) => /^[0-9a-zA-Z]+$/.test(id.trim());
const isValidString = (id) => /^[a-zA-Z]+$/.test(id.trim());

const Terminal = (command) =>
  new Promise((resolve, reject) => {
    child_process.exec(
      command,
      { maxBuffer: 1500 * 1024 },
      function (error, stdout, stderr) {
        if (!!error) reject(error);
        else resolve(stdout || stderr);
      }
    );
  });

exports.safeTerminal = {
  installModules: async (backendPath) => {
    await Terminal(`cd ${backendPath} && npm install`);
  },
  serve: async (backendPath) => {
    await Terminal(`cd ${backendPath} && node index.js`);
  },
  allContainers: () => Terminal(`docker ps -q -a`),
  inspectContainer: async (id) => {
    if (isValidId(id)) {
      return Terminal(`docker container inspect ${id}`);
    } else {
      throw new Error("The container id is invalid");
    }
  },
  generic: async (task, id) => {
    if (!isValidString(task)) {
      throw new Error("The task command is invalid.");
    }
    if (!isValidId(id)) {
      throw new Error("The container id is invalid");
    }
    return Terminal(`docker container ${task} ${id}`);
  },
  logs: async (id) => {
    if (!isValidId(id)) {
      throw new Error("The container id is invalid");
    }
    return Terminal(`docker container logs ${id} --tail 1500`);
  },
  stats: () =>
    Terminal(
      `docker container stats --no-stream --format '{"id": "{{.ID}}", "cpu_percentage": "{{.CPUPerc}}", "memory_usage": "{{.MemUsage}}", "network_io": "{{.NetIO}}"}'`
    ),
  prune: (pruneType) => {
    if (!isValidString(pruneType)) {
      throw new Error("The entity type is not valid");
    }
    return Terminal(`docker ${pruneType} prune -f`);
  },
  containerLs: () => Terminal(`docker container ls --format '{{json .}}'`),
  formattedImages: () =>
    Terminal(
      `docker images --format '{"ID": "{{.ID}}", "Tag": "{{.Tag}}", "CreatedSince": "{{.CreatedSince}}", "Size": "{{.Size}}", "VirtualSize": "{{.VirtualSize}}", "Repository": "{{.Repository}}"}'`
    ),
  singleImage: (task, id) => {
    if (!isValidString(task)) {
      throw new Error("The task command is invalid.");
    }
    if (!isValidId(id)) {
      throw new Error("The image id is invalid");
    }
    if (task == "run") {
      return Terminal(`docker ${task} ${id}`);
    } else {
      return Terminal(`docker image ${task} ${id}`);
    }
  },
};