harden IPC: reject -prefixed git refs, cap files.read / git.log (T-104)
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s

Three security fixes the consultant flagged:

* git.checkout, git.push now reject branch/remote arguments starting
  with `-` via a top-level validateGitRef helper. `git push` also
  gets a `--` option terminator; checkout can't use `--` without
  changing semantics (it would be parsed as a pathspec), so the
  validator is the only line of defence there.
* files.read caps responses at 10 MB so a single call can't OOM the
  UI on a multi-gigabyte log.
* git.log caps `count` at 1000; git.diff / git.stage cap paths at
  256. Excess is a userError rather than burning subprocess time.

The bigger typed-schema framework (item 1 in T-104) is split out as
T-120 since it needs design discussion alongside T-99.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-18 09:53:20 +02:00
co-authored by Claude
parent 683c90d0af
commit 31d40ad8ce
10 changed files with 221 additions and 2 deletions
+19
View File
@@ -13,6 +13,11 @@ import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart';
import 'dispatcher.dart';
/// Cap on `files.read` response size. UI doesn't render multi-MB
/// blobs usefully and a single uncapped call can OOM. Range/stream
/// reads will land as a separate command (T-104 follow-up).
const int _filesReadMaxBytes = 10 * 1024 * 1024;
/// Daemon-side state for the `files` subsystem. Holds one
/// [FileWatcher] rooted at the workspace and a resolved [IgnoreSet].
class FilesService {
@@ -84,6 +89,20 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
if (!file.existsSync()) {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path'));
}
// Cap response size so a single IPC call can't OOM the UI on a
// multi-gigabyte log file. Caller can paginate / stream via a
// future range-read variant when that ships.
final length = file.lengthSync();
if (length > _filesReadMaxBytes) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'file too large: $path ($length bytes; cap $_filesReadMaxBytes)',
),
);
}
final content = file.readAsStringSync();
return IpcResponse.ok(id: req.id, data: {'path': path, 'content': content});
});
+37 -1
View File
@@ -12,6 +12,16 @@ import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart';
import 'dispatcher.dart';
/// Cap on `git.log --count` to keep a single query from spinning git
/// up on multi-million-commit repos. UI's history pane paginates;
/// callers asking for more should be using ranges instead.
const int _gitLogMaxCount = 1000;
/// Cap on `git.diff` and `git.stage` paths-list length so a single
/// IPC request can't queue up an unbounded fan-out of subprocess
/// arguments.
const int _gitPathsMaxCount = 256;
void registerGitCommands(
DaemonDispatcher d,
GitClient git,
@@ -30,6 +40,8 @@ void registerGitCommands(
try {
final staged = req.args['staged'] as bool? ?? false;
final paths = _pathList(req.args['paths']);
final tooMany = _tooManyPaths(req.id, paths);
if (tooMany != null) return tooMany;
final diffs = await git.diff(staged: staged, paths: paths);
return IpcResponse.ok(id: req.id, data: {
'staged': staged,
@@ -53,6 +65,8 @@ void registerGitCommands(
),
);
}
final tooMany = _tooManyPaths(req.id, paths);
if (tooMany != null) return tooMany;
try {
await git.stage(paths);
_emitChanged(events);
@@ -190,8 +204,18 @@ void registerGitCommands(
});
d.register('git.log', (req) async {
final count = (req.args['count'] as num?)?.toInt() ?? 20;
if (count > _gitLogMaxCount) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'git.log count $count exceeds cap $_gitLogMaxCount',
),
);
}
try {
final count = (req.args['count'] as num?)?.toInt() ?? 20;
final entries = await git.log(count: count);
return IpcResponse.ok(id: req.id, data: {
'entries': [for (final e in entries) e.toJson()],
@@ -264,6 +288,18 @@ List<String> _pathList(Object? raw) {
return const [];
}
IpcResponse? _tooManyPaths(String id, List<String> paths) {
if (paths.length <= _gitPathsMaxCount) return null;
return IpcResponse.err(
id: id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'paths length ${paths.length} exceeds cap $_gitPathsMaxCount',
),
);
}
void _emitChanged(DaemonEventSink events) {
events.emit(IpcEvent(
subsystem: 'git',