Skip to content
Open
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
71 changes: 71 additions & 0 deletions app/lib/helpers/input_validator.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic>? decodeJson(String jsonString, {int? maxLength}) {
try {
if (jsonString.length > (maxLength ?? maxContentLength)) return null;

final decoded = json.decode(jsonString);
return decoded is Map<String, dynamic> ? 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<String?> 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;
}
}
}
27 changes: 21 additions & 6 deletions app/lib/screens/sign_screen.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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});
Expand Down Expand Up @@ -60,13 +58,30 @@ class _SignScreenState extends State<SignScreen> 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(() {});
Expand Down
94 changes: 43 additions & 51 deletions app/lib/screens/signing/sign_with_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -97,19 +97,18 @@ class _SignWithLinkScreenState extends ConsumerState<SignWithLinkScreen>
});

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',
Expand All @@ -118,55 +117,48 @@ class _SignWithLinkScreenState extends ConsumerState<SignWithLinkScreen>
),
);
return;
} else {
throw Exception('Failed to fetch content: ${response.statusCode}');
}
} catch (e) {
final Uri link = Uri.parse(linkText);
Map<String, String> queryParams = link.queryParameters;

List<String> 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(() {
Expand Down
103 changes: 63 additions & 40 deletions app/lib/screens/signing/sign_with_qrcode.dart
Original file line number Diff line number Diff line change
@@ -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});
Expand Down Expand Up @@ -82,51 +79,77 @@ class _SignWithQRCodeScreenState extends ConsumerState<SignWithQRCodeScreen>
}
}
if (result.rawValue != null) {
final Map<String, dynamic> 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;
}
Expand Down
Loading