CVE-2024-0763: Improper Input Validation (CWE-20)
value: Any user can delete an arbitrary folder (recursively) on a remote server due to bad input sanitization leading to path traversal. The attacker would need access to the server at some privilege level since this endpoint is protected and requires authorization.
CWE description: The product receives input or data, but it does
        not validate or incorrectly validates that the input has the
        properties that are required to process the data safely and
        correctly.
Published: 2024-02-27T22:15Z
Repository: https://github.com/mintplex-labs/anything-llm
Language: JavaScript
Files changed: server/utils/files/index.js, server/utils/files/purgeDocument.js
Commit date: 2024-01-19 12:56:00 -0800
Commit message: Employ strict validations on document pathing (#627)

* Employ strict validations on document pathing

* add comment

* update validSubfolder var
Diff: diff --git a/server/utils/files/index.js b/server/utils/files/index.js
index e713a318a..dff5bef9c 100644
--- a/server/utils/files/index.js
+++ b/server/utils/files/index.js
@@ -1,54 +1,41 @@
 const fs = require("fs");
 const path = require("path");
 const { v5: uuidv5 } = require("uuid");
+const documentsPath =
+  process.env.NODE_ENV === "development"
+    ? path.resolve(__dirname, `../../storage/documents`)
+    : path.resolve(process.env.STORAGE_DIR, `documents`);
+const vectorCachePath =
+  process.env.NODE_ENV === "development"
+    ? path.resolve(__dirname, `../../storage/vector-cache`)
+    : path.resolve(process.env.STORAGE_DIR, `vector-cache`);
 
 // Should take in a folder that is a subfolder of documents
 // eg: youtube-subject/video-123.json
 async function fileData(filePath = null) {
   if (!filePath) throw new Error("No docPath provided in request");
+  const fullFilePath = path.resolve(documentsPath, normalizePath(filePath));
+  if (!fs.existsSync(fullFilePath) || !isWithin(documentsPath, fullFilePath))
+    return null;
 
-  const fullPath =
-    process.env.NODE_ENV === "development"
-      ? path.resolve(
-          __dirname,
-          `../../storage/documents/${normalizePath(filePath)}`
-        )
-      : path.resolve(
-          process.env.STORAGE_DIR,
-          `documents/${normalizePath(filePath)}`
-        );
-
-  const fileExists = fs.existsSync(fullPath);
-  if (!fileExists) return null;
-
-  const data = fs.readFileSync(fullPath, "utf8");
+  const data = fs.readFileSync(fullFilePath, "utf8");
   return JSON.parse(data);
 }
 
 async function viewLocalFiles() {
-  const folder =
-    process.env.NODE_ENV === "development"
-      ? path.resolve(__dirname, `../../storage/documents`)
-      : path.resolve(process.env.STORAGE_DIR, `documents`);
-  const dirExists = fs.existsSync(folder);
-  if (!dirExists) fs.mkdirSync(folder);
+  if (!fs.existsSync(documentsPath)) fs.mkdirSync(documentsPath);
 
   const directory = {
     name: "documents",
     type: "folder",
     items: [],
   };
 
-  for (const file of fs.readdirSync(folder)) {
+  for (const file of fs.readdirSync(documentsPath)) {
     if (path.extname(file) === ".md") continue;
-
-    const folderPath =
-      process.env.NODE_ENV === "development"
-        ? path.resolve(__dirname, `../../storage/documents/${file}`)
-        : path.resolve(process.env.STORAGE_DIR, `documents/${file}`);
-
+    const folderPath = path.resolve(documentsPath, file);
     const isFolder = fs.lstatSync(folderPath).isDirectory();
     if (isFolder) {
       const subdocs = {
         name: file,
         type: "folder",
@@ -81,14 +68,11 @@ async function viewLocalFiles() {
 // document and can instead push directly to vector db.
 async function cachedVectorInformation(filename = null, checkOnly = false) {
   if (!filename) return checkOnly ? false : { exists: false, chunks: [] };
 
   const digest = uuidv5(filename, uuidv5.URL);
-  const file =
-    process.env.NODE_ENV === "development"
-      ? path.resolve(__dirname, `../../storage/vector-cache/${digest}.json`)
-      : path.resolve(process.env.STORAGE_DIR, `vector-cache/${digest}.json`);
+  const file = path.resolve(vectorCachePath, `${digest}.json`);
   const exists = fs.existsSync(file);
 
   if (checkOnly) return exists;
   if (!exists) return { exists, chunks: [] };
 
@@ -104,83 +88,65 @@ async function cachedVectorInformation(filename = null, checkOnly = false) {
 async function storeVectorResult(vectorData = [], filename = null) {
   if (!filename) return;
   console.log(
     `Caching vectorized results of ${filename} to prevent duplicated embedding.`
   );
-  const folder =
-    process.env.NODE_ENV === "development"
-      ? path.resolve(__dirname, `../../storage/vector-cache`)
-      : path.resolve(process.env.STORAGE_DIR, `vector-cache`);
-
-  if (!fs.existsSync(folder)) fs.mkdirSync(folder);
+  if (!fs.existsSync(vectorCachePath)) fs.mkdirSync(vectorCachePath);
 
   const digest = uuidv5(filename, uuidv5.URL);
-  const writeTo = path.resolve(folder, `${digest}.json`);
+  const writeTo = path.resolve(vectorCachePath, `${digest}.json`);
   fs.writeFileSync(writeTo, JSON.stringify(vectorData), "utf8");
   return;
 }
 
 // Purges a file from the documents/ folder.
 async function purgeSourceDocument(filename = null) {
   if (!filename) return;
+  const filePath = path.resolve(documentsPath, normalizePath(filename));
+
+  if (
+    !fs.existsSync(filePath) ||
+    !isWithin(documentsPath, filePath) ||
+    !fs.lstatSync(filePath).isFile()
+  )
+    return;
+
   console.log(`Purging source document of ${filename}.`);
-  const filePath =
-    process.env.NODE_ENV === "development"
-      ? path.resolve(
-          __dirname,
-          `../../storage/documents`,
-          normalizePath(filename)
-        )
-      : path.resolve(
-          process.env.STORAGE_DIR,
-          `documents`,
-          normalizePath(filename)
-        );
-
-  if (!fs.existsSync(filePath)) return;
   fs.rmSync(filePath);
   return;
 }
 
 // Purges a vector-cache file from the vector-cache/ folder.
 async function purgeVectorCache(filename = null) {
   if (!filename) return;
-  console.log(`Purging vector-cache of ${filename}.`);
-
   const digest = uuidv5(filename, uuidv5.URL);
-  const filePath =
-    process.env.NODE_ENV === "development"
-      ? path.resolve(__dirname, `../../storage/vector-cache`, `${digest}.json`)
-      : path.resolve(process.env.STORAGE_DIR, `vector-cache`, `${digest}.json`);
+  const filePath = path.resolve(vectorCachePath, `${digest}.json`);
 
-  if (!fs.existsSync(filePath)) return;
+  if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isFile()) return;
+  console.log(`Purging vector-cache of ${filename}.`);
   fs.rmSync(filePath);
   return;
 }
 
 // Search for a specific document by its unique name in the entire `documents`
 // folder via iteration of all folders and checking if the expected file exists.
 async function findDocumentInDocuments(documentName = null) {
   if (!documentName) return null;
-  const documentsFolder =
-    process.env.NODE_ENV === "development"
-      ? path.resolve(__dirname, `../../storage/documents`)
-      : path.resolve(process.env.STORAGE_DIR, `documents`);
-
-  for (const folder of fs.readdirSync(documentsFolder)) {
+  for (const folder of fs.readdirSync(documentsPath)) {
     const isFolder = fs
-      .lstatSync(path.join(documentsFolder, folder))
+      .lstatSync(path.join(documentsPath, folder))
       .isDirectory();
     if (!isFolder) continue;
 
     const targetFilename = normalizePath(documentName);
-    const targetFileLocation = path.join(
-      documentsFolder,
-      folder,
-      targetFilename
-    );
-    if (!fs.existsSync(targetFileLocation)) continue;
+    const targetFileLocation = path.join(documentsPath, folder, targetFilename);
+
+    if (
+      !fs.existsSync(targetFileLocation) ||
+      !isWithin(documentsPath, targetFileLocation)
+    )
+      continue;
 
     const fileData = fs.readFileSync(targetFileLocation, "utf8");
     const cachefilename = `${folder}/${targetFilename}`;
     const { pageContent, ...metadata } = JSON.parse(fileData);
     return {
@@ -192,12 +158,29 @@ async function findDocumentInDocuments(documentName = null) {
   }
 
   return null;
 }
 
+/**
+ * Checks if a given path is within another path.
+ * @param {string} outer - The outer path (should be resolved).
+ * @param {string} inner - The inner path (should be resolved).
+ * @returns {boolean} - Returns true if the inner path is within the outer path, false otherwise.
+ */
+function isWithin(outer, inner) {
+  if (outer === inner) return false;
+  const rel = path.relative(outer, inner);
+  return !rel.startsWith("../") && rel !== "..";
+}
+
 function normalizePath(filepath = "") {
-  return path.normalize(filepath).replace(/^(\.\.(\/|\\|$))+/, "");
+  const result = path
+    .normalize(filepath.trim())
+    .replace(/^(\.\.(\/|\\|$))+/, "")
+    .trim();
+  if (["..", ".", "/"].includes(result)) throw new Error("Invalid path.");
+  return result;
 }
 
 module.exports = {
   findDocumentInDocuments,
   cachedVectorInformation,
@@ -205,6 +188,8 @@ module.exports = {
   purgeSourceDocument,
   purgeVectorCache,
   storeVectorResult,
   fileData,
   normalizePath,
+  isWithin,
+  documentsPath,
 };
diff --git a/server/utils/files/purgeDocument.js b/server/utils/files/purgeDocument.js
index 46e9d37da..7f32bd427 100644
--- a/server/utils/files/purgeDocument.js
+++ b/server/utils/files/purgeDocument.js
@@ -1,32 +1,55 @@
 const fs = require("fs");
 const path = require("path");
-const { purgeVectorCache, purgeSourceDocument, normalizePath } = require(".");
+const {
+  purgeVectorCache,
+  purgeSourceDocument,
+  normalizePath,
+  isWithin,
+  documentsPath,
+} = require(".");
 const { Document } = require("../../models/documents");
 const { Workspace } = require("../../models/workspace");
 
-async function purgeDocument(filename) {
+async function purgeDocument(filename = null) {
+  if (!filename || !normalizePath(filename)) return;
+
+  await purgeVectorCache(filename);
+  await purgeSourceDocument(filename);
   const workspaces = await Workspace.where();
   for (const workspace of workspaces) {
     await Document.removeDocuments(workspace, [filename]);
   }
-  await purgeVectorCache(filename);
-  await purgeSourceDocument(filename);
   return;
 }
 
-async function purgeFolder(folderName) {
-  if (folderName === "custom-documents") return;
-  const documentsFolder =
-    process.env.NODE_ENV === "development"
-      ? path.resolve(__dirname, `../../storage/documents`)
-      : path.resolve(process.env.STORAGE_DIR, `documents`);
+async function purgeFolder(folderName = null) {
+  if (!folderName) return;
+  const subFolder = normalizePath(folderName);
+  const subFolderPath = path.resolve(documentsPath, subFolder);
+  const validRemovableSubFolders = fs
+    .readdirSync(documentsPath)
+    .map((folder) => {
+      // Filter out any results which are not folders or
+      // are the protected custom-documents folder.
+      if (folder === "custom-documents") return null;
+      const subfolderPath = path.resolve(documentsPath, folder);
+      if (!fs.lstatSync(subfolderPath).isDirectory()) return null;
+      return folder;
+    })
+    .filter((subFolder) => !!subFolder);
+
+  if (
+    !validRemovableSubFolders.includes(subFolder) ||
+    !fs.existsSync(subFolderPath) ||
+    !isWithin(documentsPath, subFolderPath)
+  )
+    return;
 
-  const folderPath = path.resolve(documentsFolder, normalizePath(folderName));
   const filenames = fs
-    .readdirSync(folderPath)
-    .map((file) => path.join(folderPath, file));
+    .readdirSync(subFolderPath)
+    .map((file) => path.join(subFolderPath, file));
   const workspaces = await Workspace.where();
 
   const purgePromises = [];
   // Remove associated Vector-cache files
   for (const filename of filenames) {
@@ -45,11 +68,11 @@ async function purgeFolder(folderName) {
       );
     purgePromises.push(rmWorkspaceDoc);
   }
 
   await Promise.all(purgePromises.flat().map((f) => f()));
-  fs.rmSync(folderPath, { recursive: true }); // Delete root document and source files.
+  fs.rmSync(subFolderPath, { recursive: true }); // Delete target document-folder and source files.
 
   return;
 }
 
 module.exports = {

Vulnerable code:   const fullPath =
    process.env.NODE_ENV === "development"
      ? path.resolve(
          __dirname,
          `../../storage/documents/${normalizePath(filePath)}`
        )
      : path.resolve(
          process.env.STORAGE_DIR,
          `documents/${normalizePath(filePath)}`
        );

  const fileExists = fs.existsSync(fullPath);
  if (!fileExists) return null;

  const data = fs.readFileSync(fullPath, "utf8");
  const folder =
    process.env.NODE_ENV === "development"
      ? path.resolve(__dirname, `../../storage/documents`)
      : path.resolve(process.env.STORAGE_DIR, `documents`);
  const dirExists = fs.existsSync(folder);
  if (!dirExists) fs.mkdirSync(folder);
  for (const file of fs.readdirSync(folder)) {

    const folderPath =
      process.env.NODE_ENV === "development"
        ? path.resolve(__dirname, `../../storage/documents/${file}`)
        : path.resolve(process.env.STORAGE_DIR, `documents/${file}`);

  const file =
    process.env.NODE_ENV === "development"
      ? path.resolve(__dirname, `../../storage/vector-cache/${digest}.json`)
      : path.resolve(process.env.STORAGE_DIR, `vector-cache/${digest}.json`);
  const folder =
    process.env.NODE_ENV === "development"
      ? path.resolve(__dirname, `../../storage/vector-cache`)
      : path.resolve(process.env.STORAGE_DIR, `vector-cache`);

  if (!fs.existsSync(folder)) fs.mkdirSync(folder);
  const writeTo = path.resolve(folder, `${digest}.json`);
  const filePath =
    process.env.NODE_ENV === "development"
      ? path.resolve(
          __dirname,
          `../../storage/documents`,
          normalizePath(filename)
        )
      : path.resolve(
          process.env.STORAGE_DIR,
          `documents`,
          normalizePath(filename)
        );

  if (!fs.existsSync(filePath)) return;
  console.log(`Purging vector-cache of ${filename}.`);

  const filePath =
    process.env.NODE_ENV === "development"
      ? path.resolve(__dirname, `../../storage/vector-cache`, `${digest}.json`)
      : path.resolve(process.env.STORAGE_DIR, `vector-cache`, `${digest}.json`);
  if (!fs.existsSync(filePath)) return;
  const documentsFolder =
    process.env.NODE_ENV === "development"
      ? path.resolve(__dirname, `../../storage/documents`)
      : path.resolve(process.env.STORAGE_DIR, `documents`);

  for (const folder of fs.readdirSync(documentsFolder)) {
      .lstatSync(path.join(documentsFolder, folder))
    const targetFileLocation = path.join(
      documentsFolder,
      folder,
      targetFilename
    );
    if (!fs.existsSync(targetFileLocation)) continue;
  return path.normalize(filepath).replace(/^(\.\.(\/|\\|$))+/, "");
const { purgeVectorCache, purgeSourceDocument, normalizePath } = require(".");
async function purgeDocument(filename) {
  await purgeVectorCache(filename);
  await purgeSourceDocument(filename);
async function purgeFolder(folderName) {
  if (folderName === "custom-documents") return;
  const documentsFolder =
    process.env.NODE_ENV === "development"
      ? path.resolve(__dirname, `../../storage/documents`)
      : path.resolve(process.env.STORAGE_DIR, `documents`);
  const folderPath = path.resolve(documentsFolder, normalizePath(folderName));
    .readdirSync(folderPath)
    .map((file) => path.join(folderPath, file));
  fs.rmSync(folderPath, { recursive: true }); // Delete root document and source files.
Fixed code: const documentsPath =
  process.env.NODE_ENV === "development"
    ? path.resolve(__dirname, `../../storage/documents`)
    : path.resolve(process.env.STORAGE_DIR, `documents`);
const vectorCachePath =
  process.env.NODE_ENV === "development"
    ? path.resolve(__dirname, `../../storage/vector-cache`)
    : path.resolve(process.env.STORAGE_DIR, `vector-cache`);
  const fullFilePath = path.resolve(documentsPath, normalizePath(filePath));
  if (!fs.existsSync(fullFilePath) || !isWithin(documentsPath, fullFilePath))
    return null;
  const data = fs.readFileSync(fullFilePath, "utf8");
  if (!fs.existsSync(documentsPath)) fs.mkdirSync(documentsPath);
  for (const file of fs.readdirSync(documentsPath)) {
    const folderPath = path.resolve(documentsPath, file);
  const file = path.resolve(vectorCachePath, `${digest}.json`);
  if (!fs.existsSync(vectorCachePath)) fs.mkdirSync(vectorCachePath);
  const writeTo = path.resolve(vectorCachePath, `${digest}.json`);
  const filePath = path.resolve(documentsPath, normalizePath(filename));

  if (
    !fs.existsSync(filePath) ||
    !isWithin(documentsPath, filePath) ||
    !fs.lstatSync(filePath).isFile()
  )
    return;

  const filePath = path.resolve(vectorCachePath, `${digest}.json`);
  if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isFile()) return;
  console.log(`Purging vector-cache of ${filename}.`);
  for (const folder of fs.readdirSync(documentsPath)) {
      .lstatSync(path.join(documentsPath, folder))
    const targetFileLocation = path.join(documentsPath, folder, targetFilename);

    if (
      !fs.existsSync(targetFileLocation) ||
      !isWithin(documentsPath, targetFileLocation)
    )
      continue;
/**
 * Checks if a given path is within another path.
 * @param {string} outer - The outer path (should be resolved).
 * @param {string} inner - The inner path (should be resolved).
 * @returns {boolean} - Returns true if the inner path is within the outer path, false otherwise.
 */
function isWithin(outer, inner) {
  if (outer === inner) return false;
  const rel = path.relative(outer, inner);
  return !rel.startsWith("../") && rel !== "..";
}

  const result = path
    .normalize(filepath.trim())
    .replace(/^(\.\.(\/|\\|$))+/, "")
    .trim();
  if (["..", ".", "/"].includes(result)) throw new Error("Invalid path.");
  return result;
  isWithin,
  documentsPath,
const {
  purgeVectorCache,
  purgeSourceDocument,
  normalizePath,
  isWithin,
  documentsPath,
} = require(".");
async function purgeDocument(filename = null) {
  if (!filename || !normalizePath(filename)) return;

  await purgeVectorCache(filename);
  await purgeSourceDocument(filename);
async function purgeFolder(folderName = null) {
  if (!folderName) return;
  const subFolder = normalizePath(folderName);
  const subFolderPath = path.resolve(documentsPath, subFolder);
  const validRemovableSubFolders = fs
    .readdirSync(documentsPath)
    .map((folder) => {
      // Filter out any results which are not folders or
      // are the protected custom-documents folder.
      if (folder === "custom-documents") return null;
      const subfolderPath = path.resolve(documentsPath, folder);
      if (!fs.lstatSync(subfolderPath).isDirectory()) return null;
      return folder;
    })
    .filter((subFolder) => !!subFolder);

  if (
    !validRemovableSubFolders.includes(subFolder) ||
    !fs.existsSync(subFolderPath) ||
    !isWithin(documentsPath, subFolderPath)
  )
    return;
    .readdirSync(subFolderPath)
    .map((file) => path.join(subFolderPath, file));
  fs.rmSync(subFolderPath, { recursive: true }); // Delete target document-folder and source files.