CVE-2024-26150 (published 2024-02-23T16:15Z)

`@backstage/backend-common` is a common functionality library for backends for Backstage, an open platform for building developer portals. In `@backstage/backend-common` prior to versions 0.21.1, 0.20.2, and 0.19.10, paths checks with the `resolveSafeChildPath` utility were not exhaustive enough, leading to risk of path traversal vulnerabilities if symlinks can be injected by attackers. This issue is patched in `@backstage/backend-common` versions 0.21.1, 0.20.2, and 0.19.10.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/backstage/backstage
commit_details: repo_url: https://github.com/backstage/backstage
author: blam
author_date: 2024-02-22 22:16:25+01:00
msg: chore: return the original input path, and add a test

Signed-off-by: blam <ben@blam.sh>
repository: repo_url: https://github.com/backstage/backstage
repo_name: backstage/backstage
description: Backstage is an open framework for building developer portals
repo_language: TypeScript
owner: backstage
file_changes: filename: paths.test.ts
change_type: ModificationType.MODIFY
diff: @@ -16,7 +16,6 @@
 
 import { createMockDirectory } from '@backstage/backend-test-utils';
 import { resolveSafeChildPath } from './paths';
-import fs from 'fs/promises';
 
 describe('paths', () => {
   describe('resolveSafeChildPath', () => {
@@ -42,9 +41,9 @@ describe('paths', () => {
       );
     });
 
-    it('should resolve to the full path if the target is inside the directory', async () => {
+    it('should resolve to the full path if the target is inside the directory', () => {
       expect(resolveSafeChildPath(workspacePath, './README.md')).toEqual(
-        `${await fs.realpath(workspacePath)}/README.md`,
+        `${workspacePath}/README.md`,
       );
     });
 
@@ -60,5 +59,11 @@ describe('paths', () => {
         'Relative path is not allowed to refer to a directory outside its parent',
       );
     });
+
+    it('should not throw an error when a folder is referenced that doesnt already exist', () => {
+      expect(resolveSafeChildPath(workspacePath, 'template')).toEqual(
+        `${workspacePath}/template`,
+      );
+    });
   });
 });

programming_language: TypeScript
filename: paths.ts
change_type: ModificationType.MODIFY
diff: @@ -72,7 +72,8 @@ export function resolveSafeChildPath(base: string, path: string): string {
     );
   }
 
-  return targetPath;
+  // Don't return the resolved path as the original could be a symlink
+  return resolvePath(base, path);
 }
 
 function resolveRealPath(path: string): string {

programming_language: TypeScript
repo_url: https://github.com/backstage/backstage
commit_details: repo_url: https://github.com/backstage/backstage
author: blam
author_date: 2024-02-22 16:37:41+01:00
msg: fix: resolve the actual paths for base and target

Signed-off-by: blam <ben@blam.sh>
repository: repo_url: https://github.com/backstage/backstage
repo_name: backstage/backstage
description: Backstage is an open framework for building developer portals
repo_language: TypeScript
owner: backstage
file_changes: filename: paths.test.ts
change_type: ModificationType.ADD
diff: @@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createMockDirectory } from '@backstage/backend-test-utils';
+import { resolveSafeChildPath } from './paths';
+
+describe('paths', () => {
+  describe('resolveSafeChildPath', () => {
+    const mockDir = createMockDirectory();
+    const secondDirectory = createMockDirectory();
+
+    const workspacePath = mockDir.resolve('workspace');
+
+    beforeEach(() => {
+      mockDir.setContent({
+        [`${workspacePath}/README.md`]: '### README.md',
+      });
+      secondDirectory.setContent({
+        [`index.md`]: '### index.md',
+      });
+    });
+
+    it('should throw an error if the path is outside of the base path', () => {
+      expect(() =>
+        resolveSafeChildPath(workspacePath, secondDirectory.path),
+      ).toThrow(
+        'Relative path is not allowed to refer to a directory outside its parent',
+      );
+    });
+
+    it('should resolve to the full path if the target is inside the directory', () => {
+      expect(resolveSafeChildPath(workspacePath, './README.md')).toEqual(
+        `${workspacePath}/README.md`,
+      );
+    });
+
+    it('should throw an error if the path is a symlink to a directory outside of the base path', () => {
+      mockDir.addContent({
+        [`${workspacePath}/symlink`]: ({ symlink }) =>
+          symlink(secondDirectory.path),
+      });
+
+      expect(() =>
+        resolveSafeChildPath(workspacePath, './symlink/index.md'),
+      ).toThrow(
+        'Relative path is not allowed to refer to a directory outside its parent',
+      );
+    });
+  });
+});

