Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion THIRD-PARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ ghostty_vte:
git:
url: https://github.com/antgrid-ai/dart_terminal.git
path: pkgs/vte/ghostty_vte
ref: f8e2e8201c7bef116ec04baa4cfc039acb53e29c
ref: 6cd393196ed301afa1d8ada7a996cc345b899b4a
```

`ghostty_vte_flutter` and `portable_pty` are pinned to the same repository and
Expand Down
58 changes: 41 additions & 17 deletions app/lib/util/external_url.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

Expand Down Expand Up @@ -79,15 +78,23 @@ Uri? openableTerminalHyperlink(String uri) {
/// returns, so a rejection would reach `PlatformDispatcher.onError` as a fatal
/// carrying no in-app frames.
///
/// On touch the destination is confirmed first — see
/// [showTerminalHyperlinkSheet] for why that is not merely a nag.
/// The destination is confirmed first unless it was already on screen, and
/// whenever the URI is shaped to be misread — see [showTerminalHyperlinkSheet]
/// for why that is not merely a nag, and [terminalHyperlinkLooksDeceptive] for
/// the shapes.
///
/// [disclosed] is the caller saying it had this exact URI painted when the
/// activation landed — `TerminalHyperlinkPreview`, in practice. It defaults to
/// false because "we showed it" is a claim only the surface that showed it can
/// make, and a caller that has no readout must not inherit one by omission.
///
/// [open] and [confirm] are injectable so tests can assert what would be
/// launched instead of handing a URL to the real browser, matching
/// `HelpAboutSection.openUrl`.
Future<void> openTerminalHyperlink(
BuildContext context,
String uri, {
bool disclosed = false,
Future<void> Function(BuildContext, String) open = openExternalUrl,
Future<bool> Function(BuildContext, Uri) confirm = showTerminalHyperlinkSheet,
}) async {
Expand All @@ -105,7 +112,7 @@ Future<void> openTerminalHyperlink(
}
return;
}
if (_browserRevealsDestination) {
if (disclosed && !terminalHyperlinkLooksDeceptive(target)) {
await open(context, target.toString());
return;
}
Expand All @@ -130,18 +137,35 @@ Future<void> openTerminalHyperlink(
}
}

/// Whether opening the link lands somewhere that reads the destination back.
/// Whether [target] is shaped like a link trying to pass as another one.
///
/// Judged from the URI alone, which is all a terminal hyperlink hands over.
/// Three shapes qualify, and each is a host claiming to be a host it is not:
///
/// Desktop hands the URI to a browser whose address bar does, which is the same
/// disclosure every terminal leans on. The terminal's own hover affordance is
/// NOT it and never was: the view only swaps the cursor to a pointer, and
/// nothing there or here paints the URI.
/// * A userinfo prefix — `https://github.com@evil.example/` resolves to
/// `evil.example` while reading as GitHub. Dart parks the impostor in
/// [Uri.userInfo], where nothing in a URL bar's first glance looks at it.
/// * A punycoded label — `xn--pple-43d.com` renders as `apple.com`
/// with a Cyrillic first letter (U+0430).
/// * A percent-encoded host — Dart does NOT punycode a raw unicode host, it
/// percent-encodes it, so the same lie arrives spelled the other way and a
/// check for `xn--` alone misses half of it.
///
/// Mobile has no such guarantee. An `https:` host holding a verified App Link
/// opens that app instead of any browser (see [openableTerminalHyperlink]), so
/// the destination can be acted on without ever being shown. That is why the
/// mobile path asks first, and why the answer to a spoofed target on desktop is
/// a hover preview rather than the same sheet on both.
bool get _browserRevealsDestination =>
defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
/// Deliberately not a blocklist of hosts, and deliberately silent about the
/// lookalike it cannot see: `https://github.com.evil.example/` is an honest
/// subdomain of an honest domain, and only the link's visible TEXT contradicts
/// it — text that never reaches this app. A false negative here costs the user
/// the extra confirmation, not the disclosure: the sheet names the host either
/// way, and a link that was never on screen is confirmed regardless of shape.
bool terminalHyperlinkLooksDeceptive(Uri target) {
if (target.userInfo.isNotEmpty) {
return true;
}
final host = target.host;
if (host.contains('%')) {
return true;
}
// `Uri` lower-cases the host as it parses, but the loop is what a reader
// checks, not the parser's contract two files away.
return host.split('.').any((label) => label.toLowerCase().startsWith('xn--'));
}
Loading