CVE-2021-32662: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: Backstage is an open platform for building developer portals, and techdocs-common contains common functionalities for Backstage's TechDocs. In `@backstage/techdocs-common` versions prior to 0.6.3, a malicious actor could read sensitive files from the environment where TechDocs documentation is built and published by setting a particular path for `docs_dir` in `mkdocs.yml`. These files would then be available over the TechDocs backend API. This vulnerability is mitigated by the fact that an attacker would need access to modify the `mkdocs.yml` in the documentation source code, and would also need access to the TechDocs backend API. The vulnerability is patched in the `0.6.3` release of `@backstage/techdocs-common`.
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-06-03T22:15Z
Repository: https://github.com/backstage/backstage
Language: TypeScript
Files changed: .changeset/gold-kings-jog.md, packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs_invalid_doc_dir.yml, packages/techdocs-common/src/stages/generate/helpers.test.ts, packages/techdocs-common/src/stages/generate/helpers.ts, packages/techdocs-common/src/stages/generate/techdocs.ts
Commit date: 2021-05-27 11:21:47 +0200
Commit message: Add validation to prevent docs_dir from being an absolute path

* Adds a new validation function to helpers to prevent generation when mkdocs.yml is not present or is invalid
* Handles vulnerability where docs_dir can be put in as an absolute path which copies and exposes the files from that absolute path in the file system

Signed-off-by: Jussi Hallila <jussi@hallila.com>

Diff: diff --git a/.changeset/gold-kings-jog.md b/.changeset/gold-kings-jog.md
new file mode 100644
index 000000000..9bc901e2a
--- /dev/null
+++ b/.changeset/gold-kings-jog.md
@@ -0,0 +1,5 @@
+---
+'@backstage/techdocs-common': patch
+---
+
+Adding validation to mkdocs.yml parsing to prevent directory tree traversing
diff --git a/packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs_invalid_doc_dir.yml b/packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs_invalid_doc_dir.yml
new file mode 100644
index 000000000..b3d87243c
--- /dev/null
+++ b/packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs_invalid_doc_dir.yml
@@ -0,0 +1,3 @@
+site_name: Test site name
+site_description: Test site description
+docs_dir: /etc
diff --git a/packages/techdocs-common/src/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts
index 05402d0cf..94a7560fb 100644
--- a/packages/techdocs-common/src/stages/generate/helpers.test.ts
+++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts
@@ -25,10 +25,11 @@ import {
   getGeneratorKey,
   getRepoUrlFromLocationAnnotation,
   isValidRepoUrlForMkdocs,
   patchMkdocsYmlPreBuild,
   storeEtagMetadata,
+  validateMkdocsYaml,
 } from './helpers';
 
 const mockEntity = {
   apiVersion: 'version',
   kind: 'TestKind',
@@ -41,10 +42,13 @@ const mkdocsYml = fs.readFileSync(
   resolvePath(__filename, '../__fixtures__/mkdocs.yml'),
 );
 const mkdocsYmlWithRepoUrl = fs.readFileSync(
   resolvePath(__filename, '../__fixtures__/mkdocs_with_repo_url.yml'),
 );
+const mkdocsYmlWithInvalidDocDir = fs.readFileSync(
+  resolvePath(__filename, '../__fixtures__/mkdocs_invalid_doc_dir.yml'),
+);
 const mockLogger = getVoidLogger();
 const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
 
 describe('helpers', () => {
   describe('getGeneratorKey', () => {
@@ -298,6 +302,30 @@ describe('helpers', () => {
 
       const json = await fs.readJson(filePath);
       expect(json.etag).toBe('etag123abc');
     });
   });
+
+  describe('validateMkdocsYaml', () => {
+    beforeEach(() => {
+      mockFs({
+        '/mkdocs.yml': mkdocsYml,
+        '/mkdocs_with_repo_url.yml': mkdocsYmlWithRepoUrl,
+        '/mkdocs_invalid_doc_dir.yml': mkdocsYmlWithInvalidDocDir,
+      });
+    });
+
+    afterEach(() => {
+      mockFs.restore();
+    });
+
+    it('should return true on when no docs_dir present', async () => {
+      await expect(validateMkdocsYaml('/mkdocs.yml')).resolves.toBeUndefined();
+    });
+
+    it('should return false on absolute doc_dir path', async () => {
+      await expect(
+        validateMkdocsYaml('/mkdocs_invalid_doc_dir.yml'),
+      ).rejects.toThrow();
+    });
+  });
 });
diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts
index 88f42c1e4..51771603d 100644
--- a/packages/techdocs-common/src/stages/generate/helpers.ts
+++ b/packages/techdocs-common/src/stages/generate/helpers.ts
@@ -14,10 +14,11 @@
  * limitations under the License.
  */
 
 import { Entity } from '@backstage/catalog-model';
 import { spawn } from 'child_process';
+import { isAbsolute, normalize } from 'path';
 import fs from 'fs-extra';
 import yaml from 'js-yaml';
 import { PassThrough, Writable } from 'stream';
 import { Logger } from 'winston';
 import { ParsedLocationAnnotation } from '../../helpers';
@@ -136,10 +137,34 @@ export const getRepoUrlFromLocationAnnotation = (
   }
 
   return undefined;
 };
 