programming_language: TypeScript
filename: paths.ts
change_type: ModificationType.MODIFY
diff: @@ -17,6 +17,7 @@
 import { isChildPath } from '@backstage/cli-common';
 import { NotAllowedError } from '@backstage/errors';
 import { resolve as resolvePath } from 'path';
+import { realpathSync as realPath } from 'fs';
 
 /** @internal */
 export const packagePathMocks = new Map<
@@ -64,7 +65,7 @@ export function resolvePackagePath(name: string, ...paths: string[]) {
 export function resolveSafeChildPath(base: string, path: string): string {
   const targetPath = resolvePath(base, path);
 
-  if (!isChildPath(base, targetPath)) {
+  if (!isChildPath(resolveRealPath(base), resolveRealPath(targetPath))) {
     throw new NotAllowedError(
       'Relative path is not allowed to refer to a directory outside its parent',
     );
@@ -73,5 +74,16 @@ export function resolveSafeChildPath(base: string, path: string): string {
   return targetPath;
 }
 
+function resolveRealPath(path: string): string {
+  try {
+    return realPath(path);
+  } catch (ex) {
+    if (ex.code !== 'ENOENT') {
+      throw ex;
+    }
+  }
+
+  return path;
+}
 // Re-export isChildPath so that backend packages don't need to depend on cli-common
 export { isChildPath };

programming_language: TypeScript
repo_url: https://github.com/backstage/backstage
commit_details: repo_url: https://github.com/backstage/backstage
author: blam
author_date: 2024-02-22 21:19:34+01:00
msg: fix: make sure to construct the target from the resolved base path too

Signed-off-by: blam <ben@blam.sh>
repository: repo_url: https://github.com/backstage/backstage
repo_name: backstage/backstage
description: Backstage is an open framework for building developer portals
repo_language: TypeScript
owner: backstage
file_changes: filename: paths.test.ts
change_type: ModificationType.MODIFY
diff: @@ -16,6 +16,7 @@
 
 import { createMockDirectory } from '@backstage/backend-test-utils';
 import { resolveSafeChildPath } from './paths';
+import fs from 'fs/promises';
 
 describe('paths', () => {
   describe('resolveSafeChildPath', () => {
@@ -41,9 +42,9 @@ describe('paths', () => {
       );
     });
 
-    it('should resolve to the full path if the target is inside the directory', () => {
+    it('should resolve to the full path if the target is inside the directory', async () => {
       expect(resolveSafeChildPath(workspacePath, './README.md')).toEqual(
-        `${workspacePath}/README.md`,
+        `${await fs.realpath(workspacePath)}/README.md`,
       );
     });
 

programming_language: TypeScript
filename: paths.ts
change_type: ModificationType.MODIFY
diff: @@ -63,9 +63,10 @@ export function resolvePackagePath(name: string, ...paths: string[]) {
  * @returns A path that is guaranteed to point to or within the base path.
  */
 export function resolveSafeChildPath(base: string, path: string): string {
-  const targetPath = resolvePath(base, path);
+  const resolvedBasePath = resolveRealPath(base);
+  const targetPath = resolvePath(resolvedBasePath, path);
 
-  if (!isChildPath(resolveRealPath(base), resolveRealPath(targetPath))) {
+  if (!isChildPath(resolvedBasePath, resolveRealPath(targetPath))) {
     throw new NotAllowedError(
       'Relative path is not allowed to refer to a directory outside its parent',
     );

programming_language: TypeScript