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:
@@ -13,6 +13,7 @@ import 'dart:io' show FileSystemException;
|
||||
|
||||
import '../editor/buffer.dart' show Selection;
|
||||
import '../editor/registry.dart';
|
||||
import '../files/path_safety.dart' show PathOutsideRoot;
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/errno_mapping.dart';
|
||||
@@ -84,6 +85,13 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
|
||||
r.setSelection(buf.id, Selection.collapsed(_offsetForLine(buf.content, line)));
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: buf.toJson());
|
||||
} on PathOutsideRoot {
|
||||
// Same containment contract as files.read (T-363); a buffer is a
|
||||
// write surface, so no D-80 extra-root widening here.
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'),
|
||||
);
|
||||
} on FileSystemException catch (e) {
|
||||
final errno = e.osError?.errorCode;
|
||||
if (errno != null) {
|
||||
@@ -190,7 +198,17 @@ Future<IpcResponse> _setContent(IpcRequest req, EditorRegistry r) async {
|
||||
Future<IpcResponse> _save(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
final ok = await r.save(id);
|
||||
final bool ok;
|
||||
try {
|
||||
ok = await r.save(id);
|
||||
} on PathOutsideRoot {
|
||||
// Defense in depth — open already validates, but a symlink can be
|
||||
// swapped in under the buffer's path between open and save (T-363).
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace'),
|
||||
);
|
||||
}
|
||||
if (!ok) return _notFound(req.id, 'no such buffer: $id');
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'saved': true});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../files/path_safety.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'buffer.dart';
|
||||
@@ -212,10 +213,13 @@ class EditorRegistry {
|
||||
events.emit(IpcEvent(subsystem: 'editor', kind: kind, timestamp: DateTime.now().toUtc(), data: data));
|
||||
}
|
||||
|
||||
/// Resolve a buffer path to disk under the workspace root, with the
|
||||
/// same traversal/symlink containment as files.* (T-363). A buffer is
|
||||
/// a WRITE surface (save), so the D-80 extra read roots do not apply —
|
||||
/// strictly workspace-confined. Throws [PathOutsideRoot] on escape.
|
||||
String _absolutePathOf(String repoRelative) {
|
||||
if (repoRelative.startsWith('/')) return repoRelative;
|
||||
final sep = Platform.pathSeparator;
|
||||
return '${workspaceRoot.absolute.path}$sep${repoRelative.replaceAll('/', sep)}';
|
||||
return resolveUnderRootFollowingSymlinks(workspaceRoot, repoRelative.replaceAll('/', sep));
|
||||
}
|
||||
|
||||
// Support JSON decode of Selection from IPC args.
|
||||
|
||||
Reference in New Issue
Block a user