From d25229cb954dae518bb71bf5b3c2a85aaa210a28 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Fri, 27 Feb 2026 08:31:19 +0100 Subject: [PATCH] improved uri parsing --- app/lib/extensions/uri.dart | 4 +- .../bangs/presentation/screens/edit.dart | 9 +- .../screens/bookmark_entry_edit.dart | 13 +- .../bookmarks/utils/bookmark_json_utils.dart | 7 +- .../dialogs/open_shared_content.dart | 27 +- .../utils/open_in_custom_tab.dart | 19 +- .../search/presentation/screens/search.dart | 31 ++- .../presentation/screens/container_sites.dart | 27 +- .../domain/entities/shared_content.dart | 5 +- .../presentation/add_feed_dialog.dart | 14 +- .../presentation/screens/feed_edit.dart | 16 +- app/lib/utils/form_validators.dart | 33 ++- app/lib/utils/input_classification.dart | 198 ++++++++++++++ app/lib/utils/uri_input_parser.dart | 245 ++++++++++++++++++ app/lib/utils/uri_parser.dart | 50 +--- app/lib/utils/uri_policy.dart | 71 +++++ .../domain/entities/shared_content_test.dart | 45 ++++ app/test/utils/input_classification_test.dart | 242 +++++++++++++++++ app/test/utils/uri_input_parser_test.dart | 171 ++++++++++++ 19 files changed, 1132 insertions(+), 95 deletions(-) create mode 100644 app/lib/utils/input_classification.dart create mode 100644 app/lib/utils/uri_input_parser.dart create mode 100644 app/lib/utils/uri_policy.dart create mode 100644 app/test/features/share_intent/domain/entities/shared_content_test.dart create mode 100644 app/test/utils/input_classification_test.dart create mode 100644 app/test/utils/uri_input_parser_test.dart diff --git a/app/lib/extensions/uri.dart b/app/lib/extensions/uri.dart index 62feda4d..e1c969e6 100644 --- a/app/lib/extensions/uri.dart +++ b/app/lib/extensions/uri.dart @@ -18,12 +18,12 @@ * along with this program. If not, see . */ -const _supportedSchemes = {'https', 'http', 'ftp', 'file', 'content', 'about'}; +import 'package:weblibre/utils/uri_policy.dart'; extension UriX on Uri { Uri get base => Uri.parse('$scheme://$authority'); - bool get hasSupportedScheme => _supportedSchemes.contains(scheme); + bool get hasSupportedScheme => allSupportedSchemes.any((s) => s.name == scheme); bool get isHttp => isScheme('http'); bool get isHttps => isScheme('https'); diff --git a/app/lib/features/bangs/presentation/screens/edit.dart b/app/lib/features/bangs/presentation/screens/edit.dart index 5c8e58cd..aec6d7a6 100644 --- a/app/lib/features/bangs/presentation/screens/edit.dart +++ b/app/lib/features/bangs/presentation/screens/edit.dart @@ -88,7 +88,14 @@ class EditBangScreen extends HookConsumerWidget { return; } - final uri = Uri.parse(urlTextController.text); + final uri = parseValidatedUrl( + urlTextController.text, + eagerParsing: false, + onlyHttpProtocol: true, + ); + if (uri == null) { + return; + } final bang = Bang( group: BangGroup.user, diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart index 52242437..ea140b80 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart @@ -28,7 +28,7 @@ import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositori import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart'; import 'package:weblibre/utils/form_validators.dart'; -import 'package:weblibre/utils/uri_parser.dart' as uri_parser; +import 'package:weblibre/utils/uri_input_parser.dart'; class BookmarkEntryEditScreen extends HookConsumerWidget { final BookmarkInfo? initialInfo; @@ -66,10 +66,17 @@ class BookmarkEntryEditScreen extends HookConsumerWidget { IconButton( onPressed: () async { if (formKey.currentState?.validate() ?? false) { - final newUrl = uri_parser.tryParseUrl( + var newUrl = parseValidatedUrl( urlTextController.text, eagerParsing: true, - )!; + onlyHttpProtocol: true, + ); + + if (newUrl == null) { + return; + } + + newUrl = redactUriCredentials(newUrl); if (exisitingEntry != null) { await ref diff --git a/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart b/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart index 6b6842db..4b534f69 100644 --- a/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart +++ b/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart @@ -23,6 +23,7 @@ import 'dart:convert'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/utils/uri_input_parser.dart'; class BookmarkJSONUtils { final GeckoBookmarksService _service; @@ -156,7 +157,11 @@ class BookmarkJSONUtils { await _service.addItem(parentGuid, uri, title, i); count++; } else { - logger.w('Skipping invalid URL: $url'); + final parsed = Uri.tryParse(url); + final redacted = parsed != null + ? redactUriCredentials(parsed) + : url; + logger.w('Skipping invalid URL: $redacted'); } } catch (e) { logger.e('Failed to import bookmark "$title": $e'); diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart index 73032449..29fc76a2 100644 --- a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart +++ b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart @@ -74,7 +74,10 @@ class OpenSharedContent extends HookConsumerWidget { return null; }, [currentUrl]); - final parsedDebouncedUrl = Uri.tryParse(debouncedUrl.value); + final parsedDebouncedUrl = parseValidatedUrl( + debouncedUrl.value, + eagerParsing: false, + ); final hasExternalApp = useCachedFuture( // ignore: discarded_futures useFuture () => parsedDebouncedUrl != null @@ -163,10 +166,18 @@ class OpenSharedContent extends HookConsumerWidget { Future openTab(TabMode tabMode) async { if (formKey.currentState?.validate() == true) { + final parsedUrl = parseValidatedUrl( + textController.text, + eagerParsing: false, + ); + if (parsedUrl == null) { + return; + } + await ref .read(tabRepositoryProvider.notifier) .addTab( - url: Uri.parse(textController.text), + url: parsedUrl, tabMode: tabMode, containerSelection: selectedContainer.value == null ? const TabContainerSelection.unassigned() @@ -183,8 +194,16 @@ class OpenSharedContent extends HookConsumerWidget { Future openCustomTab(bool isPrivate) async { if (formKey.currentState?.validate() == true) { + final parsedUrl = parseValidatedUrl( + textController.text, + eagerParsing: false, + ); + if (parsedUrl == null) { + return; + } + await GeckoBrowserService().openInCustomTab( - url: Uri.parse(textController.text), + url: parsedUrl, private: isPrivate, contextId: selectedContainer.value?.id, ); @@ -197,7 +216,7 @@ class OpenSharedContent extends HookConsumerWidget { Future openInApp() async { if (formKey.currentState?.validate() == true) { - final uri = Uri.tryParse(textController.text); + final uri = parseValidatedUrl(textController.text, eagerParsing: false); if (uri == null) return; final success = await _appLinksService.openAppLink(uri); diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart index cc329e44..46142bd8 100644 --- a/app/lib/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart +++ b/app/lib/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart @@ -20,14 +20,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' show GeckoBrowserService; + import 'package:weblibre/utils/ui_helper.dart'; +import 'package:weblibre/utils/uri_input_parser.dart'; +import 'package:weblibre/utils/uri_policy.dart'; Future openInPrivateCustomTab(BuildContext context, String url) async { try { - await GeckoBrowserService().openInCustomTab( - url: Uri.parse(url), - private: true, + final parsedUrl = parseUserInputUrl( + url, + policy: SchemePolicy.internalIntent, + allowSchemelessHosts: true, + enforceMaxInputLength: true, ); + if (parsedUrl == null) { + if (context.mounted) { + showErrorMessage(context, 'Could not open link: $url'); + } + return; + } + + await GeckoBrowserService().openInCustomTab(url: parsedUrl, private: true); } catch (e) { if (context.mounted) { showErrorMessage(context, 'Could not open link: $url'); diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart index 3a70c53b..5ded2738 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -47,8 +47,9 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/c import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart'; +import 'package:weblibre/utils/input_classification.dart'; import 'package:weblibre/utils/text_field_line_count.dart'; -import 'package:weblibre/utils/uri_parser.dart' as uri_parser; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class SearchScreen extends HookConsumerWidget { final String? initialSearchText; @@ -358,12 +359,26 @@ class SearchScreen extends HookConsumerWidget { unfocusOnTapOutside: false, onSubmitted: (value) async { if (value.isNotEmpty) { - var newUrl = uri_parser.tryParseUrl( - value, - eagerParsing: true, - ); + final classification = classifyAddressBarInput(value); + Uri? newUrl; + String? searchQuery; - if (newUrl == null) { + switch (classification) { + case NavigateInputClassification(:final uri): + newUrl = uri; + case SearchInputClassification(:final query): + searchQuery = query; + case InvalidInputClassification(): + if (context.mounted) { + ui_helper.showErrorMessage( + context, + 'Invalid address', + ); + } + return; + } + + if (newUrl == null && searchQuery != null) { // Read from both providers - use site if set, otherwise global final siteBang = isEditMode ? ref.read( @@ -383,12 +398,12 @@ class SearchScreen extends HookConsumerWidget { ); if (bang != null) { - newUrl = bang.getTemplateUrl(value); + newUrl = bang.getTemplateUrl(searchQuery); if (!privateTabMode) { await ref .read(bangSearchProvider.notifier) - .triggerBangSearch(bang, value); + .triggerBangSearch(bang, searchQuery); } } } diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart index e7c8e692..e56bb577 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart @@ -26,7 +26,6 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/form_validators.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; -import 'package:weblibre/utils/uri_parser.dart' as uri_parser; class ContainerSitesScreen extends HookConsumerWidget { final Set initialSites; @@ -91,11 +90,16 @@ class ContainerSitesScreen extends HookConsumerWidget { return uriValid; } - final origin = Uri.parse( - uri_parser - .tryParseUrl(value, eagerParsing: true)! - .origin, + final parsedUrl = parseValidatedUrl( + value, + eagerParsing: true, + onlyHttpProtocol: true, ); + if (parsedUrl == null) { + return 'Invalid URL'; + } + + final origin = Uri.parse(parsedUrl.origin); if (sites.value.contains(origin)) { return 'This site has been already assigned'; @@ -104,11 +108,16 @@ class ContainerSitesScreen extends HookConsumerWidget { return null; }, onSaved: (newValue) async { - final origin = Uri.parse( - uri_parser - .tryParseUrl(newValue, eagerParsing: true)! - .origin, + final parsedUrl = parseValidatedUrl( + newValue, + eagerParsing: true, + onlyHttpProtocol: true, ); + if (parsedUrl == null) { + return; + } + + final origin = Uri.parse(parsedUrl.origin); final isAssigned = await ref .read(containerRepositoryProvider.notifier) diff --git a/app/lib/features/share_intent/domain/entities/shared_content.dart b/app/lib/features/share_intent/domain/entities/shared_content.dart index 371324ee..5d25d345 100644 --- a/app/lib/features/share_intent/domain/entities/shared_content.dart +++ b/app/lib/features/share_intent/domain/entities/shared_content.dart @@ -18,14 +18,13 @@ * along with this program. If not, see . */ import 'package:fast_equatable/fast_equatable.dart'; -import 'package:weblibre/utils/uri_parser.dart' as uri_parser; +import 'package:weblibre/utils/input_classification.dart'; sealed class SharedContent with FastEquatable { SharedContent(); factory SharedContent.parse(String content) { - if (uri_parser.tryParseUrl(content, eagerParsing: true) - case final Uri uri) { + if (parseSharedIntentUrl(content) case final Uri uri) { return SharedUrl(uri); } else { return SharedText(content); diff --git a/app/lib/features/web_feed/presentation/add_feed_dialog.dart b/app/lib/features/web_feed/presentation/add_feed_dialog.dart index b8ee3190..8311a03f 100644 --- a/app/lib/features/web_feed/presentation/add_feed_dialog.dart +++ b/app/lib/features/web_feed/presentation/add_feed_dialog.dart @@ -24,7 +24,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/web_feed/domain/providers/add_dialog_blocking.dart'; import 'package:weblibre/utils/form_validators.dart'; -import 'package:weblibre/utils/uri_parser.dart' as uri_parser; class AddFeedDialog extends HookConsumerWidget { final Uri? initialUri; @@ -83,9 +82,16 @@ class AddFeedDialog extends HookConsumerWidget { TextButton( onPressed: () { if (formKey.currentState?.validate() == true) { - FeedCreateRoute( - feedId: uri_parser.tryParseUrl(textController.text)!, - ).pushReplacement(context); + final feedId = parseValidatedUrl( + textController.text, + eagerParsing: false, + onlyHttpProtocol: true, + ); + if (feedId == null) { + return; + } + + FeedCreateRoute(feedId: feedId).pushReplacement(context); } }, child: const Text('Add'), diff --git a/app/lib/features/web_feed/presentation/screens/feed_edit.dart b/app/lib/features/web_feed/presentation/screens/feed_edit.dart index 6ad44b6b..043e8bc6 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_edit.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_edit.dart @@ -34,7 +34,6 @@ import 'package:weblibre/features/web_feed/presentation/widgets/tag_field.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/form_validators.dart'; -import 'package:weblibre/utils/uri_parser.dart' as uri_parser; enum _DialogMode { create, edit } @@ -156,19 +155,22 @@ class _FeedEditContent extends HookConsumerWidget { onPressed: () async { if (formKey.currentState?.validate() ?? false) { final feedData = FeedData( - url: uri_parser.tryParseUrl( + url: parseValidatedUrl( urlTextController.text, - eagerParsing: true, + eagerParsing: false, + onlyHttpProtocol: true, )!, authors: initialFeed.authors, description: descriptionTextController.text.whenNotEmpty, - icon: uri_parser.tryParseUrl( + icon: parseValidatedUrl( iconUrlTextController.text, - eagerParsing: true, + eagerParsing: false, + onlyHttpProtocol: true, ), - siteLink: uri_parser.tryParseUrl( + siteLink: parseValidatedUrl( siteLinkTextController.text, - eagerParsing: true, + eagerParsing: false, + onlyHttpProtocol: true, ), tags: tags.value.map((tag) => FeedCategory(id: tag)).toList(), title: titleTextController.text.whenNotEmpty, diff --git a/app/lib/utils/form_validators.dart b/app/lib/utils/form_validators.dart index c9392d51..171aec34 100644 --- a/app/lib/utils/form_validators.dart +++ b/app/lib/utils/form_validators.dart @@ -21,8 +21,25 @@ import 'dart:io'; import 'package:nullability/nullability.dart'; import 'package:path/path.dart' as p; -import 'package:weblibre/extensions/uri.dart'; -import 'package:weblibre/utils/uri_parser.dart' as uri_parser; +import 'package:weblibre/utils/uri_input_parser.dart'; +import 'package:weblibre/utils/uri_policy.dart'; + +Uri? parseValidatedUrl( + String? value, { + required bool eagerParsing, + bool onlyHttpProtocol = false, +}) { + final policy = onlyHttpProtocol + ? SchemePolicy.strictHttpOnly + : SchemePolicy.internalIntent; + + return parseUserInputUrl( + value, + policy: policy, + allowSchemelessHosts: eagerParsing, + enforceMaxInputLength: true, + ); +} String? validateUrl( String? value, { @@ -39,16 +56,18 @@ String? validateUrl( } } - if (uri_parser.tryParseUrl(value, eagerParsing: eagerParsing) + if (parseValidatedUrl( + value, + eagerParsing: eagerParsing, + onlyHttpProtocol: onlyHttpProtocol, + ) case final Uri url) { if (!requireAuthority || url.authority.isNotEmpty) { - if (!onlyHttpProtocol || url.isHttpOrHttps) { - return null; - } + return null; } } - return 'Inavlid URL'; + return 'Invalid URL'; } String? validateRequired(String? value, {String message = 'Value required'}) { diff --git a/app/lib/utils/input_classification.dart b/app/lib/utils/input_classification.dart new file mode 100644 index 00000000..ccd75a6e --- /dev/null +++ b/app/lib/utils/input_classification.dart @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:io'; + +import 'package:weblibre/utils/uri_input_parser.dart'; +import 'package:weblibre/utils/uri_policy.dart'; + +enum NavigationReason { + explicitScheme, + schemelessHost, + localhost, + ipLiteral, + aboutLike, +} + +enum SearchReason { + containsWhitespace, + notHostCandidate, + invalidHostChars, + fallback, +} + +enum InvalidReason { + unsupportedScheme, + malformedUri, + containsControlChars, + emptyInput, + tooLong, +} + +sealed class InputClassification { + const InputClassification(); + + const factory InputClassification.navigate(Uri uri, NavigationReason reason) = + NavigateInputClassification; + const factory InputClassification.search(String query, SearchReason reason) = + SearchInputClassification; + const factory InputClassification.invalid(String raw, InvalidReason reason) = + InvalidInputClassification; +} + +final class NavigateInputClassification extends InputClassification { + final Uri uri; + final NavigationReason reason; + + const NavigateInputClassification(this.uri, this.reason); +} + +final class SearchInputClassification extends InputClassification { + final String query; + final SearchReason reason; + + const SearchInputClassification(this.query, this.reason); +} + +final class InvalidInputClassification extends InputClassification { + final String raw; + final InvalidReason reason; + + const InvalidInputClassification(this.raw, this.reason); +} + +InputClassification classifyAddressBarInput( + String input, { + SchemePolicy policy = SchemePolicy.addressBarTyped, +}) { + final normalizedInput = normalizeInput(input); + + if (normalizedInput.isEmpty) { + return InputClassification.invalid( + normalizedInput, + InvalidReason.emptyInput, + ); + } + + if (exceedsMaxInputLength(normalizedInput)) { + return InputClassification.invalid(normalizedInput, InvalidReason.tooLong); + } + + if (containsControlChars(normalizedInput)) { + return InputClassification.invalid( + normalizedInput, + InvalidReason.containsControlChars, + ); + } + + if (hasExplicitScheme(normalizedInput)) { + final explicitUri = parseExplicitUri(normalizedInput, policy: policy); + if (explicitUri != null) { + final reason = explicitUri.isScheme('about') + ? NavigationReason.aboutLike + : NavigationReason.explicitScheme; + + return InputClassification.navigate(explicitUri, reason); + } + + if (hasAllowedScheme(normalizedInput, policy.allowedSchemes)) { + return InputClassification.invalid( + normalizedInput, + InvalidReason.malformedUri, + ); + } + + return InputClassification.invalid( + normalizedInput, + InvalidReason.unsupportedScheme, + ); + } + + if (normalizedInput.contains(whitespaceRegex)) { + return InputClassification.search( + normalizedInput, + SearchReason.containsWhitespace, + ); + } + + final schemelessUri = parseSchemelessWebHost( + normalizedInput, + allowedSchemes: policy.allowedSchemes, + ); + + if (schemelessUri != null) { + if (schemelessUri.host.toLowerCase() == 'localhost') { + return InputClassification.navigate( + schemelessUri, + NavigationReason.localhost, + ); + } + + if (InternetAddress.tryParse(schemelessUri.host) != null) { + return InputClassification.navigate( + schemelessUri, + NavigationReason.ipLiteral, + ); + } + + return InputClassification.navigate( + schemelessUri, + NavigationReason.schemelessHost, + ); + } + + if (looksLikeHostExpression(normalizedInput) && + hasInvalidHostLikeChars(normalizedInput)) { + return InputClassification.search( + normalizedInput, + SearchReason.invalidHostChars, + ); + } + + return InputClassification.search( + normalizedInput, + SearchReason.notHostCandidate, + ); +} + +Uri? parseSharedIntentUrl( + String input, { + SchemePolicy policy = SchemePolicy.sharedIntent, +}) { + final normalizedInput = normalizeInput(input); + if (normalizedInput.isEmpty || + exceedsMaxInputLength(normalizedInput) || + containsControlChars(normalizedInput)) { + return null; + } + + if (hasExplicitScheme(normalizedInput)) { + return parseExplicitUri(normalizedInput, policy: policy); + } + + if (normalizedInput.contains(whitespaceRegex)) { + return null; + } + + return parseSchemelessWebHost( + normalizedInput, + allowedSchemes: policy.allowedSchemes, + ); +} diff --git a/app/lib/utils/uri_input_parser.dart b/app/lib/utils/uri_input_parser.dart new file mode 100644 index 00000000..7f1b90db --- /dev/null +++ b/app/lib/utils/uri_input_parser.dart @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:io'; + +import 'package:weblibre/utils/uri_policy.dart'; + +const maxUriInputLength = 4096; + +final _controlCharacterRegex = RegExp(r'[\x00-\x1F\u007F-\u009F]'); +final _explicitSchemeRegex = RegExp(r'^([a-zA-Z][a-zA-Z0-9+\-.]*):'); +final _hostPortSuffixRegex = RegExp(r'^\d{1,5}([/?#].*)?$'); +final _hostLabelRegex = RegExp(r'^[a-zA-Z0-9-]{1,63}$'); +final _topLevelDomainRegex = RegExp(r'^[a-zA-Z]{2,63}$'); +final _possibleHostCharsRegex = RegExp( + r"^[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$", +); +final whitespaceRegex = RegExp(r'\s'); + +String normalizeInput(String input) { + return input.trim(); +} + +bool containsControlChars(String input) { + return _controlCharacterRegex.hasMatch(input); +} + +bool hasAllowedScheme(String input, Set allowedSchemes) { + final scheme = _extractExplicitScheme(input); + if (scheme == null) { + return false; + } + + return allowedSchemes.contains(scheme); +} + +bool hasExplicitScheme(String input) { + final match = _explicitSchemeRegex.firstMatch(input); + if (match == null) { + return false; + } + + final scheme = match.group(1)?.toLowerCase(); + if (scheme == null) { + return false; + } + + final suffix = input.substring(match.end); + if (suffix.startsWith('//')) { + return true; + } + + // Keep host:port shorthand treated as schemeless input. + if ((scheme == 'localhost' || scheme.contains('.')) && + _hostPortSuffixRegex.hasMatch(suffix)) { + return false; + } + + return true; +} + +bool exceedsMaxInputLength(String input, {int limit = maxUriInputLength}) { + return input.length > limit; +} + +bool isValidHostCandidate(String hostCandidate) { + final host = hostCandidate.trim().toLowerCase(); + if (host.isEmpty) { + return false; + } + + if (host == 'localhost') { + return true; + } + + if (InternetAddress.tryParse(host) != null) { + return true; + } + + final labels = host.split('.'); + if (labels.length < 2) { + return false; + } + + if (!_topLevelDomainRegex.hasMatch(labels.last)) { + return false; + } + + for (final label in labels) { + if (!_hostLabelRegex.hasMatch(label) || + label.startsWith('-') || + label.endsWith('-')) { + return false; + } + } + + return true; +} + +bool looksLikeHostExpression(String input) { + return input.contains('.') || + input.contains(':') || + input.startsWith('[') || + input.contains('/'); +} + +bool hasInvalidHostLikeChars(String input) { + return !_possibleHostCharsRegex.hasMatch(input); +} + +Uri? parseExplicitUri( + String input, { + required SchemePolicy policy, + bool enforceMaxInputLength = false, +}) { + final normalizedInput = normalizeInput(input); + if (normalizedInput.isEmpty || + (enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) || + containsControlChars(normalizedInput) || + !hasExplicitScheme(normalizedInput) || + !hasAllowedScheme(normalizedInput, policy.allowedSchemes)) { + return null; + } + + final uri = Uri.tryParse(normalizedInput); + if (uri == null) { + return null; + } + + final scheme = uri.scheme.toLowerCase(); + if (!policy.allows(scheme)) { + return null; + } + + if (schemeRequiresAuthority(scheme) && uri.authority.isEmpty) { + return null; + } + + return uri.replace(scheme: scheme); +} + +Uri? parseSchemelessWebHost( + String input, { + Set? allowedSchemes, + bool enforceMaxInputLength = false, +}) { + final normalizedInput = normalizeInput(input); + if (normalizedInput.isEmpty || + (enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) || + containsControlChars(normalizedInput) || + hasExplicitScheme(normalizedInput) || + normalizedInput.contains(whitespaceRegex)) { + return null; + } + + final probeUri = Uri.tryParse('https://$normalizedInput'); + if (probeUri == null || probeUri.host.isEmpty) { + return null; + } + + if (!isValidHostCandidate(probeUri.host)) { + return null; + } + + if (probeUri.hasPort && (probeUri.port < 1 || probeUri.port > 65535)) { + return null; + } + + final scheme = probeUri.host.toLowerCase() == 'localhost' ? 'http' : 'https'; + if (allowedSchemes != null && !allowedSchemes.contains(scheme)) { + return null; + } + + return probeUri.replace(scheme: scheme); +} + +Uri? parseUserInputUrl( + String? input, { + required SchemePolicy policy, + bool allowSchemelessHosts = false, + bool enforceMaxInputLength = false, +}) { + if (input == null) { + return null; + } + + final normalizedInput = normalizeInput(input); + if (normalizedInput.isEmpty || + (enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) || + containsControlChars(normalizedInput)) { + return null; + } + + if (hasExplicitScheme(normalizedInput)) { + return parseExplicitUri( + normalizedInput, + policy: policy, + enforceMaxInputLength: enforceMaxInputLength, + ); + } + + if (!allowSchemelessHosts) { + return null; + } + + return parseSchemelessWebHost( + normalizedInput, + allowedSchemes: policy.allowedSchemes, + enforceMaxInputLength: enforceMaxInputLength, + ); +} + +Uri redactUriCredentials(Uri uri) { + if (uri.userInfo.isEmpty) { + return uri; + } + + return uri.replace(userInfo: ''); +} + +String? _extractExplicitScheme(String input) { + final match = _explicitSchemeRegex.firstMatch(input); + if (match == null) { + return null; + } + + return match.group(1)?.toLowerCase(); +} diff --git a/app/lib/utils/uri_parser.dart b/app/lib/utils/uri_parser.dart index 2e0c0eb9..60046c1f 100644 --- a/app/lib/utils/uri_parser.dart +++ b/app/lib/utils/uri_parser.dart @@ -18,49 +18,13 @@ * along with this program. If not, see . */ -import 'dart:io'; - -import 'package:weblibre/extensions/uri.dart'; - -final _domainRegex = RegExp(r'\.[a-zA-Z]{2,}$'); +import 'package:weblibre/utils/uri_input_parser.dart'; +import 'package:weblibre/utils/uri_policy.dart'; Uri? tryParseUrl(String? input, {bool eagerParsing = false}) { - if (input != null) { - var uri = Uri.tryParse(input); - if (uri != null) { - if (uri.authority.isEmpty && eagerParsing) { - if (uri.pathSegments.isNotEmpty) { - int? port; - var firstSegment = uri.pathSegments.first; - - //When there is no scheme while aprsing, the port becomse the first segment because : is treated as delimeter - if (int.tryParse(firstSegment) case final int segmentPort) { - port = segmentPort; - firstSegment = uri.scheme; - } - - if (_domainRegex.hasMatch(firstSegment)) { - uri = Uri.tryParse('https://$input')?.replace(port: port); - } else if (firstSegment == 'localhost') { - uri = Uri.tryParse('http://$input')?.replace(port: port); - } else if (InternetAddress.tryParse(firstSegment) != null) { - uri = Uri.tryParse('https://$input')?.replace(port: port); - } - } - } - - if (uri != null) { - if (uri.isScheme('about')) { - return uri; - } - - if (uri.authority.isNotEmpty) { - if (uri.hasSupportedScheme || !uri.hasScheme) { - return uri; - } - } - } - } - } - return null; + return parseUserInputUrl( + input, + policy: SchemePolicy.internalIntent, + allowSchemelessHosts: eagerParsing, + ); } diff --git a/app/lib/utils/uri_policy.dart b/app/lib/utils/uri_policy.dart new file mode 100644 index 00000000..296bb9e6 --- /dev/null +++ b/app/lib/utils/uri_policy.dart @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +class Scheme { + final String name; + final bool requiresAuthority; + const Scheme(this.name, {this.requiresAuthority = false}); +} + +const kSchemeHttp = Scheme('http', requiresAuthority: true); +const kSchemeHttps = Scheme('https', requiresAuthority: true); +const kSchemeFtp = Scheme('ftp', requiresAuthority: true); +const kSchemeFile = Scheme('file'); +const kSchemeContent = Scheme('content', requiresAuthority: true); +const kSchemeAbout = Scheme('about'); +const kSchemeMozExtension = Scheme('moz-extension', requiresAuthority: true); + +const allSupportedSchemes = [ + kSchemeHttp, + kSchemeHttps, + kSchemeFtp, + kSchemeFile, + kSchemeContent, + kSchemeAbout, + kSchemeMozExtension, +]; + +const httpOnlySchemes = [kSchemeHttp, kSchemeHttps]; + +bool schemeRequiresAuthority(String scheme) { + return allSupportedSchemes.any((s) => s.name == scheme && s.requiresAuthority); +} + +enum SchemePolicy { + addressBarTyped, + strictHttpOnly, + sharedIntent, + internalIntent, +} + +extension SchemePolicyX on SchemePolicy { + List get _schemes => switch (this) { + SchemePolicy.addressBarTyped => allSupportedSchemes, + SchemePolicy.strictHttpOnly => httpOnlySchemes, + SchemePolicy.sharedIntent => httpOnlySchemes, + SchemePolicy.internalIntent => allSupportedSchemes, + }; + + Set get allowedSchemes => _schemes.map((s) => s.name).toSet(); + + bool allows(String scheme) { + return allowedSchemes.contains(scheme.toLowerCase()); + } +} diff --git a/app/test/features/share_intent/domain/entities/shared_content_test.dart b/app/test/features/share_intent/domain/entities/shared_content_test.dart new file mode 100644 index 00000000..5cd48818 --- /dev/null +++ b/app/test/features/share_intent/domain/entities/shared_content_test.dart @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; + +void main() { + group('SharedContent.parse', () { + test('returns SharedUrl for explicit https uri', () { + final parsed = SharedContent.parse('https://weblibre.eu/path'); + + expect(parsed, isA()); + expect((parsed as SharedUrl).url.host, 'weblibre.eu'); + }); + + test('returns SharedText for explicit non-http scheme', () { + final parsed = SharedContent.parse('moz-extension://abc/index.html'); + + expect(parsed, isA()); + }); + + test('returns SharedText for plain sentence', () { + final parsed = SharedContent.parse('WebLibre README.md'); + + expect(parsed, isA()); + }); + }); +} diff --git a/app/test/utils/input_classification_test.dart b/app/test/utils/input_classification_test.dart new file mode 100644 index 00000000..2dc6ed73 --- /dev/null +++ b/app/test/utils/input_classification_test.dart @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/utils/input_classification.dart'; + +void main() { + group('classifyAddressBarInput', () { + test('searches free text containing whitespace', () { + final result = classifyAddressBarInput('WebLibre README.md'); + + expect(result, isA()); + final search = result as SearchInputClassification; + expect(search.reason, SearchReason.containsWhitespace); + expect(search.query, 'WebLibre README.md'); + }); + + test('navigates schemeless domain', () { + final result = classifyAddressBarInput('weblibre.eu'); + + expect(result, isA()); + final navigation = result as NavigateInputClassification; + expect(navigation.reason, NavigationReason.schemelessHost); + expect(navigation.uri.toString(), 'https://weblibre.eu'); + }); + + test('navigates localhost over http', () { + final result = classifyAddressBarInput('localhost:8080'); + + expect(result, isA()); + final navigation = result as NavigateInputClassification; + expect(navigation.reason, NavigationReason.localhost); + expect(navigation.uri.toString(), 'http://localhost:8080'); + }); + + test('navigates explicit moz-extension uri', () { + final result = classifyAddressBarInput('moz-extension://abc/index.html'); + + expect(result, isA()); + final navigation = result as NavigateInputClassification; + expect(navigation.reason, NavigationReason.explicitScheme); + expect(navigation.uri.scheme, 'moz-extension'); + }); + + test('rejects explicit unsupported scheme as invalid', () { + final result = classifyAddressBarInput('myapp://callback?token=abc'); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.unsupportedScheme); + }); + + test('rejects dotted explicit unsupported scheme as invalid', () { + final result = classifyAddressBarInput('my.app://callback'); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.unsupportedScheme); + }); + + test('rejects javascript scheme as invalid', () { + final result = classifyAddressBarInput('JAVASCRIPT:alert(1)'); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.unsupportedScheme); + }); + + test('does not decode encoded scheme before classification', () { + final result = classifyAddressBarInput('%6aavascript:alert(1)'); + + expect(result, isA()); + }); + + test('rejects control chars as invalid', () { + final result = classifyAddressBarInput('example.com\x00path'); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.containsControlChars); + }); + + test('invalid schemeless port falls back to search', () { + final result = classifyAddressBarInput('weblibre.eu:99999'); + + expect(result, isA()); + }); + + test('explicit disallowed scheme with whitespace remains invalid', () { + final result = classifyAddressBarInput('javascript: alert(1)'); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.unsupportedScheme); + }); + + test('unknown explicit scheme with whitespace remains invalid', () { + final result = classifyAddressBarInput('myapp: secret token'); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.unsupportedScheme); + }); + + test('colon-prefixed query with whitespace is treated as invalid', () { + final result = classifyAddressBarInput('site:weblibre.eu privacy'); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.unsupportedScheme); + }); + + test('symbolic colon query with whitespace is treated as invalid', () { + final result = classifyAddressBarInput('c++: tutorial'); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.unsupportedScheme); + }); + + test('navigates schemeless domain with valid port', () { + final result = classifyAddressBarInput('weblibre.eu:8080'); + + expect(result, isA()); + final navigation = result as NavigateInputClassification; + expect(navigation.reason, NavigationReason.schemelessHost); + expect(navigation.uri.toString(), 'https://weblibre.eu:8080'); + }); + + test('navigates IP literal', () { + final result = classifyAddressBarInput('192.168.1.1'); + + expect(result, isA()); + final navigation = result as NavigateInputClassification; + expect(navigation.reason, NavigationReason.ipLiteral); + expect(navigation.uri.toString(), 'https://192.168.1.1'); + }); + + test('navigates explicit scheme with space in path', () { + final result = classifyAddressBarInput('https://example.com/a b'); + + expect(result, isA()); + final navigation = result as NavigateInputClassification; + expect(navigation.reason, NavigationReason.explicitScheme); + expect(navigation.uri.host, 'example.com'); + }); + + test('rejects data scheme as invalid', () { + final result = classifyAddressBarInput( + 'data:text/html,', + ); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.unsupportedScheme); + }); + + test('treats missing scheme prefix as search', () { + final result = classifyAddressBarInput('://example.com'); + + expect(result, isA()); + }); + + test('navigates URL with credentials', () { + final result = classifyAddressBarInput('https://user:pass@example.com'); + + expect(result, isA()); + final navigation = result as NavigateInputClassification; + expect(navigation.reason, NavigationReason.explicitScheme); + expect(navigation.uri.host, 'example.com'); + }); + + test('rejects very long input as tooLong', () { + final longInput = 'a' * 5000; + final result = classifyAddressBarInput(longInput); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.tooLong); + }); + + test('rejects empty input as emptyInput', () { + final result = classifyAddressBarInput(''); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.emptyInput); + }); + + test('rejects whitespace-only input as emptyInput', () { + final result = classifyAddressBarInput(' '); + + expect(result, isA()); + final invalid = result as InvalidInputClassification; + expect(invalid.reason, InvalidReason.emptyInput); + }); + }); + + group('parseSharedIntentUrl', () { + test('parses explicit https', () { + final uri = parseSharedIntentUrl('https://weblibre.eu'); + + expect(uri, isNotNull); + expect(uri!.scheme, 'https'); + }); + + test('treats explicit non-http scheme as non-url', () { + final uri = parseSharedIntentUrl('moz-extension://abc/index.html'); + + expect(uri, isNull); + }); + + test('treats explicit dotted non-http scheme as non-url', () { + final uri = parseSharedIntentUrl('my.app://callback'); + + expect(uri, isNull); + }); + + test('treats plain sentence as non-url', () { + final uri = parseSharedIntentUrl('This is WebLibre README.md'); + + expect(uri, isNull); + }); + }); +} diff --git a/app/test/utils/uri_input_parser_test.dart b/app/test/utils/uri_input_parser_test.dart new file mode 100644 index 00000000..3f5dafec --- /dev/null +++ b/app/test/utils/uri_input_parser_test.dart @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/utils/uri_input_parser.dart'; +import 'package:weblibre/utils/uri_policy.dart'; + +void main() { + group('parseExplicitUri', () { + test('parses supported explicit scheme', () { + final uri = parseExplicitUri( + 'https://weblibre.eu/path', + policy: SchemePolicy.addressBarTyped, + ); + + expect(uri, isNotNull); + expect(uri!.scheme, 'https'); + expect(uri.host, 'weblibre.eu'); + }); + + test('rejects unsupported explicit scheme', () { + final uri = parseExplicitUri( + 'javascript:alert(1)', + policy: SchemePolicy.addressBarTyped, + ); + + expect(uri, isNull); + }); + + test('rejects malformed explicit uri with required authority', () { + final uri = parseExplicitUri( + 'https:///path', + policy: SchemePolicy.addressBarTyped, + ); + + expect(uri, isNull); + }); + }); + + group('parseSchemelessWebHost', () { + test('upgrades domain to https', () { + final uri = parseSchemelessWebHost('weblibre.eu'); + + expect(uri, isNotNull); + expect(uri!.toString(), 'https://weblibre.eu'); + }); + + test('upgrades localhost to http', () { + final uri = parseSchemelessWebHost('localhost:8080'); + + expect(uri, isNotNull); + expect(uri!.toString(), 'http://localhost:8080'); + }); + + test('rejects spaces in host candidate', () { + expect(parseSchemelessWebHost('foo bar.com'), isNull); + }); + + test('rejects invalid port range', () { + expect(parseSchemelessWebHost('weblibre.eu:99999'), isNull); + }); + }); + + group('parseUserInputUrl', () { + test('parses schemeless host only when enabled', () { + expect( + parseUserInputUrl('weblibre.eu', policy: SchemePolicy.addressBarTyped), + isNull, + ); + + expect( + parseUserInputUrl( + 'weblibre.eu', + policy: SchemePolicy.addressBarTyped, + allowSchemelessHosts: true, + ), + isNotNull, + ); + }); + + test('rejects control chars', () { + expect( + parseUserInputUrl( + 'example.com\x00path', + policy: SchemePolicy.addressBarTyped, + allowSchemelessHosts: true, + ), + isNull, + ); + }); + + test('allows long persisted urls when max length is not enforced', () { + final longPath = 'a' * 5000; + final uri = parseUserInputUrl( + 'https://weblibre.eu/$longPath', + policy: SchemePolicy.addressBarTyped, + ); + + expect(uri, isNotNull); + }); + + test('rejects long user input urls when max length is enforced', () { + final longPath = 'a' * 5000; + final uri = parseUserInputUrl( + 'https://weblibre.eu/$longPath', + policy: SchemePolicy.addressBarTyped, + enforceMaxInputLength: true, + ); + + expect(uri, isNull); + }); + }); + + group('redactUriCredentials', () { + test('strips userInfo from URI', () { + final uri = Uri.parse('https://user:pass@example.com/path'); + final redacted = redactUriCredentials(uri); + + expect(redacted.userInfo, isEmpty); + expect(redacted.host, 'example.com'); + expect(redacted.path, '/path'); + expect(redacted.toString(), 'https://example.com/path'); + }); + + test('is no-op for URIs without credentials', () { + final uri = Uri.parse('https://example.com/path'); + final redacted = redactUriCredentials(uri); + + expect(identical(redacted, uri), isTrue); + }); + }); + + group('isValidHostCandidate', () { + test('rejects single-label hosts', () { + expect(isValidHostCandidate('example'), isFalse); + expect(isValidHostCandidate('intranet'), isFalse); + }); + }); + + group('containsControlChars', () { + test('detects null bytes', () { + expect(containsControlChars('example\x00.com'), isTrue); + }); + + test('detects C1 control chars', () { + expect(containsControlChars('example\u0080.com'), isTrue); + expect(containsControlChars('example\u009F.com'), isTrue); + }); + + test('allows normal text', () { + expect(containsControlChars('example.com'), isFalse); + }); + }); +}