improved uri parsing
This commit is contained in:
@@ -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'}) {
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String> 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<String>? 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();
|
||||
}
|
||||
@@ -18,49 +18,13 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Scheme> get _schemes => switch (this) {
|
||||
SchemePolicy.addressBarTyped => allSupportedSchemes,
|
||||
SchemePolicy.strictHttpOnly => httpOnlySchemes,
|
||||
SchemePolicy.sharedIntent => httpOnlySchemes,
|
||||
SchemePolicy.internalIntent => allSupportedSchemes,
|
||||
};
|
||||
|
||||
Set<String> get allowedSchemes => _schemes.map((s) => s.name).toSet();
|
||||
|
||||
bool allows(String scheme) {
|
||||
return allowedSchemes.contains(scheme.toLowerCase());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user