Files
clide/lib/src/files/path_safety.dart
T
jpmschweitzerandClaude Opus 4.7 06b08b7388 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>
2026-05-17 21:01:56 +02:00

85 lines
3.5 KiB
Dart

/// Workspace-relative path validation. Rejects paths that resolve
/// outside the workspace root (path traversal via `..`, absolute
/// paths, symlink-out attempts).
library;
import 'dart:io';
class PathOutsideRoot implements Exception {
PathOutsideRoot(this.requested, this.resolved, this.root);
final String requested;
final String resolved;
final String root;
@override
String toString() => 'path outside workspace root: $requested → $resolved (root: $root)';
}
/// 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');
// Containment check: joined must equal rootPath, or start with
// rootPath + separator. Equality covers `relative == ''` (the
// root itself); the separator check prevents `/repo` matching
// `/repository`.
if (joined != rootPath && !joined.startsWith('$rootPath${Platform.pathSeparator}')) {
throw PathOutsideRoot(relative, joined, rootPath);
}
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
// resolve symlinks, which we don't want here — symlink handling
// belongs at the filesystem-access layer, not the path layer).
final segments = <String>[];
for (final raw in path.split(Platform.pathSeparator)) {
if (raw.isEmpty || raw == '.') continue;
if (raw == '..') {
if (segments.isNotEmpty) segments.removeLast();
continue;
}
segments.add(raw);
}
final prefix = path.startsWith(Platform.pathSeparator) ? Platform.pathSeparator : '';
return '$prefix${segments.join(Platform.pathSeparator)}';
}