confine editor.open/editor.save to the workspace (T-363)
The editor registry resolved buffer paths with a string join that passed absolute paths through verbatim and never normalized `..` — an unconfined read and write primitive over IPC while files.read was carefully guarded. Buffer paths now resolve through resolveUnderRootFollowingSymlinks: traversal, absolute escapes, and symlinks-out are rejected at open, and re-checked at save so a symlink swapped in under an open buffer's path can't redirect the write. D-80's extra read roots deliberately do not apply — a buffer is a write surface. Handlers map PathOutsideRoot to the same error files.read uses. Also merges a duplicate Added heading that had crept into the Unreleased changelog section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/editor/registry.dart';
|
||||
import 'package:clide/src/files/path_safety.dart' show PathOutsideRoot;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
@@ -212,4 +213,46 @@ void main() {
|
||||
expect(changed.map((e) => e.data['id']), contains(readme.id));
|
||||
});
|
||||
});
|
||||
|
||||
// T-363: editor.open/save returned absolute paths verbatim and did no
|
||||
// `..` normalization — an unconfined read AND write primitive over IPC
|
||||
// while files.read was carefully guarded.
|
||||
group('path confinement (T-363)', () {
|
||||
test('open rejects .. traversal out of the workspace', () async {
|
||||
final outside = await Directory.systemTemp.createTemp('clide-editor-outside-');
|
||||
addTearDown(() => outside.deleteSync(recursive: true));
|
||||
await File('${outside.path}/secret.txt').writeAsString('secret');
|
||||
final escape = '../${outside.path.split('/').last}/secret.txt';
|
||||
await expectLater(reg.open(escape), throwsA(isA<PathOutsideRoot>()));
|
||||
});
|
||||
|
||||
test('open rejects absolute paths outside the workspace', () async {
|
||||
await expectLater(reg.open('/etc/hostname'), throwsA(isA<PathOutsideRoot>()));
|
||||
});
|
||||
|
||||
test('open accepts an absolute path inside the workspace', () async {
|
||||
final buf = await reg.open('${sandbox.path}/README.md');
|
||||
expect(buf.content, contains('Hello'));
|
||||
});
|
||||
|
||||
test('open rejects a symlink pointing outside the workspace', () async {
|
||||
final outside = await Directory.systemTemp.createTemp('clide-editor-outside-');
|
||||
addTearDown(() => outside.deleteSync(recursive: true));
|
||||
await File('${outside.path}/secret.txt').writeAsString('secret');
|
||||
Link('${sandbox.path}/sneaky').createSync('${outside.path}/secret.txt');
|
||||
await expectLater(reg.open('sneaky'), throwsA(isA<PathOutsideRoot>()));
|
||||
});
|
||||
|
||||
test('save rejects a buffer whose path now symlinks outside', () async {
|
||||
// Open a legitimate file, then swap a symlink in under its path.
|
||||
final buf = await reg.open('victim.txt');
|
||||
reg.setContent(buf.id, 'attacker-controlled');
|
||||
final outside = await Directory.systemTemp.createTemp('clide-editor-outside-');
|
||||
addTearDown(() => outside.deleteSync(recursive: true));
|
||||
await File('${outside.path}/target.txt').writeAsString('original');
|
||||
Link('${sandbox.path}/victim.txt').createSync('${outside.path}/target.txt');
|
||||
await expectLater(reg.save(buf.id), throwsA(isA<PathOutsideRoot>()));
|
||||
expect(await File('${outside.path}/target.txt').readAsString(), 'original');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user