What changed, and why it matters
This commit tightens how the wallet parses amounts typed or pasted by users. It now rejects strings that contain plus/minus signs or hidden whitespace characters (like tabs and newlines), and it turns on 'strict' parsing in more places so that spaces inside an amount are not silently ignored. The change is defensive: it reduces the chance that a cleverly crafted amount string could be misread, potentially leading to a wrong payment amount or an unexpected transaction.
Treat as a defensive security improvement. Review whether any other amount parsing call sites still use non-strict parsing for user-supplied input, and consider extending the same strict checks to all fiat and crypto amount entry points. No immediate incident response is indicated, but users should update to a build containing this commit.
Security signals we found
Input validation hardening for financial amount strings
Rejection of plus/minus signs and ASCII whitespace in amount parsing
New strict parsing mode applied to formatter and exchange form
Unit tests added to enforce rejection of malformed inputs
Evidence from the diff
The patch hardens amount parsing across Stack Wallet. Amount.tryParseFiatString now returns null if the input is empty or contains +, -, ASCII control characters (\x09-\x0D), or spaces. AmountUnit.tryParse gains a strict flag (default false) that also rejects signs and whitespace, and callers in AmountFormatter.tryParse and ExchangeForm now pass strict: true and overrideWithDecimalPlacesFromString: true respectively. Previously the parser stripped leading signs and split on spaces, which could allow ambiguous or malformed strings to be accepted. New unit tests verify rejection of signed numbers and whitespace-injected values while still allowing unit suffixes such as ‘5 legacy’ in non-strict mode.
Changed components
lib/utilities/amount/amount.dartlib/utilities/amount/amount_unit.dartlib/utilities/amount/amount_formatter.dartlib/pages/exchange_view/exchange_form.dartInspect captured patch +45 / −21
diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart
index c328e5e..b861b59 100644
--- a/lib/pages/exchange_view/exchange_form.dart
+++ b/lib/pages/exchange_view/exchange_form.dart
@@ -178,6 +178,7 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
coin: Bitcoin(
CryptoCurrencyNetwork.main,
), // dummy value (not used due to override)
+ strict: true,
overrideWithDecimalPlacesFromString: true,
)
?.decimal;
diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart
index 2e9d5c6..db2336e 100644
--- a/lib/utilities/amount/amount.dart
+++ b/lib/utilities/amount/amount.dart
@@ -54,24 +54,13 @@ class Amount {
}
static Amount? tryParseFiatString(String value, {required String locale}) {
- final parts = value.split(" ");
-
- if (parts.first.isEmpty) {
- return null;
- }
-
- String str = parts.first;
- if (str.startsWith(RegExp(r'[+-]'))) {
- str = str.substring(1);
- }
-
- if (str.isEmpty) {
+ if (value.isEmpty || value.contains(RegExp(r'[+\-\x09-\x0D ]'))) {
return null;
}
// get number symbols for decimal place and group separator
return Decimal.tryParse(
- normalizeLocalizedNumber(str, locale: locale),
+ normalizeLocalizedNumber(value, locale: locale),
)?.toAmount(fractionDigits: 2);
}
diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart
index 6a6f01f..c03c487 100644
--- a/lib/utilities/amount/amount_formatter.dart
+++ b/lib/utilities/amount/amount_formatter.dart
@@ -72,6 +72,7 @@ class AmountFormatter {
locale: locale,
coin: coin,
tokenContract: tokenContract,
+ strict: true,
);
}
}
diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart
index 6ea366f..0f1cd5c 100644
--- a/lib/utilities/amount/amount_unit.dart
+++ b/lib/utilities/amount/amount_unit.dart
@@ -201,8 +201,14 @@ extension AmountUnitExt on AmountUnit {
required String locale,
required CryptoCurrency coin,
Contract? tokenContract,
+ bool strict = false,
bool overrideWithDecimalPlacesFromString = false,
}) {
+ if (value.contains(RegExp(r'[+\-\x09-\x0D]')) ||
+ (strict && value.contains(" "))) {
+ return null;
+ }
+
final precisionLost = value.startsWith("~");
final parts = (precisionLost ? value.substring(1) : value).split(" ");
@@ -211,14 +217,7 @@ extension AmountUnitExt on AmountUnit {
return null;
}
- String str = parts.first;
- if (str.startsWith(RegExp(r'[+-]'))) {
- str = str.substring(1);
- }
-
- if (str.isEmpty) {
- return null;
- }
+ final str = parts.first;
// get number symbols for decimal place and group separator
final Decimal? decimal = Decimal.tryParse(
diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart
index f59a67a..9056a46 100644
--- a/test/utilities/amount/amount_unit_test.dart
+++ b/test/utilities/amount/amount_unit_test.dart
@@ -2,6 +2,7 @@ import 'package:decimal/decimal.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stackwallet/utilities/amount/amount.dart';
+import 'package:stackwallet/utilities/amount/amount_formatter.dart';
import 'package:stackwallet/utilities/amount/amount_input_formatter.dart';
import 'package:stackwallet/utilities/amount/amount_unit.dart';
import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
@@ -228,6 +229,39 @@ void main() {
);
});
+ test("amount field parsing rejects signs and ASCII whitespace", () {
+ final coin = Bitcoin(CryptoCurrencyNetwork.main);
+ final formatter = AmountFormatter(
+ unit: AmountUnit.normal,
+ locale: "en_US",
+ coin: coin,
+ maxDecimals: 8,
+ );
+
+ expect(formatter.tryParse("5")?.decimal, Decimal.fromInt(5));
+
+ for (final value in [
+ "+5",
+ "-5",
+ for (final codePoint in [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20])
+ "1${String.fromCharCode(codePoint)}234",
+ ]) {
+ expect(formatter.tryParse(value), isNull, reason: value);
+ expect(
+ Amount.tryParseFiatString(value, locale: "en_US"),
+ isNull,
+ reason: value,
+ );
+ }
+
+ expect(
+ AmountUnit.normal
+ .tryParse("5 legacy", locale: "en_US", coin: coin)
+ ?.decimal,
+ Decimal.fromInt(5),
+ );
+ });
+
test("parse ASCII decimals in dot-group locales", () {
final coin = Bitcoin(CryptoCurrencyNetwork.main);
final formatter = AmountInputFormatter(decimals: 8, locale: "de_DE");
Why this scored 51/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.