diff --git a/libs/sdk-bindings/src/breez_sdk.udl b/libs/sdk-bindings/src/breez_sdk.udl index a49c1e664..d47fc198c 100644 --- a/libs/sdk-bindings/src/breez_sdk.udl +++ b/libs/sdk-bindings/src/breez_sdk.udl @@ -293,6 +293,7 @@ dictionary MessageSuccessActionData { dictionary UrlSuccessActionData { string description; string url; + boolean matches_callback_domain; }; [Enum] @@ -626,6 +627,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 8ff1e5579..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, @@ -31,12 +32,16 @@ pub async fn validate_lnurl_pay( if let Ok(err) = serde_json::from_str::(&callback_resp_text) { Ok(ValidatedCallbackResponse::EndpointError { data: err }) } else { - let callback_resp: CallbackResponse = serde_json::from_str(&callback_resp_text)?; + let mut callback_resp: CallbackResponse = serde_json::from_str(&callback_resp_text)?; if let Some(ref sa) = callback_resp.success_action { match sa { SuccessAction::Aes(data) => data.validate()?, SuccessAction::Message(data) => data.validate()?, - SuccessAction::Url(data) => data.validate(req_data)?, + SuccessAction::Url(data) => { + callback_resp.success_action = Some(SuccessAction::Url( + data.validate(req_data, validate_success_action_url.unwrap_or(true))?, + )); + } } } @@ -115,6 +120,7 @@ pub mod model { use anyhow::Result; use serde::{Deserialize, Serialize}; use thiserror::Error; + use utils::default_true; use crate::prelude::specs::pay::{Aes256CbcDec, Aes256CbcEnc}; use crate::prelude::*; @@ -130,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 { @@ -194,8 +203,17 @@ pub mod model { #[derive(PartialEq, Eq, Debug, Clone, Deserialize, Serialize)] pub struct UrlSuccessActionData { + /// Contents description, up to 144 characters pub description: String, + + /// URL of the success action pub url: String, + + /// Indicates the success URL domain matches the LNURL callback domain. + /// + /// See + #[serde(default = "default_true")] + pub matches_callback_domain: bool, } /// [SuccessAction] where contents are ready to be consumed by the caller @@ -308,7 +326,12 @@ 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(()), false => Err(LnUrlError::generic( @@ -328,12 +351,14 @@ pub mod model { LnUrlError::invalid_uri("Could not determine Success Action URL domain") })?; - match req_domain == action_res_domain { - true => Ok(()), - false => Err(LnUrlError::generic( + 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) }) } } @@ -605,27 +630,41 @@ pub(crate) mod tests { fn test_lnurl_pay_validate_success_url() -> Result<()> { let pay_req_data = get_test_pay_req_data(0, 100_000, 100); - assert!(UrlSuccessActionData { + let validated_data1 = UrlSuccessActionData { description: "short msg".into(), - url: pay_req_data.callback.clone() + url: pay_req_data.callback.clone(), + matches_callback_domain: true, } - .validate(&pay_req_data) - .is_ok()); + .validate(&pay_req_data, true); + assert!(validated_data1.is_ok()); + assert!(validated_data1.unwrap().matches_callback_domain); - // Too long description + // Different Success Action domain than in the callback URL with validation assert!(UrlSuccessActionData { - description: rand_string(150), - url: pay_req_data.callback.clone() + description: "short msg".into(), + url: "https://new-domain.com/test-url".into(), + matches_callback_domain: true, } - .validate(&pay_req_data) + .validate(&pay_req_data, true) .is_err()); - // Different Success Action domain than in the callback URL - assert!(UrlSuccessActionData { + // 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() + url: "https://new-domain.com/test-url".into(), + matches_callback_domain: true, + } + .validate(&pay_req_data, false); + assert!(validated_data2.is_ok()); + assert!(!validated_data2.unwrap().matches_callback_domain); + + // Too long description + assert!(UrlSuccessActionData { + description: rand_string(150), + 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-common/src/utils/mod.rs b/libs/sdk-common/src/utils/mod.rs index dfc203baf..0b8883280 100644 --- a/libs/sdk-common/src/utils/mod.rs +++ b/libs/sdk-common/src/utils/mod.rs @@ -8,3 +8,7 @@ macro_rules! ensure_sdk { } }; } + +pub(crate) fn default_true() -> bool { + true +} 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 9d295d6e1..db9d8bd01 100644 --- a/libs/sdk-core/src/breez_services.rs +++ b/libs/sdk-core/src/breez_services.rs @@ -338,6 +338,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 7dd7bfe79..4e3ed0739 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, }); } @@ -4826,6 +4828,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( @@ -6657,6 +6660,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 a0e0f7d76..b294890c5 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 { @@ -3721,6 +3733,7 @@ fun asUrlSuccessActionData(urlSuccessActionData: ReadableMap): UrlSuccessActionD arrayOf( "description", "url", + "matchesCallbackDomain", ), ) ) { @@ -3728,9 +3741,11 @@ fun asUrlSuccessActionData(urlSuccessActionData: ReadableMap): UrlSuccessActionD } val description = urlSuccessActionData.getString("description")!! val url = urlSuccessActionData.getString("url")!! + val matchesCallbackDomain = urlSuccessActionData.getBoolean("matchesCallbackDomain") return UrlSuccessActionData( description, url, + matchesCallbackDomain, ) } @@ -3738,6 +3753,7 @@ fun readableMapOf(urlSuccessActionData: UrlSuccessActionData): ReadableMap = readableMapOf( "description" to urlSuccessActionData.description, "url" to urlSuccessActionData.url, + "matchesCallbackDomain" to urlSuccessActionData.matchesCallbackDomain, ) fun asUrlSuccessActionDataList(arr: ReadableArray): List { diff --git a/libs/sdk-react-native/ios/BreezSDKMapper.swift b/libs/sdk-react-native/ios/BreezSDKMapper.swift index cbb3d422f..aff067630 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, ] } @@ -4203,10 +4212,14 @@ enum BreezSDKMapper { guard let url = urlSuccessActionData["url"] as? String else { throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "url", typeName: "UrlSuccessActionData")) } + guard let matchesCallbackDomain = urlSuccessActionData["matchesCallbackDomain"] as? Bool else { + throw SdkError.Generic(message: errMissingMandatoryField(fieldName: "matchesCallbackDomain", typeName: "UrlSuccessActionData")) + } return UrlSuccessActionData( description: description, - url: url + url: url, + matchesCallbackDomain: matchesCallbackDomain ) } @@ -4214,6 +4227,7 @@ enum BreezSDKMapper { return [ "description": urlSuccessActionData.description, "url": urlSuccessActionData.url, + "matchesCallbackDomain": urlSuccessActionData.matchesCallbackDomain, ] } diff --git a/libs/sdk-react-native/src/index.ts b/libs/sdk-react-native/src/index.ts index 03f83a5ac..26b4b479c 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 { @@ -580,6 +581,7 @@ export interface UnspentTransactionOutput { export interface UrlSuccessActionData { description: string url: string + matchesCallbackDomain: boolean } export enum AesSuccessActionDataResultVariant { 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