`@backstage/backend-common` vulnerable to path traversal through symlinks
Description
@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.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@backstage/backend-commonnpm | >= 0.21.0, < 0.21.1 | 0.21.1 |
@backstage/backend-commonnpm | < 0.19.10 | 0.19.10 |
@backstage/backend-commonnpm | >= 0.20.0, < 0.20.2 | 0.20.2 |
Affected products
1Patches
31ad2b1b61ebbchore: return the original input path, and add a test
2 files changed · +10 −4
packages/backend-common/src/paths.test.ts+8 −3 modified@@ -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`, + ); + }); }); });
packages/backend-common/src/paths.ts+2 −1 modified@@ -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 {
edf65d7d31e0fix: make sure to construct the target from the resolved base path too
2 files changed · +6 −4
packages/backend-common/src/paths.test.ts+3 −2 modified@@ -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`, ); });
packages/backend-common/src/paths.ts+3 −2 modified@@ -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', );
78f892b3a84dfix: resolve the actual paths for base and target
2 files changed · +76 −1
packages/backend-common/src/paths.test.ts+63 −0 added@@ -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', + ); + }); + }); +});
packages/backend-common/src/paths.ts+13 −1 modified@@ -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 };
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-2fc9-xpp8-2g9hghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-26150ghsaADVISORY
- github.com/backstage/backstage/commit/1ad2b1b61ebb430051f7d804b0cc7ebfe7922b6fghsax_refsource_MISCWEB
- github.com/backstage/backstage/commit/78f892b3a84d63de2ba167928f171154c447b717ghsax_refsource_MISCWEB
- github.com/backstage/backstage/commit/edf65d7d31e027599c2415f597d085ee84807871ghsax_refsource_MISCWEB
- github.com/backstage/backstage/security/advisories/GHSA-2fc9-xpp8-2g9hghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.