From e6d7c9fdf50c064d2989cbc6eef6c2a83dffdf9c Mon Sep 17 00:00:00 2001 From: zaelgohary Date: Sun, 18 Jan 2026 05:07:15 +0200 Subject: [PATCH] Implement InputValidator helper class to centralize and standardize validation logic for external inputs (URLs, JSON, content fetching) --- app/lib/helpers/input_validator.dart | 71 ++++++++++++ app/lib/screens/sign_screen.dart | 27 ++++- app/lib/screens/signing/sign_with_link.dart | 94 ++++++++-------- app/lib/screens/signing/sign_with_qrcode.dart | 103 +++++++++++------- app/lib/screens/signing/signing_mixin.dart | 27 +---- app/lib/services/stellar_service.dart | 3 +- app/lib/services/uni_link_service.dart | 16 ++- 7 files changed, 217 insertions(+), 124 deletions(-) create mode 100644 app/lib/helpers/input_validator.dart diff --git a/app/lib/helpers/input_validator.dart b/app/lib/helpers/input_validator.dart new file mode 100644 index 000000000..50f63b5aa --- /dev/null +++ b/app/lib/helpers/input_validator.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:threebotlogin/helpers/logger.dart'; + +class InputValidator { + static const int maxUrlLength = 2048; + static const int maxContentLength = 100000; + static const int maxScopeLength = 10000; + static const Duration httpTimeout = Duration(seconds: 30); + + static Uri? validateUrl(String url, {int? maxLength}) { + try { + final trimmedUrl = url.trim(); + if (trimmedUrl.length > (maxLength ?? maxUrlLength)) return null; + + final uri = Uri.parse(trimmedUrl); + if (!uri.isScheme('http') && !uri.isScheme('https')) return null; + if (!uri.hasAuthority) return null; + + return uri; + } catch (e) { + logger.e('Invalid URL: $e'); + return null; + } + } + + static Map? decodeJson(String jsonString, {int? maxLength}) { + try { + if (jsonString.length > (maxLength ?? maxContentLength)) return null; + + final decoded = json.decode(jsonString); + return decoded is Map ? decoded : null; + } catch (e) { + logger.e('Invalid JSON: $e'); + return null; + } + } + + static bool isValidLength(String? value, int maxLength) { + return value != null && value.length <= maxLength; + } + + static Future fetchValidatedContent(Uri uri, + {int? maxLength}) async { + try { + final response = await http.get(uri).timeout(httpTimeout); + + if (response.statusCode != 200) { + logger.e('Failed to fetch: HTTP ${response.statusCode}'); + return null; + } + + if (response.body.isEmpty) { + logger.e('Empty response body'); + return null; + } + + final maxLen = maxLength ?? maxContentLength; + if (response.body.length > maxLen) { + logger.e( + 'Response too large: ${response.body.length} bytes (max: $maxLen)'); + return null; + } + + return response.body; + } catch (e) { + logger.e('Error fetching content: $e'); + return null; + } + } +} diff --git a/app/lib/screens/sign_screen.dart b/app/lib/screens/sign_screen.dart index 0497a9c30..fc139dd76 100644 --- a/app/lib/screens/sign_screen.dart +++ b/app/lib/screens/sign_screen.dart @@ -1,10 +1,7 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_json_viewer/flutter_json_viewer.dart'; -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; import 'package:threebotlogin/events/events.dart'; import 'package:flutter/material.dart'; @@ -19,6 +16,7 @@ import 'package:threebotlogin/services/crypto_service.dart'; import 'package:threebotlogin/services/shared_preference_service.dart'; import 'package:threebotlogin/services/tools_service.dart'; import 'package:threebotlogin/widgets/custom_dialog.dart'; +import 'package:threebotlogin/helpers/input_validator.dart'; class SignScreen extends StatefulWidget { const SignScreen(this.signData, {super.key}); @@ -60,13 +58,30 @@ class _SignScreenState extends State with BlockAndRunMixin { } try { - Uri url = Uri.parse(widget.signData.dataUrl!); - Response r = await http.get(url); + final dataUrl = widget.signData.dataUrl; + final uri = dataUrl != null && dataUrl.isNotEmpty + ? InputValidator.validateUrl(dataUrl) + : null; - urlData = json.decode(r.body.toString()); + if (uri == null) { + throw FormatException('Invalid data URL'); + } + + final content = await InputValidator.fetchValidatedContent(uri); + if (content == null) { + throw FormatException('Failed to fetch data'); + } + + final decoded = InputValidator.decodeJson(content); + if (decoded == null) { + throw FormatException('Invalid JSON format'); + } + + urlData = decoded; isDataLoading = false; setState(() {}); } catch (e) { + logger.e('Error fetching sign data: $e'); errorMessage = 'Failed to load data'; isDataLoading = false; setState(() {}); diff --git a/app/lib/screens/signing/sign_with_link.dart b/app/lib/screens/signing/sign_with_link.dart index 4d1f765d6..ea77a3e37 100644 --- a/app/lib/screens/signing/sign_with_link.dart +++ b/app/lib/screens/signing/sign_with_link.dart @@ -5,7 +5,7 @@ import 'package:threebotlogin/helpers/logger.dart'; import 'package:threebotlogin/providers/wallets_provider.dart'; import 'package:threebotlogin/screens/signing/signing_mixin.dart'; import 'package:threebotlogin/widgets/custom_dialog.dart'; -import 'package:http/http.dart' as http; +import 'package:threebotlogin/helpers/input_validator.dart'; class SignWithLinkScreen extends ConsumerStatefulWidget { const SignWithLinkScreen({super.key}); @@ -97,19 +97,18 @@ class _SignWithLinkScreenState extends ConsumerState }); try { - String linkText = _linkController.text; - - try { - final response = await http.get(Uri.parse(linkText)); - if (response.statusCode == 200) { - _dataController.text = response.body; - textController.text = response.body; + final linkText = _linkController.text.trim(); + final uri = InputValidator.validateUrl(linkText); + if (uri != null) { + final content = await InputValidator.fetchValidatedContent(uri); + if (content != null) { + _dataController.text = content; + textController.text = content; setState(() { linkError = null; dataError = null; isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Content fetched successfully', @@ -118,55 +117,48 @@ class _SignWithLinkScreenState extends ConsumerState ), ); return; - } else { - throw Exception('Failed to fetch content: ${response.statusCode}'); - } - } catch (e) { - final Uri link = Uri.parse(linkText); - Map queryParams = link.queryParameters; - - List requiredParams = [ - 'dataHash', - 'state', - 'appId', - 'dataUrl', - 'isJson', - 'friendlyName' - ]; - - bool isValidSignAttempt = true; - for (var param in requiredParams) { - if (queryParams[param] == null || queryParams[param] == 'undefined') { - isValidSignAttempt = false; - break; - } } + } - if (!isValidSignAttempt) { - setState(() { - linkError = 'Missing required parameters'; - isLoading = false; - }); - _showInvalidLinkDialog(); - return; - } + final link = Uri.parse(linkText); + final queryParams = link.queryParameters; + final requiredParams = [ + 'dataHash', + 'state', + 'appId', + 'dataUrl', + 'isJson', + 'friendlyName' + ]; + final isValidSignAttempt = requiredParams.every( + (param) => + queryParams[param] != null && queryParams[param] != 'undefined', + ); - _dataController.text = queryParams['dataHash'] ?? ''; - textController.text = queryParams['dataHash'] ?? ''; + if (!isValidSignAttempt) { setState(() { - linkError = null; - dataError = null; + linkError = 'Missing required parameters'; isLoading = false; }); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Link processed successfully', - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.primaryContainer)), - ), - ); + _showInvalidLinkDialog(); + return; } + + _dataController.text = queryParams['dataHash'] ?? ''; + textController.text = queryParams['dataHash'] ?? ''; + setState(() { + linkError = null; + dataError = null; + isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Link processed successfully', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.primaryContainer)), + ), + ); } catch (e) { logger.e('Error processing link: $e'); setState(() { diff --git a/app/lib/screens/signing/sign_with_qrcode.dart b/app/lib/screens/signing/sign_with_qrcode.dart index a33c5c2bb..454714b33 100644 --- a/app/lib/screens/signing/sign_with_qrcode.dart +++ b/app/lib/screens/signing/sign_with_qrcode.dart @@ -1,15 +1,12 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:threebotlogin/helpers/logger.dart'; import 'package:threebotlogin/providers/wallets_provider.dart'; import 'package:threebotlogin/screens/scan_screen.dart'; import 'package:threebotlogin/screens/signing/signing_mixin.dart'; import 'package:threebotlogin/widgets/custom_dialog.dart'; -import 'package:http/http.dart' as http; +import 'package:threebotlogin/helpers/input_validator.dart'; class SignWithQRCodeScreen extends ConsumerStatefulWidget { const SignWithQRCodeScreen({super.key}); @@ -82,51 +79,77 @@ class _SignWithQRCodeScreenState extends ConsumerState } } if (result.rawValue != null) { - final Map jsonData = json.decode(result.rawValue!); - setState(() { - destUrlController.text = jsonData['dest']; - }); + final jsonData = InputValidator.decodeJson(result.rawValue!); + if (jsonData == null) { + setState(() => scannedDataError = 'Invalid QR code format'); + _showInvalidQRCodeDialog(); + return; + } + + bool hasValidData = false; + + if (jsonData.containsKey('dest')) { + final dest = jsonData['dest']; + if (dest is String && + InputValidator.isValidLength(dest, InputValidator.maxUrlLength)) { + setState(() => destUrlController.text = dest); + hasValidData = true; + } + } if (jsonData.containsKey('content')) { - textController.text = jsonData['content']; - } else if (jsonData.containsKey('src')) { - setState(() { - isLoading = true; - }); - - try { - final response = await http.get(Uri.parse(jsonData['src'])); - if (response.statusCode == 200) { - textController.text = response.body; - } else { - throw Exception('Failed to load content from source'); - } - } catch (e) { - logger.e('Error fetching content from src: $e'); - setState(() { - scannedDataError = 'Failed to fetch content from source'; - }); + final content = jsonData['content']; + if (content is String && + InputValidator.isValidLength( + content, InputValidator.maxContentLength)) { + textController.text = content; + hasValidData = true; + } else { + setState(() => scannedDataError = 'Content too large'); _showInvalidQRCodeDialog(); return; - } finally { - setState(() { - isLoading = false; - }); } - } else { - setState(() { - scannedDataError = 'No content found in QR code'; - }); + } + + if (jsonData.containsKey('src')) { + final src = jsonData['src']; + if (src is! String) { + setState(() => scannedDataError = 'Invalid source URL'); + _showInvalidQRCodeDialog(); + return; + } + + final uri = InputValidator.validateUrl(src); + if (uri == null) { + setState(() => scannedDataError = 'Invalid source URL format'); + _showInvalidQRCodeDialog(); + return; + } + + setState(() => isLoading = true); + final content = await InputValidator.fetchValidatedContent(uri); + setState(() => isLoading = false); + + if (content != null) { + textController.text = content; + hasValidData = true; + } else if (!hasValidData) { + setState( + () => scannedDataError = 'Failed to fetch content from source'); + _showInvalidQRCodeDialog(); + return; + } + } + + if (!hasValidData) { + setState(() => scannedDataError = 'No valid data found in QR code'); _showInvalidQRCodeDialog(); return; } - setState(() { - scannedDataError = null; - }); + + setState(() => scannedDataError = null); } else { - setState(() { - scannedDataError = 'No QR code data detected nor src provided'; - }); + setState(() => scannedDataError = 'No QR code data detected'); _showInvalidQRCodeDialog(); return; } diff --git a/app/lib/screens/signing/signing_mixin.dart b/app/lib/screens/signing/signing_mixin.dart index 420774ee1..b0f5899fe 100644 --- a/app/lib/screens/signing/signing_mixin.dart +++ b/app/lib/screens/signing/signing_mixin.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:threebotlogin/models/wallet.dart'; import 'package:threebotlogin/helpers/logger.dart'; import 'package:threebotlogin/providers/wallets_provider.dart'; +import 'package:threebotlogin/helpers/input_validator.dart'; import 'package:threebotlogin/services/signing_service.dart'; mixin SigningMixin on ConsumerState { @@ -106,33 +107,15 @@ mixin SigningMixin on ConsumerState { return true; } - String url = destUrlController.text.trim(); - try { - final uri = Uri.parse(url); - if (!uri.isScheme('http') && !uri.isScheme('https')) { - setState(() { - destUrlError = 'URL must start with http:// or https://'; - }); - return false; - } - - if (!uri.hasAuthority) { - setState(() { - destUrlError = 'Invalid URL format'; - }); - return false; - } - - setState(() { - destUrlError = null; - }); - return true; - } catch (e) { + final uri = InputValidator.validateUrl(destUrlController.text); + if (uri == null) { setState(() { destUrlError = 'Invalid URL format'; }); return false; } + + return true; } Future checkWalletsListed() async { diff --git a/app/lib/services/stellar_service.dart b/app/lib/services/stellar_service.dart index 8bc07ba25..c486e645a 100644 --- a/app/lib/services/stellar_service.dart +++ b/app/lib/services/stellar_service.dart @@ -21,7 +21,8 @@ const horizonUrl = 'https://horizon.stellar.org'; bool isValidStellarSecret(String seed) { try { - StrKey.decodeStellarSecretSeed(seed); + final trimmedSeed = seed.trim(); + StrKey.decodeStellarSecretSeed(trimmedSeed); return true; } catch (e) { logger.e('Secret is invalid. $e'); diff --git a/app/lib/services/uni_link_service.dart b/app/lib/services/uni_link_service.dart index 0b28e3995..da35a48f0 100644 --- a/app/lib/services/uni_link_service.dart +++ b/app/lib/services/uni_link_service.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -14,6 +13,7 @@ import 'package:threebotlogin/screens/login_screen.dart'; import 'package:threebotlogin/screens/sign_screen.dart'; import 'package:threebotlogin/services/shared_preference_service.dart'; import 'package:threebotlogin/widgets/login_dialogs.dart'; +import 'package:threebotlogin/helpers/input_validator.dart'; class UniLinkService { static void handleUniLink(UniLinkEvent e) async { @@ -149,13 +149,21 @@ Future handleSignUniLink(Uri link, BuildContext context) async { } Login queryParametersToLogin(Map map) { + Scope? scope; + if (map['scope'] != null && map['scope'] != 'null') { + final decodedScope = InputValidator.decodeJson( + map['scope'] as String, + maxLength: InputValidator.maxScopeLength, + ); + if (decodedScope != null) { + scope = Scope.fromJson(decodedScope); + } + } return Login( state: map['state'], isMobile: true, randomRoom: map['randomRoom'], - scope: map['scope'] != null && map['scope'] != 'null' - ? Scope.fromJson(jsonDecode(map['scope'] as String)) - : null, + scope: scope, appId: map['appId'], appPublicKey: map['appPublicKey'], redirectUrl: map['redirecturl']);