CVE-2024-26150: Insufficient Information (NVD-CWE-noinfo)
value: `@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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-02-23T16:15Z
Repository: https://github.com/backstage/backstage
Language: TypeScript
Files changed: packages/backend-common/src/paths.test.ts, packages/backend-common/src/paths.ts
Commit date: 2024-02-22 16:37:41 +0100
Commit message: fix: resolve the actual paths for base and target

Signed-off-by: blam <ben@blam.sh>

Diff: diff --git a/packages/backend-common/src/paths.test.ts b/packages/backend-common/src/paths.test.ts
new file mode 100644
index 00000000000..a9a557c8fe6
--- /dev/null
+++ b/packages/backend-common/src/paths.test.ts
@@ -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',
+      );
+    });
+  });
+});
diff --git a/packages/backend-common/src/paths.ts b/packages/backend-common/src/paths.ts
index 4ec8e01bc4f..0e784a60db7 100644
--- a/packages/backend-common/src/paths.ts
+++ b/packages/backend-common/src/paths.ts
@@ -15,10 +15,11 @@
  */
 
 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<
   string,
   (paths: string[]) => string | undefined
@@ -62,16 +63,27 @@ 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);
 
-  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',
     );
   }
 
   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 };

Vulnerable code:   if (!isChildPath(base, targetPath)) {
Fixed code: /*
 * 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',
      );
    });
  });
});
import { realpathSync as realPath } from 'fs';
  if (!isChildPath(resolveRealPath(base), resolveRealPath(targetPath))) {
function resolveRealPath(path: string): string {
  try {
    return realPath(path);
  } catch (ex) {
    if (ex.code !== 'ENOENT') {
      throw ex;
    }
  }

  return path;
}