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:
@@ -116,6 +116,31 @@ void main() {
|
||||
expect(r.error!.message, contains('outside workspace'));
|
||||
});
|
||||
|
||||
test('files.read rejects a symlink whose target is outside the workspace (T-102)', () async {
|
||||
final outside = await Directory.systemTemp.createTemp('clide_t102_read_');
|
||||
addTearDown(() async {
|
||||
if (await outside.exists()) await outside.delete(recursive: true);
|
||||
});
|
||||
File('${outside.path}/secret.txt').writeAsStringSync('payload');
|
||||
Link('${sandbox.path}/leak').createSync('${outside.path}/secret.txt');
|
||||
|
||||
final r = await call('files.read', const {'path': 'leak'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('outside workspace'));
|
||||
});
|
||||
|
||||
test('files.ls rejects a symlinked subdir whose target is outside (T-102)', () async {
|
||||
final outside = await Directory.systemTemp.createTemp('clide_t102_ls_');
|
||||
addTearDown(() async {
|
||||
if (await outside.exists()) await outside.delete(recursive: true);
|
||||
});
|
||||
Link('${sandbox.path}/leak-dir').createSync(outside.path);
|
||||
|
||||
final r = await call('files.ls', const {'path': 'leak-dir'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('outside workspace'));
|
||||
});
|
||||
|
||||
test('files.watch is idempotent: a second call still acks subscription', () async {
|
||||
final r1 = await call('files.watch', const {});
|
||||
final r2 = await call('files.watch', const {});
|
||||
|
||||
@@ -64,4 +64,64 @@ void main() {
|
||||
expect(e.toString(), allOf(contains('r'), contains('/abs'), contains('/root')));
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveUnderRootFollowingSymlinks (T-102)', () {
|
||||
test('plain non-symlink file passes through with the resolved real path', () {
|
||||
final f = File('${root.path}/plain.txt')..writeAsStringSync('hello');
|
||||
final out = resolveUnderRootFollowingSymlinks(root, 'plain.txt');
|
||||
// Real-path may differ from root.path on hosts where systemTemp
|
||||
// is itself a symlink (macOS /tmp -> /private/tmp). Compare via
|
||||
// resolveSymbolicLinksSync on both sides.
|
||||
expect(out, f.resolveSymbolicLinksSync());
|
||||
});
|
||||
|
||||
test('non-existent target returns the path-layer result (caller surfaces not-found)', () {
|
||||
final out = resolveUnderRootFollowingSymlinks(root, 'never-existed.txt');
|
||||
expect(out, endsWith('/never-existed.txt'));
|
||||
});
|
||||
|
||||
test('rejects a symlink under the workspace whose target lives outside', () async {
|
||||
// Create an outside file the symlink will point at.
|
||||
final outside = await Directory.systemTemp.createTemp('clide_t102_outside_');
|
||||
addTearDown(() async {
|
||||
if (await outside.exists()) await outside.delete(recursive: true);
|
||||
});
|
||||
final secret = File('${outside.path}/secret.txt')..writeAsStringSync('payload');
|
||||
|
||||
// Plant a symlink inside the workspace that targets the outside file.
|
||||
final link = Link('${root.path}/leak')..createSync(secret.path);
|
||||
expect(link.existsSync(), isTrue);
|
||||
|
||||
expect(
|
||||
() => resolveUnderRootFollowingSymlinks(root, 'leak'),
|
||||
throwsA(isA<PathOutsideRoot>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('tolerates symlinks in the workspace root path itself', () {
|
||||
// Where systemTemp is itself a symlink (macOS), the realPath of a
|
||||
// file under root won't startWith root.absolute.path — but
|
||||
// resolveUnderRootFollowingSymlinks resolves the root too, so
|
||||
// the containment check still passes.
|
||||
File('${root.path}/under-root.txt').writeAsStringSync('ok');
|
||||
// No throw is the assertion.
|
||||
resolveUnderRootFollowingSymlinks(root, 'under-root.txt');
|
||||
});
|
||||
|
||||
test('rejects a symlink-to-symlink chain whose final target is outside', () async {
|
||||
final outside = await Directory.systemTemp.createTemp('clide_t102_chain_');
|
||||
addTearDown(() async {
|
||||
if (await outside.exists()) await outside.delete(recursive: true);
|
||||
});
|
||||
final secret = File('${outside.path}/secret.txt')..writeAsStringSync('payload');
|
||||
// a -> b (under root) -> /outside/secret.txt
|
||||
Link('${root.path}/b').createSync(secret.path);
|
||||
Link('${root.path}/a').createSync('${root.path}/b');
|
||||
|
||||
expect(
|
||||
() => resolveUnderRootFollowingSymlinks(root, 'a'),
|
||||
throwsA(isA<PathOutsideRoot>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user