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
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:
@@ -110,6 +110,22 @@ void main() {
|
||||
expect(r.error!.message, contains('not found'));
|
||||
});
|
||||
|
||||
test('files.read rejects a file over the size cap', () async {
|
||||
// Cap is 10 MB; write 11 MB of zeros and confirm rejection rather
|
||||
// than reading it into memory.
|
||||
final big = File('${sandbox.path}/huge.bin');
|
||||
final chunk = List<int>.filled(1024 * 1024, 0);
|
||||
final sink = big.openWrite();
|
||||
for (var i = 0; i < 11; i++) {
|
||||
sink.add(chunk);
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
final r = await call('files.read', const {'path': 'huge.bin'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('too large'));
|
||||
});
|
||||
|
||||
test('files.ls with a path outside the root is rejected', () async {
|
||||
final r = await call('files.ls', const {'path': '../escape'});
|
||||
expect(r.ok, isFalse);
|
||||
|
||||
@@ -294,4 +294,45 @@ void main() {
|
||||
final r = await call('git.stage', {'paths': 'new.txt'});
|
||||
expect(r.ok, isTrue);
|
||||
});
|
||||
|
||||
test('git.checkout rejects a -prefixed branch (argv-injection guard)', () async {
|
||||
final r = await call('git.checkout', {'branch': '--upload-pack=evil'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('branch'));
|
||||
});
|
||||
|
||||
test('git.push rejects a -prefixed remote', () async {
|
||||
final r = await call('git.push', {'remote': '--upload-pack=evil', 'branch': 'main'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('remote'));
|
||||
});
|
||||
|
||||
test('git.push rejects a -prefixed branch', () async {
|
||||
final r = await call('git.push', {'remote': 'origin', 'branch': '--exec=evil'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('branch'));
|
||||
});
|
||||
|
||||
test('git.log over the count cap fails as userError', () async {
|
||||
final r = await call('git.log', {'count': 100000});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(r.error?.message, contains('exceeds cap'));
|
||||
});
|
||||
|
||||
test('git.diff with too many paths fails as userError', () async {
|
||||
final paths = [for (var i = 0; i < 300; i++) 'file_$i.txt'];
|
||||
final r = await call('git.diff', {'paths': paths});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(r.error?.message, contains('exceeds cap'));
|
||||
});
|
||||
|
||||
test('git.stage with too many paths fails as userError', () async {
|
||||
final paths = [for (var i = 0; i < 300; i++) 'file_$i.txt'];
|
||||
final r = await call('git.stage', {'paths': paths});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(r.error?.message, contains('exceeds cap'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,6 +227,48 @@ void main() {
|
||||
} on GitException catch (_) {}
|
||||
});
|
||||
|
||||
test('gitPush rejects a -prefixed remote (argv-injection guard)', () async {
|
||||
try {
|
||||
await gitPush(sandbox, remote: '--upload-pack=evil', branch: 'main');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.message, contains('remote'));
|
||||
}
|
||||
});
|
||||
|
||||
test('gitPush rejects a -prefixed branch', () async {
|
||||
try {
|
||||
await gitPush(sandbox, remote: 'origin', branch: '--exec=evil');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.message, contains('branch'));
|
||||
}
|
||||
});
|
||||
|
||||
test('gitCheckout rejects a -prefixed branch', () async {
|
||||
try {
|
||||
await gitCheckout(sandbox, '--upload-pack=evil');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.message, contains('branch'));
|
||||
}
|
||||
});
|
||||
|
||||
test('gitCheckout rejects an empty branch', () async {
|
||||
try {
|
||||
await gitCheckout(sandbox, '');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.message, contains('branch'));
|
||||
}
|
||||
});
|
||||
|
||||
test('validateGitRef accepts plain refs', () {
|
||||
expect(() => validateGitRef('main', kind: 'branch'), returnsNormally);
|
||||
expect(() => validateGitRef('feature/foo', kind: 'branch'), returnsNormally);
|
||||
expect(() => validateGitRef('origin', kind: 'remote'), returnsNormally);
|
||||
});
|
||||
|
||||
test('gitPull against no remote throws GitException', () async {
|
||||
try {
|
||||
await gitPull(sandbox);
|
||||
|
||||
Reference in New Issue
Block a user