+/**
+ * Validating mkdocs config file for incorrect/insecure values
+ * Throws on invalid configs
+ *
+ * @param {string} mkdocsYmlPath Absolute path to mkdocs.yml or equivalent of a docs site
+ */
+export const validateMkdocsYaml = async (mkdocsYmlPath: string) => {
+  let mkdocsYmlFileString;
+  try {
+    mkdocsYmlFileString = await fs.readFile(mkdocsYmlPath, 'utf8');
+  } catch (error) {
+    throw new Error(
+      `Could not read MkDocs YAML config file ${mkdocsYmlPath} before for validation: ${error.message}`,
+    );
+  }
+
+  const mkdocsYml: any = yaml.load(mkdocsYmlFileString);
+  if (mkdocsYml.docs_dir && isAbsolute(normalize(mkdocsYml.docs_dir))) {
+    throw new Error(
+      "docs_dir configuration value in mkdocs can't be an absolute directory path for security reasons. Use relative paths instead which are resolved relative to your mkdocs.yml file location.",
+    );
+  }
+};
+
 /**
  * Update the mkdocs.yml file before TechDocs generator uses it to generate docs site.
  *
  * List of tasks:
  * - Add repo_url if it does not exists
diff --git a/packages/techdocs-common/src/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts
index 1a6b0a581..1f2a9ae3f 100644
--- a/packages/techdocs-common/src/stages/generate/techdocs.ts
+++ b/packages/techdocs-common/src/stages/generate/techdocs.ts
@@ -22,10 +22,11 @@ import { Logger } from 'winston';
 import {
   addBuildTimestampMetadata,
   patchMkdocsYmlPreBuild,
   runCommand,
   storeEtagMetadata,
+  validateMkdocsYaml,
 } from './helpers';
 import { GeneratorBase, GeneratorRunOptions } from './types';
 
 type TechdocsGeneratorOptions = {
   // This option enables users to configure if they want to use TechDocs container
@@ -77,18 +78,21 @@ export class TechdocsGenerator implements GeneratorBase {
     const [log, logStream] = createStream();
 
     // TODO: In future mkdocs.yml can be mkdocs.yaml. So, use a config variable here to find out
     // the correct file name.
     // Do some updates to mkdocs.yml before generating docs e.g. adding repo_url
+    const mkdocsYmlPath = path.join(inputDir, 'mkdocs.yml');
     if (parsedLocationAnnotation) {
       await patchMkdocsYmlPreBuild(
-        path.join(inputDir, 'mkdocs.yml'),
+        mkdocsYmlPath,
         this.logger,
         parsedLocationAnnotation,
       );
     }
 
+    await validateMkdocsYaml(mkdocsYmlPath);
+
     // Directories to bind on container
     const mountDirs = {
       [inputDir]: '/input',
       [outputDir]: '/output',
     };

Vulnerable code:         path.join(inputDir, 'mkdocs.yml'),
Fixed code: ---
'@backstage/techdocs-common': patch
---

Adding validation to mkdocs.yml parsing to prevent directory tree traversing
site_name: Test site name
site_description: Test site description
docs_dir: /etc
  validateMkdocsYaml,
const mkdocsYmlWithInvalidDocDir = fs.readFileSync(
  resolvePath(__filename, '../__fixtures__/mkdocs_invalid_doc_dir.yml'),
);

  describe('validateMkdocsYaml', () => {
    beforeEach(() => {
      mockFs({
        '/mkdocs.yml': mkdocsYml,
        '/mkdocs_with_repo_url.yml': mkdocsYmlWithRepoUrl,
        '/mkdocs_invalid_doc_dir.yml': mkdocsYmlWithInvalidDocDir,
      });
    });

    afterEach(() => {
      mockFs.restore();
    });

    it('should return true on when no docs_dir present', async () => {
      await expect(validateMkdocsYaml('/mkdocs.yml')).resolves.toBeUndefined();
    });

    it('should return false on absolute doc_dir path', async () => {
      await expect(
        validateMkdocsYaml('/mkdocs_invalid_doc_dir.yml'),
      ).rejects.toThrow();
    });
  });
import { isAbsolute, normalize } from 'path';
/**
 * Validating mkdocs config file for incorrect/insecure values
 * Throws on invalid configs
 *
 * @param {string} mkdocsYmlPath Absolute path to mkdocs.yml or equivalent of a docs site
 */
export const validateMkdocsYaml = async (mkdocsYmlPath: string) => {
  let mkdocsYmlFileString;
  try {
    mkdocsYmlFileString = await fs.readFile(mkdocsYmlPath, 'utf8');
  } catch (error) {
    throw new Error(
      `Could not read MkDocs YAML config file ${mkdocsYmlPath} before for validation: ${error.message}`,
    );
  }

  const mkdocsYml: any = yaml.load(mkdocsYmlFileString);
  if (mkdocsYml.docs_dir && isAbsolute(normalize(mkdocsYml.docs_dir))) {
    throw new Error(
      "docs_dir configuration value in mkdocs can't be an absolute directory path for security reasons. Use relative paths instead which are resolved relative to your mkdocs.yml file location.",
    );
  }
};

  validateMkdocsYaml,
    const mkdocsYmlPath = path.join(inputDir, 'mkdocs.yml');
        mkdocsYmlPath,
    await validateMkdocsYaml(mkdocsYmlPath);