CVE-2022-36065: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: GrowthBook is an open-source platform for feature flagging and A/B testing. With some self-hosted configurations in versions prior to 2022-08-29, attackers can register new accounts and upload files to arbitrary directories within the container. If the attacker uploads a Python script to the right location, they can execute arbitrary code within the container. To be affected, ALL of the following must be true: Self-hosted deployment (GrowthBook Cloud is unaffected); using local file uploads (as opposed to S3 or Google Cloud Storage); NODE_ENV set to a non-production value and JWT_SECRET set to an easily guessable string like `dev`. This issue is patched in commit 1a5edff8786d141161bf880c2fd9ccbe2850a264 (2022-08-29). As a workaround, set `JWT_SECRET` environment variable to a long random string. This will stop arbitrary file uploads, but the only way to stop attackers from registering accounts is by updating to the latest build.
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: 7.5
Published: 2022-09-06T21:15Z
Repository: https://github.com/growthbook/growthbook
Language: TypeScript
Files changed: packages/back-end/src/controllers/auth.ts, packages/back-end/src/services/files.ts, packages/back-end/src/util/secrets.ts
Commit date: 2022-08-29 16:30:29 -0500
Commit message: Secure file uploads when NODE_ENV=dev (#487)


Diff: diff --git a/packages/back-end/src/controllers/auth.ts b/packages/back-end/src/controllers/auth.ts
index 5035aaa..caf0bff 100644
--- a/packages/back-end/src/controllers/auth.ts
+++ b/packages/back-end/src/controllers/auth.ts
@@ -181,10 +181,18 @@ export async function postFirstTimeRegister(
       companyname: string;
     }
   >,
   res: Response
 ) {
+  // Only allow this API endpoint when it's a brand-new installation with no users yet
+  const newInstallation = await isNewInstallation();
+  if (!newInstallation) {
+    throw new Error(
+      "An organization is already configured. Please refresh the page and try again."
+    );
+  }
+
   const { email, name, password, companyname } = req.body;
 
   validatePasswordFormat(password);
   if (companyname.length < 3) {
     throw Error("Company length must be at least 3 characters");
diff --git a/packages/back-end/src/services/files.ts b/packages/back-end/src/services/files.ts
index 9660071..8258833 100644
--- a/packages/back-end/src/services/files.ts
+++ b/packages/back-end/src/services/files.ts
@@ -40,11 +40,25 @@ export async function uploadFile(
   const comp = getFileSignature(filePath);
   if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(comp))) {
     throw new Error("Invalid upload signature");
   }
 
-  const fullPath = getUploadsDir() + "/" + filePath;
+  // Watch out for poison null bytes
+  if (filePath.indexOf("\0") !== -1) {
+    throw new Error("Error: Filename must not contain null bytes");
+  }
+
+  const rootDirectory = getUploadsDir();
+  const fullPath = path.join(rootDirectory, filePath);
+
+  // Prevent directory traversal
+  if (fullPath.indexOf(rootDirectory) !== 0) {
+    throw new Error(
+      "Error: Path must not escape out of the 'uploads' directory."
+    );
+  }
+
   const dir = path.dirname(fullPath);
   await fs.promises.mkdir(dir, { recursive: true });
   await fs.promises.writeFile(fullPath, contents);
 }
 
diff --git a/packages/back-end/src/util/secrets.ts b/packages/back-end/src/util/secrets.ts
index 4b1376d..ec40363 100644
--- a/packages/back-end/src/util/secrets.ts
+++ b/packages/back-end/src/util/secrets.ts
@@ -27,10 +27,11 @@ export const MONGODB_URI =
 if (!MONGODB_URI) {
   throw new Error("Missing MONGODB_URI environment variable");
 }
 
 export const APP_ORIGIN = process.env.APP_ORIGIN || "http://localhost:3000";
+const isLocalhost = APP_ORIGIN.includes("localhost");
 
 const corsOriginRegex = process.env.CORS_ORIGIN_REGEX;
 export const CORS_ORIGIN_REGEX = corsOriginRegex
   ? new RegExp(corsOriginRegex, "i")
   : null;
@@ -54,11 +55,11 @@ export const GCS_BUCKET_NAME = process.env.GCS_BUCKET_NAME || "";
 export const GCS_DOMAIN =
   process.env.GCS_DOMAIN ||
   `https://storage.googleapis.com/${GCS_BUCKET_NAME}/`;
 
 export const JWT_SECRET = process.env.JWT_SECRET || "dev";
-if (prod && !IS_CLOUD && JWT_SECRET === "dev") {
+if ((prod || !isLocalhost) && !IS_CLOUD && JWT_SECRET === "dev") {
   throw new Error(
     "Cannot use JWT_SECRET=dev in production. Please set to a long random string."
   );
 }
 

Vulnerable code:   const fullPath = getUploadsDir() + "/" + filePath;
if (prod && !IS_CLOUD && JWT_SECRET === "dev") {
Fixed code:   // Only allow this API endpoint when it's a brand-new installation with no users yet
  const newInstallation = await isNewInstallation();
  if (!newInstallation) {
    throw new Error(
      "An organization is already configured. Please refresh the page and try again."
    );
  }

  // Watch out for poison null bytes
  if (filePath.indexOf("\0") !== -1) {
    throw new Error("Error: Filename must not contain null bytes");
  }

  const rootDirectory = getUploadsDir();
  const fullPath = path.join(rootDirectory, filePath);

  // Prevent directory traversal
  if (fullPath.indexOf(rootDirectory) !== 0) {
    throw new Error(
      "Error: Path must not escape out of the 'uploads' directory."
    );
  }

const isLocalhost = APP_ORIGIN.includes("localhost");
if ((prod || !isLocalhost) && !IS_CLOUD && JWT_SECRET === "dev") {