diff --git a/api-reference-v2/openapi_spec.json b/api-reference-v2/openapi_spec.json index af1186ac9edd..cc1b2f3137ef 100644 --- a/api-reference-v2/openapi_spec.json +++ b/api-reference-v2/openapi_spec.json @@ -2953,7 +2953,15 @@ "$ref": "#/components/schemas/BusinessGenericLinkConfig" }, { - "type": "object" + "type": "object", + "properties": { + "payout_test_mode": { + "type": "boolean", + "description": "Allows for removing any validations / pre-requisites which are necessary in a production environment", + "default": false, + "nullable": true + } + } } ] }, @@ -13927,6 +13935,12 @@ "description": "List of payout methods shown on collect UI", "example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]", "nullable": true + }, + "test_mode": { + "type": "boolean", + "description": "`test_mode` allows for opening payout links without any restrictions. This removes\n- domain name validations\n- check for making sure link is accessed within an iframe", + "example": false, + "nullable": true } } } diff --git a/api-reference/openapi_spec.json b/api-reference/openapi_spec.json index 83a505746ef2..c88c4c8a90c9 100644 --- a/api-reference/openapi_spec.json +++ b/api-reference/openapi_spec.json @@ -7174,7 +7174,15 @@ "$ref": "#/components/schemas/BusinessGenericLinkConfig" }, { - "type": "object" + "type": "object", + "properties": { + "payout_test_mode": { + "type": "boolean", + "description": "Allows for removing any validations / pre-requisites which are necessary in a production environment", + "default": false, + "nullable": true + } + } } ] }, @@ -18734,6 +18742,12 @@ "description": "List of payout methods shown on collect UI", "example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]", "nullable": true + }, + "test_mode": { + "type": "boolean", + "description": "`test_mode` allows for opening payout links without any restrictions. This removes\n- domain name validations\n- check for making sure link is accessed within an iframe", + "example": false, + "nullable": true } } } diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index 18ad5dbf8443..41378def83ad 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -2516,6 +2516,10 @@ pub struct BusinessCollectLinkConfig { pub struct BusinessPayoutLinkConfig { #[serde(flatten)] pub config: BusinessGenericLinkConfig, + + /// Allows for removing any validations / pre-requisites which are necessary in a production environment + #[schema(value_type = Option, default = false)] + pub payout_test_mode: Option, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, ToSchema)] diff --git a/crates/api_models/src/payouts.rs b/crates/api_models/src/payouts.rs index 0010c2988e10..fc3bd5fcda7e 100644 --- a/crates/api_models/src/payouts.rs +++ b/crates/api_models/src/payouts.rs @@ -202,6 +202,12 @@ pub struct PayoutCreatePayoutLinkConfig { /// List of payout methods shown on collect UI #[schema(value_type = Option>, example = r#"[{"payment_method": "bank_transfer", "payment_method_types": ["ach", "bacs"]}]"#)] pub enabled_payment_methods: Option>, + + /// `test_mode` allows for opening payout links without any restrictions. This removes + /// - domain name validations + /// - check for making sure link is accessed within an iframe + #[schema(value_type = Option, example = false)] + pub test_mode: Option, } /// The payout method information required for carrying out a payout @@ -772,6 +778,7 @@ pub struct PayoutLinkDetails { pub amount: common_utils::types::StringMajorUnit, pub currency: common_enums::Currency, pub locale: String, + pub test_mode: bool, } #[derive(Clone, Debug, serde::Serialize)] @@ -787,4 +794,5 @@ pub struct PayoutLinkStatusDetails { pub error_message: Option, #[serde(flatten)] pub ui_config: link_utils::GenericLinkUiConfigFormData, + pub test_mode: bool, } diff --git a/crates/common_utils/src/link_utils.rs b/crates/common_utils/src/link_utils.rs index e95832eeba9b..dc7153f2c5b6 100644 --- a/crates/common_utils/src/link_utils.rs +++ b/crates/common_utils/src/link_utils.rs @@ -167,6 +167,8 @@ pub struct PayoutLinkData { pub currency: enums::Currency, /// A list of allowed domains (glob patterns) where this link can be embedded / opened from pub allowed_domains: HashSet, + /// `test_mode` can be used for testing payout links without any restrictions + pub test_mode: Option, } crate::impl_to_sql_from_sql_json!(PayoutLinkData); diff --git a/crates/diesel_models/src/business_profile.rs b/crates/diesel_models/src/business_profile.rs index cab1aec410f9..6c9b839e7644 100644 --- a/crates/diesel_models/src/business_profile.rs +++ b/crates/diesel_models/src/business_profile.rs @@ -552,6 +552,7 @@ common_utils::impl_to_sql_from_sql_json!(BusinessPaymentLinkConfig); pub struct BusinessPayoutLinkConfig { #[serde(flatten)] pub config: BusinessGenericLinkConfig, + pub payout_test_mode: Option, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] diff --git a/crates/router/src/core/generic_link/payout_link/initiate/script.js b/crates/router/src/core/generic_link/payout_link/initiate/script.js index 2327bfba13c6..276ef6449487 100644 --- a/crates/router/src/core/generic_link/payout_link/initiate/script.js +++ b/crates/router/src/core/generic_link/payout_link/initiate/script.js @@ -1,6 +1,10 @@ // @ts-check // Top level checks +// @ts-ignore +var payoutDetails = window.__PAYOUT_DETAILS; +var isTestMode = payoutDetails.test_mode; + var isFramed = false; try { isFramed = window.parent.location !== window.location; @@ -12,7 +16,7 @@ try { } // Remove the script from DOM incase it's not iframed -if (!isFramed) { +if (!isTestMode && !isFramed) { function initializePayoutSDK() { var errMsg = "{{i18n_not_allowed}}"; var contentElement = document.getElementById("payout-link"); diff --git a/crates/router/src/core/payout_link.rs b/crates/router/src/core/payout_link.rs index d18afe020568..7ca564426e8b 100644 --- a/crates/router/src/core/payout_link.rs +++ b/crates/router/src/core/payout_link.rs @@ -79,7 +79,10 @@ pub async fn initiate_payout_link( message: "payout link not found".to_string(), })?; - validator::validate_payout_link_render_request(request_headers, &payout_link)?; + let allowed_domains = validator::validate_payout_link_render_request_and_get_allowed_domains( + request_headers, + &payout_link, + )?; // Check status and return form data accordingly let has_expired = common_utils::date_time::now() > payout_link.expiry; @@ -120,7 +123,7 @@ pub async fn initiate_payout_link( Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: (link_data.allowed_domains), + allowed_domains, data: GenericLinksData::ExpiredLink(expired_link_data), locale, }, @@ -204,6 +207,7 @@ pub async fn initiate_payout_link( amount, currency: payout.destination_currency, locale: locale.clone(), + test_mode: link_data.test_mode.unwrap_or(false), }; let serialized_css_content = String::new(); @@ -224,7 +228,7 @@ pub async fn initiate_payout_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: (link_data.allowed_domains), + allowed_domains, data: GenericLinksData::PayoutLink(generic_form_data), locale, }, @@ -249,6 +253,7 @@ pub async fn initiate_payout_link( error_code: payout_attempt.error_code, error_message: payout_attempt.error_message, ui_config: ui_config_data, + test_mode: link_data.test_mode.unwrap_or(false), }; let serialized_css_content = String::new(); @@ -267,7 +272,7 @@ pub async fn initiate_payout_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: (link_data.allowed_domains), + allowed_domains, data: GenericLinksData::PayoutLinkStatus(generic_status_data), locale, }, diff --git a/crates/router/src/core/payouts.rs b/crates/router/src/core/payouts.rs index a54f00d6aa04..e0bd37aef0de 100644 --- a/crates/router/src/core/payouts.rs +++ b/crates/router/src/core/payouts.rs @@ -4,7 +4,7 @@ pub mod helpers; pub mod retry; pub mod transformers; pub mod validator; -use std::vec::IntoIter; +use std::{collections::HashSet, vec::IntoIter}; use api_models::{self, enums as api_enums, payouts::PayoutLinkResponse}; #[cfg(feature = "payout_retry")] @@ -28,7 +28,7 @@ use futures::future::join_all; use masking::{PeekInterface, Secret}; #[cfg(feature = "payout_retry")] use retry::GsmValidation; -use router_env::{instrument, logger, tracing}; +use router_env::{instrument, logger, tracing, Env}; use scheduler::utils as pt_utils; use serde_json; use time::Duration; @@ -2614,15 +2614,34 @@ pub async fn create_payout_link( .and_then(|config| config.ui_config.clone()) .or(profile_ui_config); - // Validate allowed_domains presence - let allowed_domains = profile_config + let test_mode_in_config = payout_link_config_req .as_ref() - .map(|config| config.config.allowed_domains.to_owned()) - .get_required_value("allowed_domains") - .change_context(errors::ApiErrorResponse::LinkConfigurationError { - message: "Payout links cannot be used without setting allowed_domains in profile" - .to_string(), - })?; + .and_then(|config| config.test_mode) + .or_else(|| profile_config.as_ref().and_then(|c| c.payout_test_mode)); + let is_test_mode_enabled = test_mode_in_config.unwrap_or(false); + + let allowed_domains = match (router_env::which(), is_test_mode_enabled) { + // Throw error in case test_mode was enabled in production + (Env::Production, true) => Err(report!(errors::ApiErrorResponse::LinkConfigurationError { + message: "test_mode cannot be true for creating payout_links in production".to_string() + })), + // Send empty set of whitelisted domains + (_, true) => { + Ok(HashSet::new()) + }, + // Otherwise, fetch and use allowed domains from profile config + (_, false) => { + profile_config + .as_ref() + .map(|config| config.config.allowed_domains.to_owned()) + .get_required_value("allowed_domains") + .change_context(errors::ApiErrorResponse::LinkConfigurationError { + message: + "Payout links cannot be used without setting allowed_domains in profile. If you're using a non-production environment, you can set test_mode to true while in payout_link_config" + .to_string(), + }) + } + }?; // Form data to be injected in the link let (logo, merchant_name, theme) = match ui_config { @@ -2685,6 +2704,7 @@ pub async fn create_payout_link( amount: MinorUnit::from(*amount), currency: *currency, allowed_domains, + test_mode: test_mode_in_config, }; create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index d425725645b8..2d47a60f23d4 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use actix_web::http::header; #[cfg(feature = "olap")] use common_utils::errors::CustomResult; @@ -5,7 +7,7 @@ use common_utils::validation::validate_domain_against_allowed_domains; use diesel_models::generic_link::PayoutLink; use error_stack::{report, ResultExt}; pub use hyperswitch_domain_models::errors::StorageError; -use router_env::{instrument, tracing}; +use router_env::{instrument, tracing, which as router_env_which, Env}; use url::Url; use super::helpers; @@ -225,88 +227,105 @@ pub(super) fn validate_payout_list_request_for_joins( Ok(()) } -pub fn validate_payout_link_render_request( +pub fn validate_payout_link_render_request_and_get_allowed_domains( request_headers: &header::HeaderMap, payout_link: &PayoutLink, -) -> RouterResult<()> { +) -> RouterResult> { let link_id = payout_link.link_id.to_owned(); let link_data = payout_link.link_data.to_owned(); - // Fetch destination is "iframe" - match request_headers.get("sec-fetch-dest").and_then(|v| v.to_str().ok()) { - Some("iframe") => Ok(()), - Some(requestor) => Err(report!(errors::ApiErrorResponse::AccessForbidden { - resource: "payout_link".to_string(), - })) - .attach_printable_lazy(|| { - format!( - "Access to payout_link [{}] is forbidden when requested through {}", - link_id, requestor - ) - }), - None => Err(report!(errors::ApiErrorResponse::AccessForbidden { - resource: "payout_link".to_string(), - })) - .attach_printable_lazy(|| { - format!( - "Access to payout_link [{}] is forbidden when sec-fetch-dest is not present in request headers", - link_id - ) - }), - }?; + let is_test_mode_enabled = link_data.test_mode.unwrap_or(false); - // Validate origin / referer - let domain_in_req = { - let origin_or_referer = request_headers - .get("origin") - .or_else(|| request_headers.get("referer")) - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - report!(errors::ApiErrorResponse::AccessForbidden { + match (router_env_which(), is_test_mode_enabled) { + // Throw error in case test_mode was enabled in production + (Env::Production, true) => Err(report!(errors::ApiErrorResponse::LinkConfigurationError { + message: "test_mode cannot be true for rendering payout_links in production" + .to_string() + })), + // Skip all validations when test mode is enabled in non prod env + (_, true) => Ok(HashSet::new()), + // Otherwise, perform validations + (_, false) => { + // Fetch destination is "iframe" + match request_headers.get("sec-fetch-dest").and_then(|v| v.to_str().ok()) { + Some("iframe") => Ok(()), + Some(requestor) => Err(report!(errors::ApiErrorResponse::AccessForbidden { resource: "payout_link".to_string(), - }) - }) - .attach_printable_lazy(|| { - format!( - "Access to payout_link [{}] is forbidden when origin or referer is not present in request headers", - link_id - ) - })?; - - let url = Url::parse(origin_or_referer) - .map_err(|_| { - report!(errors::ApiErrorResponse::AccessForbidden { + })) + .attach_printable_lazy(|| { + format!( + "Access to payout_link [{}] is forbidden when requested through {}", + link_id, requestor + ) + }), + None => Err(report!(errors::ApiErrorResponse::AccessForbidden { resource: "payout_link".to_string(), - }) - }) - .attach_printable_lazy(|| { - format!("Invalid URL found in request headers {}", origin_or_referer) - })?; + })) + .attach_printable_lazy(|| { + format!( + "Access to payout_link [{}] is forbidden when sec-fetch-dest is not present in request headers", + link_id + ) + }), + }?; + + // Validate origin / referer + let domain_in_req = { + let origin_or_referer = request_headers + .get("origin") + .or_else(|| request_headers.get("referer")) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + report!(errors::ApiErrorResponse::AccessForbidden { + resource: "payout_link".to_string(), + }) + }) + .attach_printable_lazy(|| { + format!( + "Access to payout_link [{}] is forbidden when origin or referer is not present in request headers", + link_id + ) + })?; - url.host_str() - .and_then(|host| url.port().map(|port| format!("{}:{}", host, port))) - .or_else(|| url.host_str().map(String::from)) - .ok_or_else(|| { - report!(errors::ApiErrorResponse::AccessForbidden { + let url = Url::parse(origin_or_referer) + .map_err(|_| { + report!(errors::ApiErrorResponse::AccessForbidden { + resource: "payout_link".to_string(), + }) + }) + .attach_printable_lazy(|| { + format!("Invalid URL found in request headers {}", origin_or_referer) + })?; + + url.host_str() + .and_then(|host| url.port().map(|port| format!("{}:{}", host, port))) + .or_else(|| url.host_str().map(String::from)) + .ok_or_else(|| { + report!(errors::ApiErrorResponse::AccessForbidden { + resource: "payout_link".to_string(), + }) + }) + .attach_printable_lazy(|| { + format!("host or port not found in request headers {:?}", url) + })? + }; + + if validate_domain_against_allowed_domains( + &domain_in_req, + link_data.allowed_domains.clone(), + ) { + Ok(link_data.allowed_domains) + } else { + Err(report!(errors::ApiErrorResponse::AccessForbidden { resource: "payout_link".to_string(), + })) + .attach_printable_lazy(|| { + format!( + "Access to payout_link [{}] is forbidden from requestor - {}", + link_id, domain_in_req + ) }) - }) - .attach_printable_lazy(|| { - format!("host or port not found in request headers {:?}", url) - })? - }; - - if validate_domain_against_allowed_domains(&domain_in_req, link_data.allowed_domains) { - Ok(()) - } else { - Err(report!(errors::ApiErrorResponse::AccessForbidden { - resource: "payout_link".to_string(), - })) - .attach_printable_lazy(|| { - format!( - "Access to payout_link [{}] is forbidden from requestor - {}", - link_id, domain_in_req - ) - }) + } + } } } diff --git a/crates/router/src/types/transformers.rs b/crates/router/src/types/transformers.rs index 2cc95281f030..b385cc64ad32 100644 --- a/crates/router/src/types/transformers.rs +++ b/crates/router/src/types/transformers.rs @@ -1788,6 +1788,7 @@ impl ForeignFrom fn foreign_from(item: api_models::admin::BusinessPayoutLinkConfig) -> Self { Self { config: item.config.foreign_into(), + payout_test_mode: item.payout_test_mode, } } } @@ -1798,6 +1799,7 @@ impl ForeignFrom fn foreign_from(item: diesel_models::business_profile::BusinessPayoutLinkConfig) -> Self { Self { config: item.config.foreign_into(), + payout_test_mode: item.payout_test_mode, } } }