CVE-2024-3149 (JavaScript, published 2024-06-06T19:16Z)
Description: value: A Server-Side Request Forgery (SSRF) vulnerability exists in the upload link feature of mintplex-labs/anything-llm. This feature, intended for users with manager or admin roles, processes uploaded links through an internal Collector API using a headless browser. An attacker can exploit this by hosting a malicious website and using it to perform actions such as internal port scanning, accessing internal web applications not exposed externally, and interacting with the Collector API. This interaction can lead to unauthorized actions such as arbitrary file deletion and limited Local File Inclusion (LFI), including accessing NGINX access logs which may contain sensitive information.
Severity: nan (CVSS v3 base score: )
Weakness: CWE-918 \u2014 Server-Side Request Forgery (SSRF)
The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
Repository: https://github.com/mintplex-labs/anything-llm
Files changed:
.vscode/settings.json
collector/extensions/index.js
collector/index.js
collector/middleware/verifyIntegrity.js
collector/processSingleFile/index.js
collector/utils/comKey/index.js
collector/utils/files/index.js
server/.gitignore
server/utils/boot/index.js
server/utils/collectorApi/index.js
server/utils/comKey/index.js
Commit message: RSA-Signing on server<->collector communication via API (#1005)

* WIP integrity check between processes

* Implement integrity checking on document processor payloads

Vulnerable code:
}
  app.post("/ext/github-repo", async function (request, response) {
  app.post("/ext/github-repo/branches", async function (request, response) {
  app.post("/ext/youtube-transcript", async function (request, response) {
app.post("/process", async function (request, response) {
  const { filename, options = {} } = reqBody(request);
  try {
    const targetFilename = path
      .normalize(filename)
      .replace(/^(\.\.(\/|\\|$))+/, "");
    const {
      success,
      reason,
      documents = [],
    } = await processSingleFile(targetFilename, options);
    response
      .status(200)
      .json({ filename: targetFilename, success, reason, documents });
  } catch (e) {
    console.error(e);
    response.status(200).json({
      filename: filename,
      success: false,
      reason: "A processing error occurred.",
      documents: [],
    });
  return;
});
app.post("/process-link", async function (request, response) {
  const { link } = reqBody(request);
  try {
    const { success, reason, documents = [] } = await processLink(link);
    response.status(200).json({ url: link, success, reason, documents });
  } catch (e) {
    console.error(e);
    response.status(200).json({
      url: link,
      success: false,
      reason: "A processing error occurred.",
      documents: [],
    });
  return;
});
app.post("/process-raw-text", async function (request, response) {
  const { textContent, metadata } = reqBody(request);
  try {
    const {
      success,
      reason,
      documents = [],
    } = await processRawText(textContent, metadata);
    response
      .status(200)
      .json({ filename: metadata.title, success, reason, documents });
  } catch (e) {
    console.error(e);
    response.status(200).json({
      filename: metadata?.title || "Unknown-doc.txt",
      success: false,
      reason: "A processing error occurred.",
      documents: [],
    });
  return;
});
const { trashFile, isTextType } = require("../utils/files");
  const fullFilePath = path.resolve(WATCH_DIRECTORY, targetFilename);
      body: JSON.stringify({
        filename,
        options: this.#attachOptions(),
      }),
      body: JSON.stringify({ link }),
      body: JSON.stringify({ textContent, metadata }),

Fixed code:
    "comkey",
}
const { verifyPayloadIntegrity } = require("../middleware/verifyIntegrity");
  app.post("/ext/github-repo", [verifyPayloadIntegrity], async function (request, response) {
  app.post("/ext/github-repo/branches", [verifyPayloadIntegrity], async function (request, response) {
  app.post("/ext/youtube-transcript", [verifyPayloadIntegrity], async function (request, response) {
const { verifyPayloadIntegrity } = require("./middleware/verifyIntegrity");
app.post(
  "/process",
  [verifyPayloadIntegrity],
  async function (request, response) {
    const { filename, options = {} } = reqBody(request);
    try {
      const targetFilename = path
        .normalize(filename)
        .replace(/^(\.\.(\/|\\|$))+/, "");
      const {
        success,
        reason,
        documents = [],
      } = await processSingleFile(targetFilename, options);
      response
        .status(200)
        .json({ filename: targetFilename, success, reason, documents });
    } catch (e) {
      console.error(e);
      response.status(200).json({
        filename: filename,
        success: false,
        reason: "A processing error occurred.",
        documents: [],
      });
    }
    return;
);
app.post(
  "/process-link",
  [verifyPayloadIntegrity],
  async function (request, response) {
    const { link } = reqBody(request);
    try {
      const { success, reason, documents = [] } = await processLink(link);
      response.status(200).json({ url: link, success, reason, documents });
    } catch (e) {
      console.error(e);
      response.status(200).json({
        url: link,
        success: false,
        reason: "A processing error occurred.",
        documents: [],
      });
    }
    return;
);
app.post(
  "/process-raw-text",
  [verifyPayloadIntegrity],
  async function (request, response) {
    const { textContent, metadata } = reqBody(request);
    try {
      const {
        success,
        reason,
        documents = [],
      } = await processRawText(textContent, metadata);
      response
        .status(200)
        .json({ filename: metadata.title, success, reason, documents });
    } catch (e) {
      console.error(e);
      response.status(200).json({
        filename: metadata?.title || "Unknown-doc.txt",
        success: false,
        reason: "A processing error occurred.",
        documents: [],
      });
    }
    return;
);
const { CommunicationKey } = require("../utils/comKey");

function verifyPayloadIntegrity(request, response, next) {
  const comKey = new CommunicationKey();
  if (process.env.NODE_ENV === "development") {
    comKey.log('verifyPayloadIntegrity is skipped in development.')
    next();
    return;
  }

  const signature = request.header("X-Integrity");
  if (!signature) return response.status(400).json({ msg: 'Failed integrity signature check.' })

  const validSignedPayload = comKey.verify(signature, request.body);
  if (!validSignedPayload) return response.status(400).json({ msg: 'Failed integrity signature check.' })
  next();
}

module.exports = {
  verifyPayloadIntegrity
}
const {
  trashFile,
  isTextType,
  normalizePath,
  isWithin,
} = require("../utils/files");
  const fullFilePath = path.resolve(
    WATCH_DIRECTORY,
    normalizePath(targetFilename)
  );
  if (!isWithin(path.resolve(WATCH_DIRECTORY), fullFilePath))
    return {
      success: false,
      reason: "Filename is a not a valid path to process.",
      documents: [],
    };

const crypto = require("crypto");
const fs = require("fs");
const path = require("path");

const keyPath =
  process.env.NODE_ENV === "development"
    ? path.resolve(__dirname, `../../../server/storage/comkey`)
    : path.resolve(process.env.STORAGE_DIR, `comkey`);

class CommunicationKey {
  #pubKeyName = "ipc-pub.pem";
  #storageLoc = keyPath;

  constructor() {}

  log(text, ...args) {
    console.log(`\x1b[36m[CommunicationKeyVerify]\x1b[0m ${text}`, ...args);
  }

  #readPublicKey() {
    return fs.readFileSync(path.resolve(this.#storageLoc, this.#pubKeyName));
  }

  // Given a signed payload from private key from /app/server/ this signature should
  // decode to match the textData provided. This class does verification only in collector.
  // Note: The textData is typically the JSON stringified body sent to the document processor API.
  verify(signature = "", textData = "") {
    try {
      let data = textData;
      if (typeof textData !== "string") data = JSON.stringify(data);
      return crypto.verify(
        "RSA-SHA256",
        Buffer.from(data),
        this.#readPublicKey(),
        Buffer.from(signature, "hex")
      );
    } catch {}
    return false;
  }
}

module.exports = { CommunicationKey };
/**
 * 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 = "") {
  const result = path
    .normalize(filepath.trim())
    .replace(/^(\.\.(\/|\\|$))+/, "")
    .trim();
  if (["..", ".", "/"].includes(result)) throw new Error("Invalid path.");
  return result;
}

  normalizePath,
  isWithin,
storage/comkey/*
const { CommunicationKey } = require("../comKey");
        new CommunicationKey(true);
      new CommunicationKey(true);
    const { CommunicationKey } = require("../comKey");
    this.comkey = new CommunicationKey();

    const data = JSON.stringify({
      filename,
      options: this.#attachOptions(),
    });

        "X-Integrity": this.comkey.sign(data),
      body: data,
    const data = JSON.stringify({ link });
        "X-Integrity": this.comkey.sign(data),
      body: data,
    const data = JSON.stringify({ textContent, metadata });
        "X-Integrity": this.comkey.sign(data),
      body: data,
        "X-Integrity": this.comkey.sign(body),
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const keyPath =
  process.env.NODE_ENV === "development"
    ? path.resolve(__dirname, `../../storage/comkey`)
    : path.resolve(process.env.STORAGE_DIR, `comkey`);

// What does this class do?
// This class generates a hashed version of some text (typically a JSON payload) using a rolling RSA key
// that can then be appended as a header value to do integrity checking on a payload. Given the
// nature of this class and that keys are rolled constantly, this protects the request
// integrity of requests sent to the collector as only the server can sign these requests.
// This keeps accidental misconfigurations of AnythingLLM that leaving port 8888 open from
// being abused or SSRF'd by users scraping malicious sites who have a loopback embedded in a <script>, for example.
// Since each request to the collector must be signed to be valid, unsigned requests directly to the collector
// will be dropped and must go through the /server endpoint directly.
class CommunicationKey {
  #privKeyName = "ipc-priv.pem";
  #pubKeyName = "ipc-pub.pem";
  #storageLoc = keyPath;

  // Init the class and determine if keys should be rolled.
  // This typically occurs on boot up so key is fresh each boot.
  constructor(generate = false) {
    if (generate) this.#generate();
  }

  log(text, ...args) {
    console.log(`\x1b[36m[CommunicationKey]\x1b[0m ${text}`, ...args);
  }

  #readPrivateKey() {
    return fs.readFileSync(path.resolve(this.#storageLoc, this.#privKeyName));
  }

  #generate() {
    const keyPair = crypto.generateKeyPairSync("rsa", {
      modulusLength: 2048,
      publicKeyEncoding: {
        type: "pkcs1",
        format: "pem",
      },
      privateKeyEncoding: {
        type: "pkcs1",
        format: "pem",
      },
    });

    if (!fs.existsSync(this.#storageLoc))
      fs.mkdirSync(this.#storageLoc, { recursive: true });
    fs.writeFileSync(
      `${path.resolve(this.#storageLoc, this.#privKeyName)}`,
      keyPair.privateKey
    );
    fs.writeFileSync(
      `${path.resolve(this.#storageLoc, this.#pubKeyName)}`,
      keyPair.publicKey
    );
    this.log(
      "RSA key pair generated for signed payloads within AnythingLLM services."
    );
  }

  // This instance of ComKey on server is intended for generation of Priv/Pub key for signing and decoding.
  // this resource is shared with /collector/ via a class of the same name in /utils which does decoding/verification only
  // while this server class only does signing with the private key.
  sign(textData = "") {
    return crypto
      .sign("RSA-SHA256", Buffer.from(textData), this.#readPrivateKey())
      .toString("hex");
  }
}

module.exports = { CommunicationKey };