Merge commit from fork

This commit is contained in:
Ben Lambert
2026-01-20 16:04:33 +01:00
committed by GitHub
parent 6a36e1e06f
commit ae4dd5d157
4 changed files with 135 additions and 17 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-plugin-api': patch
'@backstage/cli-common': patch
---
Move some of the symlink resolution to `isChildPath`
@@ -65,5 +65,85 @@ describe('paths', () => {
`${workspacePath}/template`,
);
});
it('should throw an error if the path is a symlink pointing to a non-existent target outside base', () => {
// This tests the case where realpathSync would fail with ENOENT
// but the symlink itself exists and points outside the base directory
const nonExistentTarget = `${secondDirectory.path}/does-not-exist.txt`;
mockDir.addContent({
[`${workspacePath}/dangling-link`]: ({ symlink }) =>
symlink(nonExistentTarget),
});
expect(() =>
resolveSafeChildPath(workspacePath, './dangling-link'),
).toThrow(
'Relative path is not allowed to refer to a directory outside its parent',
);
});
it('should allow symlinks pointing to non-existent targets within base directory', () => {
mockDir.addContent({
[`${workspacePath}/internal-link`]: ({ symlink }) =>
symlink('./future-file.txt'),
});
expect(resolveSafeChildPath(workspacePath, './internal-link')).toEqual(
`${workspacePath}/internal-link`,
);
});
it('should throw an error when writing through a symlink to a non-existent file outside base', () => {
// Symlink in workspace points outside, target directory exists but file doesn't
// e.g., /workspace/evil -> /etc, then write to evil/newfile.conf
// The check should catch that evil/newfile.conf resolves to /etc/newfile.conf
mockDir.addContent({
[`${workspacePath}/escape-link`]: ({ symlink }) =>
symlink(secondDirectory.path),
});
// This should throw because escape-link/new-file.txt would write to secondDirectory/new-file.txt
expect(() =>
resolveSafeChildPath(workspacePath, './escape-link/new-file.txt'),
).toThrow(
'Relative path is not allowed to refer to a directory outside its parent',
);
});
it('should throw an error for symlink chains pointing outside base', () => {
// link1 -> link2 -> outside
// Even if final target doesn't exist, should detect the escape
const nonExistentOutside = `${secondDirectory.path}/does-not-exist`;
mockDir.addContent({
[`${workspacePath}/link2`]: ({ symlink }) =>
symlink(nonExistentOutside),
[`${workspacePath}/link1`]: ({ symlink }) => symlink('./link2'),
});
expect(() => resolveSafeChildPath(workspacePath, './link1')).toThrow(
'Relative path is not allowed to refer to a directory outside its parent',
);
});
it('should throw when deeply nested non-existent path has symlink ancestor pointing outside', () => {
// Tests the recursive parent-walking behavior in resolveRealPath.
// When given a/b/c/d/file.txt where none of b/c/d exist, the code walks up
// the tree until finding 'a' (a symlink), resolves it, then rebuilds the path.
mockDir.addContent({
[`${workspacePath}/escape`]: ({ symlink }) =>
symlink(secondDirectory.path),
});
// escape/deeply/nested/path/file.txt requires walking up 4 levels
// before finding the symlink at 'escape'
expect(() =>
resolveSafeChildPath(
workspacePath,
'./escape/deeply/nested/path/file.txt',
),
).toThrow(
'Relative path is not allowed to refer to a directory outside its parent',
);
});
});
});
+2 -15
View File
@@ -17,7 +17,6 @@
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<
@@ -63,10 +62,9 @@ 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 resolvedBasePath = resolveRealPath(base);
const targetPath = resolvePath(resolvedBasePath, path);
const targetPath = resolvePath(base, path);
if (!isChildPath(resolvedBasePath, resolveRealPath(targetPath))) {
if (!isChildPath(base, targetPath)) {
throw new NotAllowedError(
'Relative path is not allowed to refer to a directory outside its parent',
);
@@ -76,16 +74,5 @@ export function resolveSafeChildPath(base: string, path: string): string {
return resolvePath(base, path);
}
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 };
+47 -2
View File
@@ -14,7 +14,49 @@
* limitations under the License.
*/
import { relative, isAbsolute } from 'path';
import {
relative,
isAbsolute,
resolve as resolvePath,
dirname,
basename,
} from 'path';
import { realpathSync, lstatSync, readlinkSync } from 'fs';
// Resolves a path to its real location, following symlinks.
// Handles cases where the final target doesn't exist by recursively
// resolving parent directories.
function resolveRealPath(path: string): string {
try {
return realpathSync(path);
} catch (ex) {
if (ex.code !== 'ENOENT') {
throw ex;
}
}
// Check if path itself is a dangling symlink - recursively resolve the target
// to handle symlink chains (e.g., link1 -> link2 -> /outside)
try {
if (lstatSync(path).isSymbolicLink()) {
const target = resolvePath(dirname(path), readlinkSync(path));
return resolveRealPath(target);
}
} catch (ex) {
if (ex.code !== 'ENOENT') {
throw ex;
}
}
// Path doesn't exist - walk up the tree until we find an existing path,
// resolve it, then rebuild the non-existent portion on top
const parent = dirname(path);
if (parent === path) {
return path; // Hit filesystem root
}
return resolvePath(resolveRealPath(parent), basename(path));
}
/**
* Checks if path is the same as or a child path of base.
@@ -22,7 +64,10 @@ import { relative, isAbsolute } from 'path';
* @public
*/
export function isChildPath(base: string, path: string): boolean {
const relativePath = relative(base, path);
const resolvedBase = resolveRealPath(base);
const resolvedPath = resolveRealPath(path);
const relativePath = relative(resolvedBase, resolvedPath);
if (relativePath === '') {
// The same directory
return true;