gate clide:// deep links: paranoid allowlist + confirmation prompt (T-56, D-90)
A clide:// link is an untrusted external vector (any webpage can fire one), so it no longer translates to a command in parseArgv. It routes the raw URL to a new builtin.deeplink handler that is doubly defensive: a default-deny allowlist (kDeepLinkSafeActions — only the read-only 'open' verb; run/git/write/passthrough rejected) AND a mandatory 'an external link wants to: … allow?' confirmation before anything runs. Records the security boundary as D-90. The earlier silent editor.open passthrough is replaced; open still works, now behind the prompt. Tests cover the allowlist (the boundary) + the gating. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
/// Tests for the clide:// deep-link parser + paranoid allowlist (T-56, D-90).
|
||||
/// The allowlist is the security boundary, so it gets the bulk of the coverage.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/deeplink/src/deep_link.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('parseDeepLink — open', () {
|
||||
test('parses path + optional line', () {
|
||||
final a = parseDeepLink('clide://open?path=/repo/x.dart')!;
|
||||
expect(a.name, 'open');
|
||||
expect(a.path, '/repo/x.dart');
|
||||
expect(a.line, isNull);
|
||||
|
||||
final b = parseDeepLink('clide://open?path=/repo/x.dart&line=42')!;
|
||||
expect(b.line, 42);
|
||||
});
|
||||
|
||||
test('decodes a percent-encoded path', () {
|
||||
expect(parseDeepLink('clide://open?path=/a%20b/c.dart')!.path, '/a b/c.dart');
|
||||
});
|
||||
|
||||
test('describe reads naturally for the prompt', () {
|
||||
expect(parseDeepLink('clide://open?path=/x&line=9')!.describe, contains('/x'));
|
||||
expect(parseDeepLink('clide://open?path=/x&line=9')!.describe, contains('9'));
|
||||
});
|
||||
|
||||
test('missing/empty path is rejected', () {
|
||||
expect(parseDeepLink('clide://open'), isNull);
|
||||
expect(parseDeepLink('clide://open?path='), isNull);
|
||||
});
|
||||
|
||||
test('a non-positive or non-numeric line is rejected (no guessing)', () {
|
||||
expect(parseDeepLink('clide://open?path=/x&line=0'), isNull);
|
||||
expect(parseDeepLink('clide://open?path=/x&line=-3'), isNull);
|
||||
expect(parseDeepLink('clide://open?path=/x&line=abc'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('paranoid allowlist (default-deny) — the security boundary', () {
|
||||
test('the allowlist is exactly the safe navigation set', () {
|
||||
expect(kDeepLinkSafeActions, {'open'});
|
||||
});
|
||||
|
||||
test('a non-allowlisted action is rejected even if well-formed', () {
|
||||
// These are the kind of things a malicious page might try.
|
||||
expect(parseDeepLink('clide://run?cmd=rm'), isNull);
|
||||
expect(parseDeepLink('clide://git?verb=push'), isNull);
|
||||
expect(parseDeepLink('clide://write?path=/x&content=evil'), isNull);
|
||||
expect(parseDeepLink('clide://exec?path=/x'), isNull);
|
||||
});
|
||||
|
||||
test('a non-clide scheme or garbage is rejected', () {
|
||||
expect(parseDeepLink('https://evil.com/open?path=/x'), isNull);
|
||||
expect(parseDeepLink('file:///etc/passwd'), isNull);
|
||||
expect(parseDeepLink('not a url at all'), isNull);
|
||||
expect(parseDeepLink(''), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// Tests for the deeplink handler's gating (T-56, D-90): an allowlisted link
|
||||
/// prompts before acting; a non-allowlisted one is rejected with no prompt.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/deeplink/deeplink.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.services.extensions.register(DeepLinkExtension());
|
||||
await f.services.extensions.activate('builtin.deeplink');
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('an allowlisted link prompts before doing anything', (tester) async {
|
||||
await tester.pumpWidget(harness(f, const SizedBox()));
|
||||
await tester.pump();
|
||||
expect(f.services.dialog.isOpen, isFalse);
|
||||
|
||||
// The handler blocks on the confirmation, so don't await it.
|
||||
unawaited(f.services.commands.execute('deeplink.invoke', args: ['clide://open?path=/x.dart']));
|
||||
await tester.pump();
|
||||
|
||||
expect(f.services.dialog.isOpen, isTrue, reason: 'a confirmation must be shown before acting');
|
||||
f.services.dialog.dismiss(false); // decline → cleanup
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('a non-allowlisted link is rejected with no prompt', (tester) async {
|
||||
await tester.pumpWidget(harness(f, const SizedBox()));
|
||||
await tester.pump();
|
||||
|
||||
final r = await f.services.commands.execute('deeplink.invoke', args: ['clide://run?cmd=rm%20-rf']);
|
||||
await tester.pump();
|
||||
|
||||
expect(f.services.dialog.isOpen, isFalse, reason: 'no dialog for a rejected link');
|
||||
expect(r.data['status'], 'rejected');
|
||||
});
|
||||
|
||||
testWidgets('a malformed link is rejected with no prompt', (tester) async {
|
||||
await tester.pumpWidget(harness(f, const SizedBox()));
|
||||
await tester.pump();
|
||||
|
||||
final r = await f.services.commands.execute('deeplink.invoke', args: ['https://evil.example/open?path=/x']);
|
||||
expect(f.services.dialog.isOpen, isFalse);
|
||||
expect(r.data['status'], 'rejected');
|
||||
});
|
||||
}
|
||||
@@ -150,37 +150,18 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('parseArgv — clide:// deep links (T-56)', () {
|
||||
test('clide://open?path= maps to editor.open', () {
|
||||
final req = _expectOk(parseArgv(['clide://open?path=/repo/x.md'], requestId: '1'));
|
||||
expect(req.cmd, 'editor.open');
|
||||
expect(req.args['positional'], ['/repo/x.md']);
|
||||
group('parseArgv — clide:// deep links route to the gated handler (T-56)', () {
|
||||
test('a clide:// URL is handed verbatim to deeplink.invoke (not translated)', () {
|
||||
// Validation + the user prompt happen in the handler (D-90), not here.
|
||||
final req = _expectOk(parseArgv(['clide://open?path=/repo/x.md&line=42'], requestId: '1'));
|
||||
expect(req.cmd, 'deeplink.invoke');
|
||||
expect(req.args['positional'], ['clide://open?path=/repo/x.md&line=42']);
|
||||
});
|
||||
|
||||
test('a &line= becomes the second positional', () {
|
||||
final req = _expectOk(parseArgv(['clide://open?path=/repo/x.md&line=42'], requestId: '2'));
|
||||
expect(req.cmd, 'editor.open');
|
||||
expect(req.args['positional'], ['/repo/x.md', '42']);
|
||||
});
|
||||
|
||||
test('an encoded path is decoded', () {
|
||||
final req = _expectOk(parseArgv(['clide://open?path=/a%20b/c.dart'], requestId: '3'));
|
||||
expect(req.args['positional'], ['/a b/c.dart']);
|
||||
});
|
||||
|
||||
test('missing path errors', () {
|
||||
final err = _expectErr(parseArgv(['clide://open'], requestId: '4'));
|
||||
expect(err.error?.message, contains('requires a ?path'));
|
||||
});
|
||||
|
||||
test('a non-positive or non-numeric line errors', () {
|
||||
expect(_expectErr(parseArgv(['clide://open?path=/x&line=0'], requestId: '5')).error?.message, contains('positive integer'));
|
||||
expect(_expectErr(parseArgv(['clide://open?path=/x&line=abc'], requestId: '6')).error?.message, contains('positive integer'));
|
||||
});
|
||||
|
||||
test('an unknown action errors', () {
|
||||
final err = _expectErr(parseArgv(['clide://frobnicate?x=1'], requestId: '7'));
|
||||
expect(err.error?.message, contains('unknown clide:// action'));
|
||||
test('even an unknown action is passed through (the handler rejects it)', () {
|
||||
final req = _expectOk(parseArgv(['clide://frobnicate?x=1'], requestId: '2'));
|
||||
expect(req.cmd, 'deeplink.invoke');
|
||||
expect(req.args['positional'], ['clide://frobnicate?x=1']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user