From ef63ea4273c4b666378d40aa3dac4c36086d14b6 Mon Sep 17 00:00:00 2001 From: Ross Savage Date: Mon, 15 Jul 2024 14:25:04 +0200 Subject: [PATCH] Add an option to not validate the success action url --- libs/sdk-bindings/src/breez_sdk.udl | 1 + libs/sdk-common/src/lnurl/specs/pay.rs | 38 +++++- libs/sdk-core/src/binding.rs | 1 + libs/sdk-core/src/breez_services.rs | 1 + libs/sdk-core/src/bridge_generated.io.rs | 3 + libs/sdk-core/src/lnurl/pay.rs | 124 ++++++++++++++++-- .../ios/Classes/bridge_generated.h | 1 + libs/sdk-flutter/lib/bridge_generated.dart | 5 + .../main/java/com/breezsdk/BreezSDKMapper.kt | 12 ++ .../sdk-react-native/ios/BreezSDKMapper.swift | 11 +- libs/sdk-react-native/src/index.ts | 1 + tools/sdk-cli/src/command_handlers.rs | 7 +- tools/sdk-cli/src/commands.rs | 4 + 13 files changed, 192 insertions(+), 17 deletions(-) diff --git a/libs/sdk-bindings/src/breez_sdk.udl b/libs/sdk-bindings/src/breez_sdk.udl index 01272bc4e..0563159b5 100644 --- a/libs/sdk-bindings/src/breez_sdk.udl +++ b/libs/sdk-bindings/src/breez_sdk.udl @@ -626,6 +626,7 @@ dictionary LnUrlPayRequest { u64 amount_msat; string? comment = null; string? payment_label = null; + boolean? validate_success_action_url = null; }; dictionary LnUrlPayRequestData { diff --git a/libs/sdk-common/src/lnurl/specs/pay.rs b/libs/sdk-common/src/lnurl/specs/pay.rs index 612de833e..358e36e34 100644 --- a/libs/sdk-common/src/lnurl/specs/pay.rs +++ b/libs/sdk-common/src/lnurl/specs/pay.rs @@ -14,6 +14,7 @@ pub async fn validate_lnurl_pay( comment: &Option, req_data: &LnUrlPayRequestData, network: Network, + validate_success_action_url: Option, ) -> LnUrlResult { validate_user_input( user_amount_msat, @@ -37,8 +38,9 @@ pub async fn validate_lnurl_pay( SuccessAction::Aes(data) => data.validate()?, SuccessAction::Message(data) => data.validate()?, SuccessAction::Url(data) => { - callback_resp.success_action = - Some(SuccessAction::Url(data.validate(req_data)?)); + callback_resp.success_action = Some(SuccessAction::Url( + data.validate(req_data, validate_success_action_url.unwrap_or(true))?, + )); } } } @@ -134,6 +136,9 @@ pub mod model { pub comment: Option, /// The external label or identifier of the [Payment] pub payment_label: Option, + /// Validates that, if there is a URL success action, the URL domain matches + /// the LNURL callback domain. Defaults to `true` + pub validate_success_action_url: Option, } pub enum ValidatedCallbackResponse { @@ -321,7 +326,11 @@ pub mod model { } impl UrlSuccessActionData { - pub fn validate(&self, data: &LnUrlPayRequestData) -> LnUrlResult { + pub fn validate( + &self, + data: &LnUrlPayRequestData, + validate_url: bool, + ) -> LnUrlResult { let mut validated_data = self.clone(); match self.description.len() <= 144 { true => Ok(()), @@ -342,6 +351,12 @@ pub mod model { LnUrlError::invalid_uri("Could not determine Success Action URL domain") })?; + if validate_url && req_domain != action_res_domain { + return Err(LnUrlError::generic( + "Success Action URL has different domain than the callback domain", + )); + } + validated_data.matches_callback_domain = req_domain == action_res_domain; Ok(validated_data) }) @@ -620,17 +635,26 @@ pub(crate) mod tests { url: pay_req_data.callback.clone(), matches_callback_domain: true, } - .validate(&pay_req_data); + .validate(&pay_req_data, true); assert!(validated_data1.is_ok()); assert!(validated_data1.unwrap().matches_callback_domain); - // Different Success Action domain than in the callback URL + // Different Success Action domain than in the callback URL with validation + assert!(UrlSuccessActionData { + description: "short msg".into(), + url: "https://new-domain.com/test-url".into(), + matches_callback_domain: true, + } + .validate(&pay_req_data, true) + .is_err()); + + // Different Success Action domain than in the callback URL without validation let validated_data2 = UrlSuccessActionData { description: "short msg".into(), url: "https://new-domain.com/test-url".into(), matches_callback_domain: true, } - .validate(&pay_req_data); + .validate(&pay_req_data, false); assert!(validated_data2.is_ok()); assert!(!validated_data2.unwrap().matches_callback_domain); @@ -640,7 +664,7 @@ pub(crate) mod tests { url: pay_req_data.callback.clone(), matches_callback_domain: true, } - .validate(&pay_req_data) + .validate(&pay_req_data, true) .is_err()); Ok(()) diff --git a/libs/sdk-core/src/binding.rs b/libs/sdk-core/src/binding.rs index 320408914..13420a054 100644 --- a/libs/sdk-core/src/binding.rs +++ b/libs/sdk-core/src/binding.rs @@ -125,6 +125,7 @@ pub struct _LnUrlPayRequest { pub amount_msat: u64, pub comment: Option, pub payment_label: Option, + pub validate_success_action_url: Option, } #[frb(mirror(LnUrlPayRequestData))] diff --git a/libs/sdk-core/src/breez_services.rs b/libs/sdk-core/src/breez_services.rs index 39a79866a..4d31f4276 100644 --- a/libs/sdk-core/src/breez_services.rs +++ b/libs/sdk-core/src/breez_services.rs @@ -339,6 +339,7 @@ impl BreezServices { &req.comment, &req.data, self.config.network, + req.validate_success_action_url, ) .await? { diff --git a/libs/sdk-core/src/bridge_generated.io.rs b/libs/sdk-core/src/bridge_generated.io.rs index f2313d99c..05c3c5579 100644 --- a/libs/sdk-core/src/bridge_generated.io.rs +++ b/libs/sdk-core/src/bridge_generated.io.rs @@ -869,6 +869,7 @@ impl Wire2Api for wire_LnUrlPayRequest { amount_msat: self.amount_msat.wire2api(), comment: self.comment.wire2api(), payment_label: self.payment_label.wire2api(), + validate_success_action_url: self.validate_success_action_url.wire2api(), } } } @@ -1237,6 +1238,7 @@ pub struct wire_LnUrlPayRequest { amount_msat: u64, comment: *mut wire_uint_8_list, payment_label: *mut wire_uint_8_list, + validate_success_action_url: *mut bool, } #[repr(C)] @@ -1644,6 +1646,7 @@ impl NewWithNullPtr for wire_LnUrlPayRequest { amount_msat: Default::default(), comment: core::ptr::null_mut(), payment_label: core::ptr::null_mut(), + validate_success_action_url: core::ptr::null_mut(), } } } diff --git a/libs/sdk-core/src/lnurl/pay.rs b/libs/sdk-core/src/lnurl/pay.rs index 5d6c62a04..d90842f95 100644 --- a/libs/sdk-core/src/lnurl/pay.rs +++ b/libs/sdk-core/src/lnurl/pay.rs @@ -195,6 +195,7 @@ pub(crate) mod tests { /// Mock an LNURL-pay endpoint that responds with a Success Action of type URL fn mock_lnurl_pay_callback_endpoint_url_success_action( callback_params: LnurlPayCallbackParams, + success_action_url: Option<&str>, ) -> Result { let LnurlPayCallbackParams { pay_req, @@ -215,7 +216,7 @@ pub(crate) mod tests { "successAction": { "tag":"url", "description":"test description", - "url":"http://localhost:8080/test-url" + "url":"success-action-url" } } "# @@ -223,6 +224,10 @@ pub(crate) mod tests { .replace( "token-invoice", &pr.unwrap_or_else(|| "token-invoice".to_string()), + ) + .replace( + "success-action-url", + success_action_url.unwrap_or("http://localhost:8080/test-url"), ); let response_body = match error { @@ -399,6 +404,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await? { @@ -443,6 +449,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await; // An unsupported Success Action results in an error @@ -473,6 +480,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await? { @@ -506,6 +514,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await? { @@ -554,6 +563,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await .is_err()); @@ -584,6 +594,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await; assert!(matches!(res, Ok(LnUrlPayResult::EndpointError { data: _ }))); @@ -606,13 +617,16 @@ pub(crate) mod tests { let temp_desc = pay_req.metadata_str.clone(); let inv = rand_invoice_with_description_hash(temp_desc)?; let user_amount_msat = inv.amount_milli_satoshis().unwrap(); - let _m = mock_lnurl_pay_callback_endpoint_url_success_action(LnurlPayCallbackParams { - pay_req: &pay_req, - user_amount_msat, - error: None, - pr: Some(inv.to_string()), - comment: comment.clone(), - })?; + let _m = mock_lnurl_pay_callback_endpoint_url_success_action( + LnurlPayCallbackParams { + pay_req: &pay_req, + user_amount_msat, + error: None, + pr: Some(inv.to_string()), + comment: comment.clone(), + }, + None, + )?; let mock_breez_services = crate::breez_services::tests::breez_services().await?; match mock_breez_services @@ -621,6 +635,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await? { @@ -652,6 +667,97 @@ pub(crate) mod tests { } } + #[tokio::test] + async fn test_lnurl_pay_url_success_action_validate_url_invalid() -> Result<()> { + let comment = rand_string(COMMENT_LENGTH as usize); + let pay_req = get_test_pay_req_data(0, 100_000, COMMENT_LENGTH); + let temp_desc = pay_req.metadata_str.clone(); + let inv = rand_invoice_with_description_hash(temp_desc)?; + let user_amount_msat = inv.amount_milli_satoshis().unwrap(); + let _m = mock_lnurl_pay_callback_endpoint_url_success_action( + LnurlPayCallbackParams { + pay_req: &pay_req, + user_amount_msat, + error: None, + pr: Some(inv.to_string()), + comment: comment.clone(), + }, + Some("http://different.localhost:8080/test-url"), + )?; + + let mock_breez_services = crate::breez_services::tests::breez_services().await?; + let r = mock_breez_services + .lnurl_pay(LnUrlPayRequest { + data: pay_req, + amount_msat: user_amount_msat, + comment: Some(comment), + payment_label: None, + validate_success_action_url: Some(true), + }) + .await; + // An invalid Success Action URL results in an error + assert!(r.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_lnurl_pay_url_success_action_validate_url_valid() -> Result<()> { + let comment = rand_string(COMMENT_LENGTH as usize); + let pay_req = get_test_pay_req_data(0, 100_000, COMMENT_LENGTH); + let temp_desc = pay_req.metadata_str.clone(); + let inv = rand_invoice_with_description_hash(temp_desc)?; + let user_amount_msat = inv.amount_milli_satoshis().unwrap(); + let _m = mock_lnurl_pay_callback_endpoint_url_success_action( + LnurlPayCallbackParams { + pay_req: &pay_req, + user_amount_msat, + error: None, + pr: Some(inv.to_string()), + comment: comment.clone(), + }, + Some("http://different.localhost:8080/test-url"), + )?; + + let mock_breez_services = crate::breez_services::tests::breez_services().await?; + match mock_breez_services + .lnurl_pay(LnUrlPayRequest { + data: pay_req, + amount_msat: user_amount_msat, + comment: Some(comment), + payment_label: None, + validate_success_action_url: Some(false), + }) + .await? + { + LnUrlPayResult::EndpointSuccess { + data: + LnUrlPaySuccessData { + success_action: Some(SuccessActionProcessed::Url { data: url }), + .. + }, + } => { + if url.url == "http://different.localhost:8080/test-url" + && url.description == "test description" + { + Ok(()) + } else { + Err(anyhow!("Unexpected success action content")) + } + } + LnUrlPayResult::EndpointSuccess { + data: + LnUrlPaySuccessData { + success_action: None, + .. + }, + } => Err(anyhow!( + "Expected success action in callback, but none provided" + )), + _ => Err(anyhow!("Unexpected success action type")), + } + } + #[tokio::test] async fn test_lnurl_pay_aes_success_action() -> Result<()> { // Expected fields in the AES payload @@ -707,6 +813,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await? { @@ -791,6 +898,7 @@ pub(crate) mod tests { amount_msat: user_amount_msat, comment: Some(comment), payment_label: None, + validate_success_action_url: None, }) .await? { diff --git a/libs/sdk-flutter/ios/Classes/bridge_generated.h b/libs/sdk-flutter/ios/Classes/bridge_generated.h index 7408bf1ed..b022e0a7d 100644 --- a/libs/sdk-flutter/ios/Classes/bridge_generated.h +++ b/libs/sdk-flutter/ios/Classes/bridge_generated.h @@ -171,6 +171,7 @@ typedef struct wire_LnUrlPayRequest { uint64_t amount_msat; struct wire_uint_8_list *comment; struct wire_uint_8_list *payment_label; + bool *validate_success_action_url; } wire_LnUrlPayRequest; typedef struct wire_LnUrlWithdrawRequestData { diff --git a/libs/sdk-flutter/lib/bridge_generated.dart b/libs/sdk-flutter/lib/bridge_generated.dart index ddd06d9ed..fff6cf212 100644 --- a/libs/sdk-flutter/lib/bridge_generated.dart +++ b/libs/sdk-flutter/lib/bridge_generated.dart @@ -858,12 +858,14 @@ class LnUrlPayRequest { final int amountMsat; final String? comment; final String? paymentLabel; + final bool? validateSuccessActionUrl; const LnUrlPayRequest({ required this.data, required this.amountMsat, this.comment, this.paymentLabel, + this.validateSuccessActionUrl, }); } @@ -4825,6 +4827,7 @@ class BreezSdkCorePlatform extends FlutterRustBridgeBase { wireObj.amount_msat = api2wire_u64(apiObj.amountMsat); wireObj.comment = api2wire_opt_String(apiObj.comment); wireObj.payment_label = api2wire_opt_String(apiObj.paymentLabel); + wireObj.validate_success_action_url = api2wire_opt_box_autoadd_bool(apiObj.validateSuccessActionUrl); } void _api_fill_to_wire_ln_url_pay_request_data( @@ -6656,6 +6659,8 @@ final class wire_LnUrlPayRequest extends ffi.Struct { external ffi.Pointer comment; external ffi.Pointer payment_label; + + external ffi.Pointer validate_success_action_url; } final class wire_LnUrlWithdrawRequestData extends ffi.Struct { diff --git a/libs/sdk-react-native/android/src/main/java/com/breezsdk/BreezSDKMapper.kt b/libs/sdk-react-native/android/src/main/java/com/breezsdk/BreezSDKMapper.kt index 8f45d2952..f628b0712 100644 --- a/libs/sdk-react-native/android/src/main/java/com/breezsdk/BreezSDKMapper.kt +++ b/libs/sdk-react-native/android/src/main/java/com/breezsdk/BreezSDKMapper.kt @@ -1156,11 +1156,22 @@ fun asLnUrlPayRequest(lnUrlPayRequest: ReadableMap): LnUrlPayRequest? { val amountMsat = lnUrlPayRequest.getDouble("amountMsat").toULong() val comment = if (hasNonNullKey(lnUrlPayRequest, "comment")) lnUrlPayRequest.getString("comment") else null val paymentLabel = if (hasNonNullKey(lnUrlPayRequest, "paymentLabel")) lnUrlPayRequest.getString("paymentLabel") else null + val validateSuccessActionUrl = + if (hasNonNullKey( + lnUrlPayRequest, + "validateSuccessActionUrl", + ) + ) { + lnUrlPayRequest.getBoolean("validateSuccessActionUrl") + } else { + null + } return LnUrlPayRequest( data, amountMsat, comment, paymentLabel, + validateSuccessActionUrl, ) } @@ -1170,6 +1181,7 @@ fun readableMapOf(lnUrlPayRequest: LnUrlPayRequest): ReadableMap = "amountMsat" to lnUrlPayRequest.amountMsat, "comment" to lnUrlPayRequest.comment, "paymentLabel" to lnUrlPayRequest.paymentLabel, + "validateSuccessActionUrl" to lnUrlPayRequest.validateSuccessActionUrl, ) fun asLnUrlPayRequestList(arr: ReadableArray): List { diff --git a/libs/sdk-react-native/ios/BreezSDKMapper.swift b/libs/sdk-react-native/ios/BreezSDKMapper.swift index bafa135fa..1b0b3cd84 100644 --- a/libs/sdk-react-native/ios/BreezSDKMapper.swift +++ b/libs/sdk-react-native/ios/BreezSDKMapper.swift @@ -1326,12 +1326,20 @@ enum BreezSDKMapper { } paymentLabel = paymentLabelTmp } + var validateSuccessActionUrl: Bool? + if hasNonNilKey(data: lnUrlPayRequest, key: "validateSuccessActionUrl") { + guard let validateSuccessActionUrlTmp = lnUrlPayRequest["validateSuccessActionUrl"] as? Bool else { + throw SdkError.Generic(message: errUnexpectedValue(fieldName: "validateSuccessActionUrl")) + } + validateSuccessActionUrl = validateSuccessActionUrlTmp + } return LnUrlPayRequest( data: data, amountMsat: amountMsat, comment: comment, - paymentLabel: paymentLabel + paymentLabel: paymentLabel, + validateSuccessActionUrl: validateSuccessActionUrl ) } @@ -1341,6 +1349,7 @@ enum BreezSDKMapper { "amountMsat": lnUrlPayRequest.amountMsat, "comment": lnUrlPayRequest.comment == nil ? nil : lnUrlPayRequest.comment, "paymentLabel": lnUrlPayRequest.paymentLabel == nil ? nil : lnUrlPayRequest.paymentLabel, + "validateSuccessActionUrl": lnUrlPayRequest.validateSuccessActionUrl == nil ? nil : lnUrlPayRequest.validateSuccessActionUrl, ] } diff --git a/libs/sdk-react-native/src/index.ts b/libs/sdk-react-native/src/index.ts index 530b306c0..f07a07a65 100644 --- a/libs/sdk-react-native/src/index.ts +++ b/libs/sdk-react-native/src/index.ts @@ -192,6 +192,7 @@ export interface LnUrlPayRequest { amountMsat: number comment?: string paymentLabel?: string + validateSuccessActionUrl?: boolean } export interface LnUrlPayRequestData { diff --git a/tools/sdk-cli/src/command_handlers.rs b/tools/sdk-cli/src/command_handlers.rs index db9e8873f..53175bd07 100644 --- a/tools/sdk-cli/src/command_handlers.rs +++ b/tools/sdk-cli/src/command_handlers.rs @@ -466,7 +466,11 @@ pub(crate) async fn handle_command( let res = sdk()?.check_message(req).await?; Ok(format!("Message was signed by node: {}", res.is_valid)) } - Commands::LnurlPay { lnurl, label } => match parse(&lnurl).await? { + Commands::LnurlPay { + lnurl, + label, + validate_success_url, + } => match parse(&lnurl).await? { LnUrlPay { data: pd } => { let prompt = format!( "Amount to pay in millisatoshi (min {} msat, max {} msat: ", @@ -480,6 +484,7 @@ pub(crate) async fn handle_command( amount_msat: amount_msat.parse::()?, comment: None, payment_label: label, + validate_success_action_url: validate_success_url, }) .await?; //show_results(pay_res); diff --git a/tools/sdk-cli/src/commands.rs b/tools/sdk-cli/src/commands.rs index ff2c19ab6..ef8cbc4a4 100644 --- a/tools/sdk-cli/src/commands.rs +++ b/tools/sdk-cli/src/commands.rs @@ -93,6 +93,10 @@ pub(crate) enum Commands { /// The external label or identifier of the payment #[clap(name = "label", short = 'l', long = "label")] label: Option, + + /// Validates the success action URL + #[clap(name = "validate_success_url", short = 'v', long = "validate")] + validate_success_url: Option, }, /// [lnurl] Withdraw using lnurl withdraw