1
0
forked from GRIN/grim

fix(payuri): reject non-ASCII and oversized scanned amounts

A crafted pay-URI amount with a multibyte UTF-8 char (e.g. amount=0.EEEE)
reached grin_core's amount_from_hr_string, which slices the fractional tail
at a fixed byte index and panics on a non-char-boundary. The scan/deep-link
thread has no catch_unwind, so a single scanned QR or opened link could crash
the wallet. validate_amount now rejects any non-ASCII amount up front (Grin
amounts are only [0-9.]), degrading to manual entry.

Also cap the whole-GRIN part at 1e9 before the grin parse, below the u64
overflow point of grins * GRIN_BASE. Without this a giant amount wraps in
release to a small atomic value that is what actually gets dispatched while
the review screen still shows the giant figure.

Adds regression tests for both (multibyte to None no panic; over-cap to None).
This commit is contained in:
2ro
2026-07-05 06:08:25 -04:00
parent 42d70e1a5e
commit c0b622f694
+72
View File
@@ -46,6 +46,12 @@ const MAX_MEMO_BYTES: usize = 256;
/// param is an opaque routing key (magick's `MM-<hex>` invoice number), never
/// free text, so it is capped tight per the frozen contract (section 4.1).
const MAX_ORDER_BYTES: usize = 64;
/// Upper bound on the whole-GRIN part of an accepted amount. It sits far below
/// the point where `amount_from_hr_string`'s `grins * GRIN_BASE` would overflow
/// u64 (which in release wraps to a small atomic value while the review screen
/// still shows the giant figure), yet comfortably above Grin's real circulating
/// supply (~10^8 GRIN). A scanned amount above this is abuse, not a payment.
const MAX_WHOLE_GRIN: u64 = 1_000_000_000;
/// Schemes that unlock amount/memo parsing. `goblin:` is Goblin's registered
/// deep-link scheme (web "Open in Goblin" buttons, so the OS opens the wallet);
@@ -187,6 +193,24 @@ pub fn is_pay_uri(scanned: &str) -> bool {
/// manual entry). Returns the clean decoded decimal string on success.
fn validate_amount(raw: &str) -> Option<String> {
let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned();
// A Grin amount is only ASCII `[0-9.]`. Reject any non-ASCII up front:
// `amount_from_hr_string` slices the fractional tail at a fixed byte index,
// which panics if that index lands inside a multibyte UTF-8 char. The scan /
// deep-link thread has no catch_unwind, so a crafted amount like `0.€€€€`
// would crash the wallet. Fail closed to manual entry instead.
if !decoded.is_ascii() {
return None;
}
// Cap the whole-GRIN part below the u64 overflow point. Without this a giant
// amount wraps (in release) to a small atomic value that is what actually
// gets dispatched, while the review screen shows the giant figure. A whole
// part that does not even fit u64 is left for `amount_from_hr_string` to
// reject (its own `parse::<u64>` errors, so no wrap can occur).
if let Ok(whole) = decoded.split('.').next().unwrap_or("").parse::<u64>() {
if whole > MAX_WHOLE_GRIN {
return None;
}
}
match amount_from_hr_string(&decoded) {
Ok(atomic) if atomic > 0 => Some(decoded),
_ => None,
@@ -438,6 +462,54 @@ mod tests {
}
}
#[test]
fn multibyte_amount_rejected_no_panic() {
// A crafted multibyte amount must never reach grin_core's fixed-index
// fractional-tail slice (which panics on a non-char-boundary). It is
// dropped to None and the payment degrades to manual entry; no panic on
// the scan/deep-link thread. The `0.€€€€` case slices mid-char in
// grin_core without the ASCII guard.
for bad in [
"0.\u{20ac}\u{20ac}\u{20ac}\u{20ac}", // 3-byte euro signs
"0.\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}", // 2-byte accents past WIDTH
"\u{20ac}",
"1\u{e9}",
"0.5\u{1f600}", // 4-byte emoji
] {
let out = parse(&format!("nostr:{NPROFILE}?amount={bad}"));
assert_eq!(out.amount, None, "expected {bad:?} to be dropped");
assert_eq!(out.recipient, NPROFILE);
}
}
#[test]
fn oversized_amount_rejected() {
// Above the whole-GRIN ceiling → dropped before the grin parse, so a huge
// display value can't overflow-wrap into a small dispatched atomic amount.
for bad in [
"2000000000", // 2e9 GRIN, over the 1e9 ceiling
"99999999999", // ~1e11 GRIN
"1000000001.5", // just over the ceiling, with a fraction
"18446744073709551615", // u64::MAX whole grins (would wrap grins*BASE)
] {
let out = parse(&format!("nostr:{NPROFILE}?amount={bad}"));
assert_eq!(out.amount, None, "expected {bad:?} to be rejected");
}
// The ceiling itself and just under it still parse.
assert_eq!(
parse(&format!("nostr:{NPROFILE}?amount=1000000000"))
.amount
.as_deref(),
Some("1000000000")
);
assert_eq!(
parse(&format!("nostr:{NPROFILE}?amount=999999999.5"))
.amount
.as_deref(),
Some("999999999.5")
);
}
#[test]
fn overlong_memo_truncated() {
let long = "a".repeat(500);