reject symlinks pointing outside the workspace (T-102)
resolveUnderRoot already blocked path-layer traversal but explicitly did NOT follow symlinks — a repo symlink config -> /etc/shadow passed the containment check because the link path was under root. clide would then read the target. Add resolveUnderRootFollowingSymlinks: resolves any symlinks at the target and re-verifies containment against the resolved real root. The split keeps pure path math testable without filesystem access. files.read and files.ls now route through it. Tests cover: plain non-symlink passthrough, non-existent target (returns path-layer result so caller surfaces not-found cleanly), single-hop and chained symlinks whose targets escape the workspace, and tolerance of symlinks in the root path itself (macOS /tmp). Also adds the T-101 CHANGELOG entry that the docs commit missed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,7 +74,9 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
}
|
||||
final String absPath;
|
||||
try {
|
||||
absPath = resolveUnderRoot(files.root, path);
|
||||
// Follow symlinks + re-check containment so a `config -> /etc/shadow`
|
||||
// symlink under the workspace can't be read (T-102).
|
||||
absPath = resolveUnderRootFollowingSymlinks(files.root, path);
|
||||
} on PathOutsideRoot {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'));
|
||||
}
|
||||
@@ -90,7 +92,7 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
final dir = (req.args['path'] as String?) ?? '';
|
||||
if (dir.isNotEmpty) {
|
||||
try {
|
||||
resolveUnderRoot(files.root, dir);
|
||||
resolveUnderRootFollowingSymlinks(files.root, dir);
|
||||
} on PathOutsideRoot {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $dir'));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ class PathOutsideRoot implements Exception {
|
||||
/// Resolve [relative] against [root] and verify the result is
|
||||
/// contained within [root]. Returns the absolute, normalized path.
|
||||
/// Throws [PathOutsideRoot] on traversal attempts.
|
||||
///
|
||||
/// Path-layer check only — does NOT follow symlinks. Callers that
|
||||
/// read or list the filesystem should use [resolveUnderRootFollowingSymlinks]
|
||||
/// instead, which adds a second containment check against the real
|
||||
/// path. The two-step split exists so pure path math can be tested
|
||||
/// without touching disk (T-102).
|
||||
String resolveUnderRoot(Directory root, String relative) {
|
||||
final rootPath = _normalize(root.absolute.path);
|
||||
final joined = _normalize('$rootPath${Platform.pathSeparator}$relative');
|
||||
@@ -32,6 +38,33 @@ String resolveUnderRoot(Directory root, String relative) {
|
||||
return joined;
|
||||
}
|
||||
|
||||
/// Like [resolveUnderRoot] but also resolves any symlinks at the
|
||||
/// target and re-verifies containment against the real path. Use this
|
||||
/// for any operation that will read/list/write the filesystem — the
|
||||
/// path-layer check alone does not defend against a symlink under the
|
||||
/// workspace whose target lives outside (T-102, e.g. `config ->
|
||||
/// /etc/shadow`).
|
||||
///
|
||||
/// Returns the **resolved real path** (with symlinks followed) when
|
||||
/// the target exists. When the target does not exist, returns the
|
||||
/// path-layer result so callers surface a clean "not found" error from
|
||||
/// their filesystem op (rather than this layer throwing first).
|
||||
///
|
||||
/// Symlinks in the workspace root path itself are tolerated: both
|
||||
/// sides of the containment check are resolved.
|
||||
String resolveUnderRootFollowingSymlinks(Directory root, String relative) {
|
||||
final pathResolved = resolveUnderRoot(root, relative);
|
||||
if (FileSystemEntity.typeSync(pathResolved, followLinks: false) == FileSystemEntityType.notFound) {
|
||||
return pathResolved;
|
||||
}
|
||||
final realRoot = Directory(root.absolute.path).resolveSymbolicLinksSync();
|
||||
final realPath = File(pathResolved).resolveSymbolicLinksSync();
|
||||
if (realPath != realRoot && !realPath.startsWith('$realRoot${Platform.pathSeparator}')) {
|
||||
throw PathOutsideRoot(relative, realPath, realRoot);
|
||||
}
|
||||
return realPath;
|
||||
}
|
||||
|
||||
String _normalize(String path) {
|
||||
// Use Uri to collapse `..` and `.` segments without hitting the
|
||||
// filesystem (Directory(...).resolveSymbolicLinksSync would also
|
||||
|
||||
Reference in New Issue
Block a user