From 11a49160000a76d5d7601d114dd14987e60968f0 Mon Sep 17 00:00:00 2001 From: Kashif Date: Fri, 5 Jul 2024 16:53:14 +0530 Subject: [PATCH 01/17] feat(api): update html response to include headers if needed feat(generic_link): add allowed_domains in GenericLink response feat(payout_link): add allowed_domains in payout_link_config (fallback profile config + payout request config) feat(payout_link): consume meta headers for validating render request for payout links feat(payout_link): consume CSP and X-Frame headers in the link's response --- crates/api_models/src/admin.rs | 5 +- crates/api_models/src/payouts.rs | 5 + crates/common_utils/src/link_utils.rs | 2 + crates/router/src/compatibility/wrap.rs | 13 +- crates/router/src/core/admin.rs | 30 +++- .../payout_link/status/styles.css | 30 +++- crates/router/src/core/payment_methods.rs | 17 ++- crates/router/src/core/payout_link.rs | 23 ++- crates/router/src/core/payouts/validator.rs | 137 ++++++++++++++++-- crates/router/src/routes/payout_link.rs | 3 +- crates/router/src/services/api.rs | 66 ++++++--- .../src/services/api/generic_link_response.rs | 14 +- 12 files changed, 287 insertions(+), 58 deletions(-) diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index cb268262fbcf..85cfa427eed4 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use common_utils::{ consts, @@ -1160,6 +1160,9 @@ pub struct BusinessGenericLinkConfig { /// Custom domain name to be used for hosting the link pub domain_name: Option, + /// A list of allowed domains regexes where this link can be embedded / opened from + pub allowed_domains: Option>, + #[serde(flatten)] #[schema(value_type = GenericLinkUiConfig)] pub ui_config: link_utils::GenericLinkUiConfig, diff --git a/crates/api_models/src/payouts.rs b/crates/api_models/src/payouts.rs index 49a053dd603b..0500bfd96f07 100644 --- a/crates/api_models/src/payouts.rs +++ b/crates/api_models/src/payouts.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use cards::CardNumber; use common_utils::{ consts::default_payouts_list_limit, @@ -180,6 +182,9 @@ 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>, + + /// A list of allowed domains regexes where the payout link can be embedded / opened from + pub allowed_domains: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] diff --git a/crates/common_utils/src/link_utils.rs b/crates/common_utils/src/link_utils.rs index 2960209dfc90..7355dfefe0db 100644 --- a/crates/common_utils/src/link_utils.rs +++ b/crates/common_utils/src/link_utils.rs @@ -162,6 +162,8 @@ pub struct PayoutLinkData { pub amount: MinorUnit, /// Payout currency pub currency: enums::Currency, + /// A list of allowed domains regexes where the payout link can be embedded / opened from + pub allowed_domains: Option>, } crate::impl_to_sql_from_sql_json!(PayoutLinkData); diff --git a/crates/router/src/compatibility/wrap.rs b/crates/router/src/compatibility/wrap.rs index 7c1bd7e67121..9ed03c6d2ce0 100644 --- a/crates/router/src/compatibility/wrap.rs +++ b/crates/router/src/compatibility/wrap.rs @@ -141,10 +141,11 @@ where } Ok(api::ApplicationResponse::GenericLinkForm(boxed_generic_link_data)) => { - let link_type = (boxed_generic_link_data).to_string(); - match services::generic_link_response::build_generic_link_html(*boxed_generic_link_data) - { - Ok(rendered_html) => api::http_response_html_data(rendered_html), + let link_type = (boxed_generic_link_data).data.to_string(); + match services::generic_link_response::build_generic_link_html( + boxed_generic_link_data.data, + ) { + Ok(rendered_html) => api::http_response_html_data(rendered_html, None), Err(_) => { api::http_response_err(format!("Error while rendering {} HTML page", link_type)) } @@ -155,7 +156,7 @@ where match *boxed_payment_link_data { api::PaymentLinkAction::PaymentLinkFormData(payment_link_data) => { match api::build_payment_link_html(payment_link_data) { - Ok(rendered_html) => api::http_response_html_data(rendered_html), + Ok(rendered_html) => api::http_response_html_data(rendered_html, None), Err(_) => api::http_response_err( r#"{ "error": { @@ -167,7 +168,7 @@ where } api::PaymentLinkAction::PaymentLinkStatus(payment_link_data) => { match api::get_payment_link_status(payment_link_data) { - Ok(rendered_html) => api::http_response_html_data(rendered_html), + Ok(rendered_html) => api::http_response_html_data(rendered_html, None), Err(_) => api::http_response_err( r#"{ "error": { diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index dabe11552942..166bc77e278c 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +use std::{collections::HashSet, str::FromStr}; use api_models::{ admin::{self as admin_types}, @@ -15,6 +15,7 @@ use error_stack::{report, FutureExt, ResultExt}; use futures::future::try_join_all; use masking::{PeekInterface, Secret}; use pm_auth::connector::plaid::transformers::PlaidAuthType; +use regex::Regex; use router_env::metrics::add_attributes; use uuid::Uuid; @@ -1693,6 +1694,12 @@ pub async fn update_business_profile( .transpose()? .map(Secret::new); + request + .payout_link_config + .clone() + .and_then(|config| config.config.allowed_domains) + .map_or(Ok(()), validate_allowed_domains_regex)?; + let business_profile_update = storage::business_profile::BusinessProfileUpdate::Update { profile_name: request.profile_name, modified_at: Some(date_time::now()), @@ -2275,3 +2282,24 @@ pub fn validate_status_and_disabled( Ok((connector_status, disabled)) } + +pub fn validate_allowed_domains_regex(allowed_domains: HashSet) -> RouterResult<()> { + let errors: Vec = allowed_domains + .into_iter() + .filter_map(|domain| { + Regex::new(&format!(r"{}", domain)) + .err() + .map(|err| format!("Failed to parse regex `{}` - {err}", domain)) + }) + .collect(); + if errors.is_empty() { + Ok(()) + } else { + Err(report!(errors::ApiErrorResponse::InvalidRequestData { + message: format!( + "allowed_domains contain invalid domain regexes -\n{}", + errors.join(", ") + ) + })) + } +} diff --git a/crates/router/src/core/generic_link/payout_link/status/styles.css b/crates/router/src/core/generic_link/payout_link/status/styles.css index cf2d89b6a30b..d56685531294 100644 --- a/crates/router/src/core/generic_link/payout_link/status/styles.css +++ b/crates/router/src/core/generic_link/payout_link/status/styles.css @@ -78,9 +78,9 @@ body { } #resource-info-container { - width: calc(100% - 80px); + width: 100%; border-top: 1px solid rgb(231, 234, 241); - padding: 20px 40px; + padding: 20px 0; } #resource-info { display: flex; @@ -88,7 +88,7 @@ body { } #info-key { text-align: right; - font-size: 15px; + font-size: 14px; min-width: 10ch; } #info-val { @@ -101,13 +101,33 @@ body { margin-top: 40px; } -@media only screen and (max-width: 1199px) { +@media only screen and (max-width: 420px) { body { overflow-y: scroll; } + body { + justify-content: start; + } + .main { - width: auto; + width: 100%; min-width: 300px; } + + #status-card { + box-shadow: none; + } + + #info-key { + min-width: 12ch; + } + + #info-val { + font-size: 11px; + } + + #resource-info { + margin: 0 10px; + } } diff --git a/crates/router/src/core/payment_methods.rs b/crates/router/src/core/payment_methods.rs index e4f961f40d01..e4590b0d0c24 100644 --- a/crates/router/src/core/payment_methods.rs +++ b/crates/router/src/core/payment_methods.rs @@ -25,7 +25,7 @@ use crate::{ pm_auth as core_pm_auth, }, routes::{app::StorageInterface, SessionState}, - services::{self, GenericLinks}, + services::{self, GenericLinks, GenericLinksData}, types::{ api::{self, payments}, domain, storage, @@ -243,7 +243,10 @@ pub async fn render_pm_collect_link( theme: link_data.ui_config.theme.unwrap_or(default_ui_config.theme), }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( - GenericLinks::ExpiredLink(expired_link_data), + GenericLinks { + allowed_domains: None, + data: GenericLinksData::ExpiredLink(expired_link_data), + }, ))) // else, send back form link @@ -306,7 +309,10 @@ pub async fn render_pm_collect_link( html_meta_tags: String::new(), }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( - GenericLinks::PaymentMethodCollect(generic_form_data), + GenericLinks { + allowed_domains: None, + data: GenericLinksData::PaymentMethodCollect(generic_form_data), + }, ))) } } @@ -347,7 +353,10 @@ pub async fn render_pm_collect_link( css_data: serialized_css_content, }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( - GenericLinks::PaymentMethodCollectStatus(generic_status_data), + GenericLinks { + allowed_domains: None, + data: GenericLinksData::PaymentMethodCollectStatus(generic_status_data), + }, ))) } } diff --git a/crates/router/src/core/payout_link.rs b/crates/router/src/core/payout_link.rs index 0039b019868e..0cdcf74c3e76 100644 --- a/crates/router/src/core/payout_link.rs +++ b/crates/router/src/core/payout_link.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; +use actix_web::http::header; use api_models::payouts; use common_utils::{ ext_traits::{Encode, OptionExt}, @@ -11,10 +12,10 @@ use error_stack::ResultExt; use super::errors::{RouterResponse, StorageErrorExt}; use crate::{ - core::payments::helpers, + core::{payments::helpers, payouts::validator}, errors, routes::{app::StorageInterface, SessionState}, - services::{self, GenericLinks}, + services::{self, GenericLinks, GenericLinksData}, types::domain, }; @@ -23,6 +24,7 @@ pub async fn initiate_payout_link( merchant_account: domain::MerchantAccount, key_store: domain::MerchantKeyStore, req: payouts::PayoutLinkInitiateRequest, + request_headers: &header::HeaderMap, ) -> RouterResponse { let db: &dyn StorageInterface = &*state.store; let merchant_id = &merchant_account.merchant_id; @@ -58,6 +60,8 @@ pub async fn initiate_payout_link( message: "payout link not found".to_string(), })?; + validator::validate_payout_link_render_request(&state, request_headers, &payout_link)?; + // Check status and return form data accordingly let has_expired = common_utils::date_time::now() > payout_link.expiry; let status = payout_link.link_status.clone(); @@ -96,7 +100,10 @@ pub async fn initiate_payout_link( } Ok(services::ApplicationResponse::GenericLinkForm(Box::new( - GenericLinks::ExpiredLink(expired_link_data), + GenericLinks { + allowed_domains: link_data.allowed_domains, + data: GenericLinksData::ExpiredLink(expired_link_data), + }, ))) } @@ -191,7 +198,10 @@ pub async fn initiate_payout_link( html_meta_tags: String::new(), }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( - GenericLinks::PayoutLink(generic_form_data), + GenericLinks { + allowed_domains: link_data.allowed_domains, + data: GenericLinksData::PayoutLink(generic_form_data), + }, ))) } @@ -230,7 +240,10 @@ pub async fn initiate_payout_link( css_data: serialized_css_content, }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( - GenericLinks::PayoutLinkStatus(generic_status_data), + GenericLinks { + allowed_domains: link_data.allowed_domains, + data: GenericLinksData::PayoutLinkStatus(generic_status_data), + }, ))) } } diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index 3e69216167de..211bbc7ed5d9 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -1,7 +1,10 @@ -use api_models::admin; +use std::collections::HashSet; + #[cfg(feature = "olap")] -use common_utils::errors::CustomResult; +use actix_web::http::header; +use api_models::admin; use common_utils::{ + errors::CustomResult, ext_traits::ValueExt, id_type::CustomerId, link_utils::{GenericLinkStatus, GenericLinkUiConfig, PayoutLinkData, PayoutLinkStatus}, @@ -14,13 +17,15 @@ use diesel_models::{ use error_stack::{report, ResultExt}; pub use hyperswitch_domain_models::errors::StorageError; use masking::Secret; -use router_env::{instrument, tracing}; +use regex::Regex; +use router_env::{instrument, logger, tracing, Env}; use time::Duration; use super::helpers; use crate::{ consts, core::{ + admin::validate_allowed_domains_regex, errors::{self, RouterResult, StorageErrorExt}, utils as core_utils, }, @@ -202,14 +207,12 @@ pub async fn create_payout_link( payout_id: &String, ) -> RouterResult { let payout_link_config_req = req.payout_link_config.to_owned(); - // Create payment method collect link ID - let payout_link_id = core_utils::get_or_generate_id( - "payout_link_id", - &payout_link_config_req - .as_ref() - .and_then(|config| config.payout_link_id.clone()), - "payout_link", - )?; + + // Validate allowed domains in request + payout_link_config_req + .as_ref() + .and_then(|config| config.allowed_domains.clone()) + .map_or(Ok(()), validate_allowed_domains_regex)?; // Fetch all configs let default_config = &state.conf.generic_link.payout_link; @@ -231,6 +234,20 @@ pub async fn create_payout_link( .and_then(|config| config.ui_config.clone()) .or(profile_ui_config); + // Validate allowed_domains presence + let req_allowed_domains = payout_link_config_req + .as_ref() + .and_then(|req| req.allowed_domains.to_owned()) + .or(profile_config + .as_ref() + .and_then(|config| config.config.allowed_domains.to_owned())); + + if matches!(state.conf.env, Env::Production) && req_allowed_domains.is_none() { + return Err(report!(errors::ApiErrorResponse::MissingRequiredField { + field_name: "allowed_domains" + })); + } + // Form data to be injected in the link let (logo, merchant_name, theme) = match ui_config { Some(config) => (config.logo, config.merchant_name, config.theme), @@ -268,6 +285,13 @@ pub async fn create_payout_link( .as_ref() .get_required_value("currency") .attach_printable("currency is a required value when creating payout links")?; + let payout_link_id = core_utils::get_or_generate_id( + "payout_link_id", + &payout_link_config_req + .as_ref() + .and_then(|config| config.payout_link_id.clone()), + "payout_link", + )?; let data = PayoutLinkData { payout_link_id: payout_link_id.clone(), @@ -280,6 +304,7 @@ pub async fn create_payout_link( enabled_payment_methods: req_enabled_payment_methods, amount: MinorUnit::from(*amount), currency: *currency, + allowed_domains: req_allowed_domains, }; create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await @@ -317,3 +342,93 @@ pub async fn create_payout_link_db_entry( message: "payout link already exists".to_string(), }) } + +pub fn validate_payout_link_render_request( + state: &SessionState, + request_headers: &header::HeaderMap, + payout_link: &PayoutLink, +) -> RouterResult<()> { + let link_id = payout_link.link_id.to_owned(); + let link_data = payout_link.link_data.to_owned(); + + // Fetch allowed domains + let (allowed_domains, should_validate_request_headers) = match state.conf.env { + Env::Production => { + (link_data.allowed_domains + .ok_or_else(|| report!(errors::ApiErrorResponse::AccessForbidden { + resource: "payout_link".to_string(), + })) + .attach_printable_lazy(|| { + format!("Access to payout_link [{}] is forbidden without setting up allowed_domains for the link", link_id) + })?, true) + }, + _ => { + link_data.allowed_domains + .map_or((HashSet::from(["*".to_string()]), false), |allowed_domains| (allowed_domains, true)) + } + }; + + if !should_validate_request_headers { + return Ok(()); + } + + // 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 + ) + }), + }?; + + // Validate origin / referer + let domain_in_req = 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 both origin and referer headers are missing from the request headers", + link_id + ) + })?; + + if is_domain_allowed(domain_in_req, 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 + ) + }) + } +} + +fn is_domain_allowed(domain: &str, allowed_domains: HashSet) -> bool { + allowed_domains.iter().any(|allowed_domain| { + Regex::new(&format!(r"{}", allowed_domain)) + .and_then(|regex| Ok(regex.is_match(domain))) + .map_err(|err| logger::error!("Invalid regex! - {:?}", err)) + .unwrap_or(false) + }) +} diff --git a/crates/router/src/routes/payout_link.rs b/crates/router/src/routes/payout_link.rs index 34850bdea6e7..e3a0327c3298 100644 --- a/crates/router/src/routes/payout_link.rs +++ b/crates/router/src/routes/payout_link.rs @@ -23,13 +23,14 @@ pub async fn render_payout_link( merchant_id: merchant_id.clone(), payout_id, }; + let headers = req.headers(); Box::pin(api::server_wrap( flow, state, &req, payload.clone(), |state, auth, req, _| { - initiate_payout_link(state, auth.merchant_account, auth.key_store, req) + initiate_payout_link(state, auth.merchant_account, auth.key_store, req, headers) }, &auth::MerchantIdAuth(merchant_id), api_locking::LockAction::NotApplicable, diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index 9f9dffec5861..4d7e5a8340ab 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -13,8 +13,9 @@ use std::{ use actix_http::header::HeaderMap; use actix_web::{ - body, http::header::HeaderValue, web, FromRequest, HttpRequest, HttpResponse, Responder, - ResponseError, + body, + http::header::{self, HeaderValue}, + web, FromRequest, HttpRequest, HttpResponse, Responder, ResponseError, }; use api_models::enums::{CaptureMethod, PaymentMethodType}; pub use client::{proxy_bypass_urls, ApiClient, MockApiClient, ProxyClient}; @@ -727,7 +728,13 @@ pub enum ApplicationResponse { } #[derive(Debug, Eq, PartialEq)] -pub enum GenericLinks { +pub struct GenericLinks { + pub allowed_domains: Option>, + pub data: GenericLinksData, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum GenericLinksData { ExpiredLink(GenericExpiredLinkData), PaymentMethodCollect(GenericLinkFormData), PayoutLink(GenericLinkFormData), @@ -735,17 +742,17 @@ pub enum GenericLinks { PaymentMethodCollectStatus(GenericLinkStatusData), } -impl Display for Box { +impl Display for GenericLinksData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", - match **self { - GenericLinks::ExpiredLink(_) => "ExpiredLink", - GenericLinks::PaymentMethodCollect(_) => "PaymentMethodCollect", - GenericLinks::PayoutLink(_) => "PayoutLink", - GenericLinks::PayoutLinkStatus(_) => "PayoutLinkStatus", - GenericLinks::PaymentMethodCollectStatus(_) => "PaymentMethodCollectStatus", + match *self { + GenericLinksData::ExpiredLink(_) => "ExpiredLink", + GenericLinksData::PaymentMethodCollect(_) => "PaymentMethodCollect", + GenericLinksData::PayoutLink(_) => "PayoutLink", + GenericLinksData::PayoutLinkStatus(_) => "PayoutLinkStatus", + GenericLinksData::PaymentMethodCollectStatus(_) => "PaymentMethodCollectStatus", } ) } @@ -1118,9 +1125,20 @@ where } Ok(ApplicationResponse::GenericLinkForm(boxed_generic_link_data)) => { - let link_type = (boxed_generic_link_data).to_string(); - match build_generic_link_html(*boxed_generic_link_data) { - Ok(rendered_html) => http_response_html_data(rendered_html), + let link_type = boxed_generic_link_data.data.to_string(); + match build_generic_link_html(boxed_generic_link_data.data) { + Ok(rendered_html) => { + let headers = boxed_generic_link_data.allowed_domains.map(|domains| { + let domains_str = domains.into_iter().collect::>().join(" "); + let csp_header = format!("frame-ancestors 'self' {}", domains_str); + let frame_header = format!("ALLOW-FROM {}", domains_str); + HashSet::from([ + ("content-security-policy", csp_header), + ("x-frame-options", frame_header), + ]) + }); + http_response_html_data(rendered_html, headers) + } Err(_) => { http_response_err(format!("Error while rendering {} HTML page", link_type)) } @@ -1131,7 +1149,7 @@ where match *boxed_payment_link_data { PaymentLinkAction::PaymentLinkFormData(payment_link_data) => { match build_payment_link_html(payment_link_data) { - Ok(rendered_html) => http_response_html_data(rendered_html), + Ok(rendered_html) => http_response_html_data(rendered_html, None), Err(_) => http_response_err( r#"{ "error": { @@ -1143,7 +1161,7 @@ where } PaymentLinkAction::PaymentLinkStatus(payment_link_data) => { match get_payment_link_status(payment_link_data) { - Ok(rendered_html) => http_response_html_data(rendered_html), + Ok(rendered_html) => http_response_html_data(rendered_html, None), Err(_) => http_response_err( r#"{ "error": { @@ -1300,8 +1318,22 @@ pub fn http_response_file_data( HttpResponse::Ok().content_type(content_type).body(res) } -pub fn http_response_html_data(res: T) -> HttpResponse { - HttpResponse::Ok().content_type(mime::TEXT_HTML).body(res) +pub fn http_response_html_data( + res: T, + optional_headers: Option>, +) -> HttpResponse { + let mut res_builder = HttpResponse::Ok(); + res_builder.content_type(mime::TEXT_HTML); + + if let Some(headers) = optional_headers { + for (key, value) in headers { + if let Ok(header_val) = header::HeaderValue::try_from(value) { + res_builder.insert_header((header::HeaderName::from_static(key), header_val)); + } + } + } + + res_builder.body(res) } pub fn http_response_ok() -> HttpResponse { diff --git a/crates/router/src/services/api/generic_link_response.rs b/crates/router/src/services/api/generic_link_response.rs index fb8b3b3ec74d..83ca9d5b58b9 100644 --- a/crates/router/src/services/api/generic_link_response.rs +++ b/crates/router/src/services/api/generic_link_response.rs @@ -2,24 +2,24 @@ use common_utils::errors::CustomResult; use error_stack::ResultExt; use tera::{Context, Tera}; -use super::{GenericExpiredLinkData, GenericLinkFormData, GenericLinkStatusData, GenericLinks}; +use super::{GenericExpiredLinkData, GenericLinkFormData, GenericLinkStatusData, GenericLinksData}; use crate::core::errors; pub fn build_generic_link_html( - boxed_generic_link_data: GenericLinks, + boxed_generic_link_data: GenericLinksData, ) -> CustomResult { match boxed_generic_link_data { - GenericLinks::ExpiredLink(link_data) => build_generic_expired_link_html(&link_data), + GenericLinksData::ExpiredLink(link_data) => build_generic_expired_link_html(&link_data), - GenericLinks::PaymentMethodCollect(pm_collect_data) => { + GenericLinksData::PaymentMethodCollect(pm_collect_data) => { build_pm_collect_link_html(&pm_collect_data) } - GenericLinks::PaymentMethodCollectStatus(pm_collect_data) => { + GenericLinksData::PaymentMethodCollectStatus(pm_collect_data) => { build_pm_collect_link_status_html(&pm_collect_data) } - GenericLinks::PayoutLink(payout_link_data) => build_payout_link_html(&payout_link_data), + GenericLinksData::PayoutLink(payout_link_data) => build_payout_link_html(&payout_link_data), - GenericLinks::PayoutLinkStatus(pm_collect_data) => { + GenericLinksData::PayoutLinkStatus(pm_collect_data) => { build_payout_link_status_html(&pm_collect_data) } } From 68651b8270a608a95567f42cfeafb3f5a5a255a1 Mon Sep 17 00:00:00 2001 From: "hyperswitch-bot[bot]" <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:34:53 +0000 Subject: [PATCH 02/17] docs(openapi): re-generate OpenAPI specification --- api-reference/openapi_spec.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/api-reference/openapi_spec.json b/api-reference/openapi_spec.json index c7831f179bb1..e08b35c5868e 100644 --- a/api-reference/openapi_spec.json +++ b/api-reference/openapi_spec.json @@ -6814,6 +6814,15 @@ "type": "string", "description": "Custom domain name to be used for hosting the link", "nullable": true + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of allowed domains regexes where this link can be embedded / opened from", + "uniqueItems": true, + "nullable": true } } } @@ -17656,6 +17665,15 @@ "description": "List of payout methods shown on collect UI", "example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]", "nullable": true + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of allowed domains regexes where the payout link can be embedded / opened from", + "uniqueItems": true, + "nullable": true } } } From d805a34b3c23d1a66de2d09bef8564a4523c7e52 Mon Sep 17 00:00:00 2001 From: Kashif Date: Fri, 5 Jul 2024 17:45:56 +0530 Subject: [PATCH 03/17] refactor: clippy fixes --- crates/router/src/core/admin.rs | 2 +- crates/router/src/core/payouts/validator.rs | 4 ++-- crates/router/src/services/api.rs | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index 166bc77e278c..854b7c71d84e 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -2287,7 +2287,7 @@ pub fn validate_allowed_domains_regex(allowed_domains: HashSet) -> Route let errors: Vec = allowed_domains .into_iter() .filter_map(|domain| { - Regex::new(&format!(r"{}", domain)) + Regex::new(&domain) .err() .map(|err| format!("Failed to parse regex `{}` - {err}", domain)) }) diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index 211bbc7ed5d9..87e8c99f9960 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -426,8 +426,8 @@ pub fn validate_payout_link_render_request( fn is_domain_allowed(domain: &str, allowed_domains: HashSet) -> bool { allowed_domains.iter().any(|allowed_domain| { - Regex::new(&format!(r"{}", allowed_domain)) - .and_then(|regex| Ok(regex.is_match(domain))) + Regex::new(allowed_domain) + .map(|regex| regex.is_match(domain)) .map_err(|err| logger::error!("Invalid regex! - {:?}", err)) .unwrap_or(false) }) diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index 4d7e5a8340ab..e3afd90169b1 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -748,11 +748,11 @@ impl Display for GenericLinksData { f, "{}", match *self { - GenericLinksData::ExpiredLink(_) => "ExpiredLink", - GenericLinksData::PaymentMethodCollect(_) => "PaymentMethodCollect", - GenericLinksData::PayoutLink(_) => "PayoutLink", - GenericLinksData::PayoutLinkStatus(_) => "PayoutLinkStatus", - GenericLinksData::PaymentMethodCollectStatus(_) => "PaymentMethodCollectStatus", + Self::ExpiredLink(_) => "ExpiredLink", + Self::PaymentMethodCollect(_) => "PaymentMethodCollect", + Self::PayoutLink(_) => "PayoutLink", + Self::PayoutLinkStatus(_) => "PayoutLinkStatus", + Self::PaymentMethodCollectStatus(_) => "PaymentMethodCollectStatus", } ) } From c2454b2273f3385332d7777c43844ed230271db4 Mon Sep 17 00:00:00 2001 From: "hyperswitch-bot[bot]" <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 12:16:58 +0000 Subject: [PATCH 04/17] chore: run formatter --- crates/router/src/core/payouts/validator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index 87e8c99f9960..7344890321fa 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -427,7 +427,7 @@ pub fn validate_payout_link_render_request( fn is_domain_allowed(domain: &str, allowed_domains: HashSet) -> bool { allowed_domains.iter().any(|allowed_domain| { Regex::new(allowed_domain) - .map(|regex| regex.is_match(domain)) + .map(|regex| regex.is_match(domain)) .map_err(|err| logger::error!("Invalid regex! - {:?}", err)) .unwrap_or(false) }) From cc2ddc31eeb3f2b156cc512760d72a721288b8f3 Mon Sep 17 00:00:00 2001 From: Kashif Date: Mon, 8 Jul 2024 13:50:48 +0530 Subject: [PATCH 05/17] refactor: clippy fixes --- crates/router/src/services/api.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index e3afd90169b1..6cd2d8a972d2 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -14,7 +14,7 @@ use std::{ use actix_http::header::HeaderMap; use actix_web::{ body, - http::header::{self, HeaderValue}, + http::header::{HeaderName, HeaderValue}, web, FromRequest, HttpRequest, HttpResponse, Responder, ResponseError, }; use api_models::enums::{CaptureMethod, PaymentMethodType}; @@ -1327,8 +1327,8 @@ pub fn http_response_html_data( if let Some(headers) = optional_headers { for (key, value) in headers { - if let Ok(header_val) = header::HeaderValue::try_from(value) { - res_builder.insert_header((header::HeaderName::from_static(key), header_val)); + if let Ok(header_val) = HeaderValue::try_from(value) { + res_builder.insert_header((HeaderName::from_static(key), header_val)); } } } From 0c395f0f63d0b134d29fb9331525d00635658bbd Mon Sep 17 00:00:00 2001 From: Kashif Date: Mon, 8 Jul 2024 15:50:58 +0530 Subject: [PATCH 06/17] refactor: hacks fixes --- crates/router/src/core/payouts/validator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index 7344890321fa..e8d7860c5f22 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -1,8 +1,8 @@ use std::collections::HashSet; -#[cfg(feature = "olap")] use actix_web::http::header; use api_models::admin; +#[cfg(feature = "olap")] use common_utils::{ errors::CustomResult, ext_traits::ValueExt, From 118dfc466a9e3c21d080ba5ca1bb69aac67586f8 Mon Sep 17 00:00:00 2001 From: Kashif Date: Tue, 9 Jul 2024 10:39:25 +0530 Subject: [PATCH 07/17] refactor: hack fixes --- crates/router/src/core/payouts/validator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index e8d7860c5f22..147d97806059 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -3,8 +3,8 @@ use std::collections::HashSet; use actix_web::http::header; use api_models::admin; #[cfg(feature = "olap")] +use common_utils::errors::CustomResult; use common_utils::{ - errors::CustomResult, ext_traits::ValueExt, id_type::CustomerId, link_utils::{GenericLinkStatus, GenericLinkUiConfig, PayoutLinkData, PayoutLinkStatus}, From 560371cd35ffbd4a38f9a5f0c170d915838a0c31 Mon Sep 17 00:00:00 2001 From: Kashif Date: Thu, 11 Jul 2024 13:44:18 +0530 Subject: [PATCH 08/17] feat: attach client side JS to restrict loading the payout SDK --- .../payout_link/initiate/script.js | 286 +++++++++--------- crates/router/src/core/payout_link.rs | 14 +- 2 files changed, 161 insertions(+), 139 deletions(-) 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 a8050e90d6eb..fc2916f4d1cc 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,153 +1,165 @@ // @ts-check -var widgets = null; -var payoutWidget = null; -// @ts-ignore -var publishableKey = window.__PAYOUT_DETAILS.publishable_key; -var hyper = null; +// Top level checks +var isFramed = false; +try { + isFramed = window.parent.location !== window.location; -/** - * Use - format date in "hh:mm AM/PM timezone MM DD, YYYY" - * @param {Date} date - **/ -function formatDate(date) { - var months = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ]; + // If parent's window object is restricted, DOMException is + // thrown which concludes that the webpage is iframed +} catch (err) { + isFramed = true; +} - var hours = date.getHours(); - var minutes = date.getMinutes(); - // @ts-ignore - minutes = minutes < 10 ? "0" + minutes : minutes; - var suffix = hours > 11 ? "PM" : "AM"; - hours = hours % 12; - hours = hours ? hours : 12; - var day = date.getDate(); - var month = months[date.getMonth()]; - var year = date.getUTCFullYear(); +// Remove the script from DOM incase it's not iframed +if (!isFramed) { + function initializePayoutSDK() { + alert("This page can only be viewed within trusted domains."); + } - // @ts-ignore - var locale = navigator.language || navigator.userLanguage; - var timezoneShorthand = date - .toLocaleDateString(locale, { - day: "2-digit", - timeZoneName: "long", - }) - .substring(4) - .split(" ") - .reduce(function (tz, c) { - return tz + c.charAt(0).toUpperCase(); - }, ""); + // webpage is iframed, good to load +} else { + var hyper = null; + var payoutWidget = null; + var widgets = null; + /** + * Use - format date in "hh:mm AM/PM timezone MM DD, YYYY" + * @param {Date} date + **/ + function formatDate(date) { + var months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; - var formatted = - hours + - ":" + - minutes + - " " + - suffix + - " " + - timezoneShorthand + - " " + - month + - " " + - day + - ", " + - year; - return formatted; -} + var hours = date.getHours(); + var minutes = date.getMinutes(); + // @ts-ignore + minutes = minutes < 10 ? "0" + minutes : minutes; + var suffix = hours > 11 ? "PM" : "AM"; + hours = hours % 12; + hours = hours ? hours : 12; + var day = date.getDate(); + var month = months[date.getMonth()]; + var year = date.getUTCFullYear(); -/** - * Trigger - init - * Uses - * - Initialize SDK - * - Update document's icon - */ -function boot() { - // Initialize SDK - // @ts-ignore - if (window.Hyper) { - initializePayoutSDK(); + // @ts-ignore + var locale = navigator.language || navigator.userLanguage; + var timezoneShorthand = date + .toLocaleDateString(locale, { + day: "2-digit", + timeZoneName: "long", + }) + .substring(4) + .split(" ") + .reduce(function (tz, c) { + return tz + c.charAt(0).toUpperCase(); + }, ""); + + var formatted = + hours + + ":" + + minutes + + " " + + suffix + + " " + + timezoneShorthand + + " " + + month + + " " + + day + + ", " + + year; + return formatted; } - // @ts-ignore - var payoutDetails = window.__PAYOUT_DETAILS; + /** + * Trigger - init + * Uses + * - Initialize SDK + * - Update document's icon + */ + function boot() { + // Initialize SDK + // @ts-ignore + if (window.Hyper) { + initializePayoutSDK(); + } + + // @ts-ignore + var payoutDetails = window.__PAYOUT_DETAILS; - // Attach document icon - if (payoutDetails.logo) { - var link = document.createElement("link"); - link.rel = "icon"; - link.href = payoutDetails.logo; - link.type = "image/x-icon"; - document.head.appendChild(link); + // Attach document icon + if (payoutDetails.logo) { + var link = document.createElement("link"); + link.rel = "icon"; + link.href = payoutDetails.logo; + link.type = "image/x-icon"; + document.head.appendChild(link); + } } -} -boot(); + boot(); -/** - * Trigger - post downloading SDK - * Uses - * - Initialize SDK - * - Create a payout widget - * - Mount it in DOM - **/ -function initializePayoutSDK() { - // @ts-ignore - var payoutDetails = window.__PAYOUT_DETAILS; - var clientSecret = payoutDetails.client_secret; - var appearance = { - variables: { - colorPrimary: payoutDetails?.theme?.primary_color || "rgb(0, 109, 249)", - fontFamily: "Work Sans, sans-serif", - fontSizeBase: "16px", - colorText: "rgb(51, 65, 85)", - colorTextSecondary: "#334155B3", - colorPrimaryText: "rgb(51, 65, 85)", - colorTextPlaceholder: "#33415550", - borderColor: "#33415550", - colorBackground: "rgb(255, 255, 255)", - }, - }; - // Instantiate - // @ts-ignore - hyper = window.Hyper(publishableKey, { - isPreloadEnabled: false, - }); - widgets = hyper.widgets({ - appearance: appearance, - clientSecret: clientSecret, - }); + /** + * Trigger - post downloading SDK + * Uses + * - Initialize SDK + * - Create a payout widget + * - Mount it in DOM + **/ + function initializePayoutSDK() { + // @ts-ignore + var payoutDetails = window.__PAYOUT_DETAILS; + var clientSecret = payoutDetails.client_secret; + var publishableKey = payoutDetails.publishable_key; + var appearance = { + variables: { + colorPrimary: payoutDetails?.theme?.primary_color || "rgb(0, 109, 249)", + fontFamily: "Work Sans, sans-serif", + fontSizeBase: "16px", + colorText: "rgb(51, 65, 85)", + }, + }; + // @ts-ignore + hyper = window.Hyper(publishableKey, { + isPreloadEnabled: false, + }); + widgets = hyper.widgets({ + appearance: appearance, + clientSecret: clientSecret, + }); - // Create payment method collect widget - let sessionExpiry = formatDate(new Date(payoutDetails.session_expiry)); - var payoutOptions = { - linkId: payoutDetails.payout_link_id, - payoutId: payoutDetails.payout_id, - customerId: payoutDetails.customer_id, - theme: payoutDetails.theme, - collectorName: payoutDetails.merchant_name, - logo: payoutDetails.logo, - enabledPaymentMethods: payoutDetails.enabled_payment_methods, - returnUrl: payoutDetails.return_url, - sessionExpiry, - amount: payoutDetails.amount, - currency: payoutDetails.currency, - flow: "PayoutLinkInitiate", - }; - payoutWidget = widgets.create("paymentMethodCollect", payoutOptions); + // Create payment method collect widget + let sessionExpiry = formatDate(new Date(payoutDetails.session_expiry)); + var payoutOptions = { + linkId: payoutDetails.payout_link_id, + payoutId: payoutDetails.payout_id, + customerId: payoutDetails.customer_id, + theme: payoutDetails.theme, + collectorName: payoutDetails.merchant_name, + logo: payoutDetails.logo, + enabledPaymentMethods: payoutDetails.enabled_payment_methods, + returnUrl: payoutDetails.return_url, + sessionExpiry, + amount: payoutDetails.amount, + currency: payoutDetails.currency, + flow: "PayoutLinkInitiate", + }; + payoutWidget = widgets.create("paymentMethodCollect", payoutOptions); - // Mount - if (payoutWidget !== null) { - payoutWidget.mount("#payout-link"); + // Mount + if (payoutWidget !== null) { + payoutWidget.mount("#payout-link"); + } } } diff --git a/crates/router/src/core/payout_link.rs b/crates/router/src/core/payout_link.rs index 0cdcf74c3e76..4856f796d4ee 100644 --- a/crates/router/src/core/payout_link.rs +++ b/crates/router/src/core/payout_link.rs @@ -1,4 +1,7 @@ -use std::collections::{HashMap, HashSet}; +use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, +}; use actix_web::http::header; use api_models::payouts; @@ -152,10 +155,17 @@ pub async fn initiate_payout_link( }; // Fetch enabled payout methods from the request. If not found, fetch the enabled payout methods from MCA, // If none are configured for merchant connector accounts, fetch them from the default enabled payout methods. - let enabled_payment_methods = link_data + let mut enabled_payment_methods = link_data .enabled_payment_methods .unwrap_or(fallback_enabled_payout_methods.to_vec()); + // Sort payment methods (cards first) + enabled_payment_methods.sort_by(|a, b| match (a.payment_method, b.payment_method) { + (_, common_enums::PaymentMethod::Card) => Ordering::Greater, + (common_enums::PaymentMethod::Card, _) => Ordering::Less, + _ => Ordering::Equal, + }); + let js_data = payouts::PayoutLinkDetails { publishable_key: merchant_account .publishable_key From f2d2c424cf49d8eb9745ee9e67bd8f5037737d49 Mon Sep 17 00:00:00 2001 From: Kashif Date: Fri, 12 Jul 2024 14:56:21 +0530 Subject: [PATCH 09/17] refactor(payout_link): enforce allowed_domains for payout links feat: add validator for generic link configs --- crates/api_models/src/admin.rs | 27 +++++++++- crates/api_models/src/payouts.rs | 5 -- crates/common_utils/src/consts.rs | 16 ++++++ crates/common_utils/src/link_utils.rs | 35 ++++++++++-- .../src/errors/api_error_response.rs | 5 ++ .../router/src/compatibility/stripe/errors.rs | 10 +++- crates/router/src/core/admin.rs | 50 ++++++----------- .../payout_link/initiate/script.js | 8 ++- crates/router/src/core/payout_link.rs | 8 +-- crates/router/src/core/payouts/validator.rs | 53 ++++--------------- crates/router/src/types/api/admin.rs | 26 +++++---- 11 files changed, 141 insertions(+), 102 deletions(-) diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index 85cfa427eed4..7dafbf37b15e 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -1161,13 +1161,38 @@ pub struct BusinessGenericLinkConfig { pub domain_name: Option, /// A list of allowed domains regexes where this link can be embedded / opened from - pub allowed_domains: Option>, + pub allowed_domains: HashSet, #[serde(flatten)] #[schema(value_type = GenericLinkUiConfig)] pub ui_config: link_utils::GenericLinkUiConfig, } +impl BusinessGenericLinkConfig { + pub fn validate(&self) -> Result<(), &str> { + // Validate host domain name + let host_domain_valid = self + .domain_name + .clone() + .map(|host_domain| link_utils::validate_strict_domain(&host_domain)) + .unwrap_or(true); + if !host_domain_valid { + return Err("Invalid host domain name received"); + } + + let are_allowed_domains_valid = self + .allowed_domains + .clone() + .iter() + .any(|allowed_domain| link_utils::validate_wildcard_domain(allowed_domain)); + if !are_allowed_domains_valid { + return Err("Invalid allowed domain names received"); + } + + Ok(()) + } +} + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, ToSchema)] pub struct BusinessPaymentLinkConfig { /// Custom domain name to be used for hosting the link in your own domain diff --git a/crates/api_models/src/payouts.rs b/crates/api_models/src/payouts.rs index 0500bfd96f07..49a053dd603b 100644 --- a/crates/api_models/src/payouts.rs +++ b/crates/api_models/src/payouts.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use cards::CardNumber; use common_utils::{ consts::default_payouts_list_limit, @@ -182,9 +180,6 @@ 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>, - - /// A list of allowed domains regexes where the payout link can be embedded / opened from - pub allowed_domains: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] diff --git a/crates/common_utils/src/consts.rs b/crates/common_utils/src/consts.rs index efb60149c0bd..c856b07af603 100644 --- a/crates/common_utils/src/consts.rs +++ b/crates/common_utils/src/consts.rs @@ -98,3 +98,19 @@ pub const MAX_ALLOWED_MERCHANT_REFERENCE_ID_LENGTH: u8 = 64; /// Minimum allowed length for MerchantReferenceId pub const MIN_REQUIRED_MERCHANT_REFERENCE_ID_LENGTH: u8 = 1; + +/// Regex for matching a domain +/// Eg - +/// http://www.example.com +/// https://www.example.com +/// www.example.com +/// example.io +pub const STRICT_DOMAIN_REGEX: &str = r"^((http)s?:\/\/)?([A-Za-z0-9]{1,63}\.?)+([A-Za-z0-9-]{1,63}\.?)+([A-Za-z0-9]{1,63}\.?)+(\.[A-Za-z]{2,6}|:[0-9]{1,4})?$"; + +/// Regex for matching a wildcard domain +/// Eg - +/// *.example.com +/// *.subdomain.domain.com +/// *://example.com +/// *example.com +pub const WILDCARD_DOMAIN_REGEX: &str = r"^((((http)s|\*)?:\/\/)?([A-Za-z0-9]{1,63}\.?)+[A-Za-z0-9-*]{1,63}\.?)+([A-Za-z0-9]{1,63}\.?)+(\.[A-Za-z]{2,6}|:[0-9*]{1,4})?$"; diff --git a/crates/common_utils/src/link_utils.rs b/crates/common_utils/src/link_utils.rs index 7355dfefe0db..511e65fc935f 100644 --- a/crates/common_utils/src/link_utils.rs +++ b/crates/common_utils/src/link_utils.rs @@ -1,4 +1,4 @@ -//! Common +//! This module has common utilities for links in HyperSwitch use std::{collections::HashSet, primitive::i64}; @@ -13,10 +13,13 @@ use diesel::{ }; use error_stack::{report, ResultExt}; use masking::Secret; +use regex::Regex; use serde::Serialize; +#[cfg(feature = "logs")] +use router_env::logger; use utoipa::ToSchema; -use crate::{errors::ParsingError, id_type, types::MinorUnit}; +use crate::{consts, errors::ParsingError, id_type, types::MinorUnit}; #[derive( Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, FromSqlRow, AsExpression, ToSchema, @@ -163,7 +166,7 @@ pub struct PayoutLinkData { /// Payout currency pub currency: enums::Currency, /// A list of allowed domains regexes where the payout link can be embedded / opened from - pub allowed_domains: Option>, + pub allowed_domains: HashSet, } crate::impl_to_sql_from_sql_json!(PayoutLinkData); @@ -211,3 +214,29 @@ pub struct EnabledPaymentMethod { #[schema(value_type = HashSet)] pub payment_method_types: HashSet, } + +/// Util function for validating a domain without any wildcard characters. +pub fn validate_strict_domain(domain: &str) -> bool { + Regex::new(consts::STRICT_DOMAIN_REGEX) + .map(|regex| regex.is_match(domain)) + .map_err(|err| { + let err_msg = format!("Invalid regex found while checking host domain \"{}\" - {:?}", domain, err); + #[cfg(feature = "logs")] + logger::error!(err_msg); + err_msg + }) + .unwrap_or(false) +} + +/// Util function for validating a domain with "*" wildcard characters. +pub fn validate_wildcard_domain(domain: &str) -> bool { + Regex::new(consts::WILDCARD_DOMAIN_REGEX) + .map(|regex| regex.is_match(domain)) + .map_err(|err| { + let err_msg = format!("Invalid regex found while checking allowed domain \"{}\" - {:?}", domain, err); + #[cfg(feature = "logs")] + logger::error!(err_msg); + err_msg + }) + .unwrap_or(false) +} diff --git a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs index 782376519c29..00594ec4c695 100644 --- a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs +++ b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs @@ -231,6 +231,8 @@ pub enum ApiErrorResponse { MissingFilePurpose, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "File content type not found / valid")] MissingFileContentType, + #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "{message}")] + GenericConfigurationError { message: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_05", message = "{message}")] GenericNotFoundError { message: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_01", message = "{message}")] @@ -523,6 +525,9 @@ impl ErrorSwitch for ApiErrorRespon Self::AddressNotFound => { AER::NotFound(ApiError::new("HE", 4, "Address does not exist in our records", None)) }, + Self::GenericConfigurationError { message } => { + AER::BadRequest(ApiError::new("IR", 4, message, None)) + }, Self::GenericNotFoundError { message } => { AER::NotFound(ApiError::new("HE", 5, message, None)) }, diff --git a/crates/router/src/compatibility/stripe/errors.rs b/crates/router/src/compatibility/stripe/errors.rs index f72b71570a01..52bb399e828c 100644 --- a/crates/router/src/compatibility/stripe/errors.rs +++ b/crates/router/src/compatibility/stripe/errors.rs @@ -99,6 +99,9 @@ pub enum StripeErrorCode { #[error(error_type = StripeErrorType::InvalidRequestError, code = "resource_missing", message = "No such payment method")] PaymentMethodNotFound, + #[error(error_type = StripeErrorType::InvalidRequestError, code = "not_configured", message = "{message}")] + GenericConfigurationError { message: String }, + #[error(error_type = StripeErrorType::InvalidRequestError, code = "resource_missing", message = "{message}")] GenericNotFoundError { message: String }, @@ -461,6 +464,10 @@ impl From for StripeErrorCode { param: field_names.clone().join(", "), } } + + errors::ApiErrorResponse::GenericConfigurationError { message } => { + Self::GenericConfigurationError { message } + } errors::ApiErrorResponse::GenericNotFoundError { message } => { Self::GenericNotFoundError { message } } @@ -742,7 +749,8 @@ impl actix_web::ResponseError for StripeErrorCode { | Self::InvalidConnectorConfiguration { .. } | Self::CurrencyConversionFailed | Self::PaymentMethodDeleteFailed - | Self::ExtendedCardInfoNotFound => StatusCode::BAD_REQUEST, + | Self::ExtendedCardInfoNotFound + | Self::GenericConfigurationError { .. } => StatusCode::BAD_REQUEST, Self::RefundFailed | Self::PayoutFailed | Self::PaymentLinkNotFound diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index 854b7c71d84e..f314536019fb 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, str::FromStr}; +use std::str::FromStr; use api_models::{ admin::{self as admin_types}, @@ -15,7 +15,6 @@ use error_stack::{report, FutureExt, ResultExt}; use futures::future::try_join_all; use masking::{PeekInterface, Secret}; use pm_auth::connector::plaid::transformers::PlaidAuthType; -use regex::Regex; use router_env::metrics::add_attributes; use uuid::Uuid; @@ -1694,11 +1693,20 @@ pub async fn update_business_profile( .transpose()? .map(Secret::new); - request + let payout_link_config = request .payout_link_config - .clone() - .and_then(|config| config.config.allowed_domains) - .map_or(Ok(()), validate_allowed_domains_regex)?; + .as_ref() + .map(|payout_conf| match payout_conf.config.validate() { + Ok(_) => Encode::encode_to_value(payout_conf).change_context( + errors::ApiErrorResponse::InvalidDataValue { + field_name: "payout_link_config", + }, + ), + Err(e) => Err(report!(errors::ApiErrorResponse::InvalidRequestData { + message: e.to_string() + })), + }) + .transpose()?; let business_profile_update = storage::business_profile::BusinessProfileUpdate::Update { profile_name: request.profile_name, @@ -1728,14 +1736,7 @@ pub async fn update_business_profile( .change_context(errors::ApiErrorResponse::InvalidDataValue { field_name: "authentication_connector_details", })?, - payout_link_config: request - .payout_link_config - .as_ref() - .map(Encode::encode_to_value) - .transpose() - .change_context(errors::ApiErrorResponse::InvalidDataValue { - field_name: "payout_link_config", - })?, + payout_link_config, extended_card_info_config, use_billing_as_payment_method_billing: request.use_billing_as_payment_method_billing, collect_shipping_details_from_wallet_connector: request @@ -2282,24 +2283,3 @@ pub fn validate_status_and_disabled( Ok((connector_status, disabled)) } - -pub fn validate_allowed_domains_regex(allowed_domains: HashSet) -> RouterResult<()> { - let errors: Vec = allowed_domains - .into_iter() - .filter_map(|domain| { - Regex::new(&domain) - .err() - .map(|err| format!("Failed to parse regex `{}` - {err}", domain)) - }) - .collect(); - if errors.is_empty() { - Ok(()) - } else { - Err(report!(errors::ApiErrorResponse::InvalidRequestData { - message: format!( - "allowed_domains contain invalid domain regexes -\n{}", - errors.join(", ") - ) - })) - } -} 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 fc2916f4d1cc..731f3f76d0ac 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 @@ -14,7 +14,13 @@ try { // Remove the script from DOM incase it's not iframed if (!isFramed) { function initializePayoutSDK() { - alert("This page can only be viewed within trusted domains."); + var errMsg = "You are not allowed to view this content."; + var contentElement = document.getElementById("payout-link"); + if (contentElement instanceof HTMLDivElement) { + contentElement.innerHTML = errMsg; + } else { + document.body.innerHTML = errMsg; + } } // webpage is iframed, good to load diff --git a/crates/router/src/core/payout_link.rs b/crates/router/src/core/payout_link.rs index 4856f796d4ee..cfd966b0e0ee 100644 --- a/crates/router/src/core/payout_link.rs +++ b/crates/router/src/core/payout_link.rs @@ -63,7 +63,7 @@ pub async fn initiate_payout_link( message: "payout link not found".to_string(), })?; - validator::validate_payout_link_render_request(&state, request_headers, &payout_link)?; + validator::validate_payout_link_render_request(request_headers, &payout_link)?; // Check status and return form data accordingly let has_expired = common_utils::date_time::now() > payout_link.expiry; @@ -104,7 +104,7 @@ pub async fn initiate_payout_link( Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: link_data.allowed_domains, + allowed_domains: Some(link_data.allowed_domains), data: GenericLinksData::ExpiredLink(expired_link_data), }, ))) @@ -209,7 +209,7 @@ pub async fn initiate_payout_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: link_data.allowed_domains, + allowed_domains: Some(link_data.allowed_domains), data: GenericLinksData::PayoutLink(generic_form_data), }, ))) @@ -251,7 +251,7 @@ pub async fn initiate_payout_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: link_data.allowed_domains, + allowed_domains: Some(link_data.allowed_domains), data: GenericLinksData::PayoutLinkStatus(generic_status_data), }, ))) diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index 147d97806059..ff004faf852b 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -18,14 +18,13 @@ use error_stack::{report, ResultExt}; pub use hyperswitch_domain_models::errors::StorageError; use masking::Secret; use regex::Regex; -use router_env::{instrument, logger, tracing, Env}; +use router_env::{instrument, logger, tracing}; use time::Duration; use super::helpers; use crate::{ consts, core::{ - admin::validate_allowed_domains_regex, errors::{self, RouterResult, StorageErrorExt}, utils as core_utils, }, @@ -208,12 +207,6 @@ pub async fn create_payout_link( ) -> RouterResult { let payout_link_config_req = req.payout_link_config.to_owned(); - // Validate allowed domains in request - payout_link_config_req - .as_ref() - .and_then(|config| config.allowed_domains.clone()) - .map_or(Ok(()), validate_allowed_domains_regex)?; - // Fetch all configs let default_config = &state.conf.generic_link.payout_link; let profile_config = business_profile @@ -235,18 +228,14 @@ pub async fn create_payout_link( .or(profile_ui_config); // Validate allowed_domains presence - let req_allowed_domains = payout_link_config_req + let allowed_domains = profile_config .as_ref() - .and_then(|req| req.allowed_domains.to_owned()) - .or(profile_config - .as_ref() - .and_then(|config| config.config.allowed_domains.to_owned())); - - if matches!(state.conf.env, Env::Production) && req_allowed_domains.is_none() { - return Err(report!(errors::ApiErrorResponse::MissingRequiredField { - field_name: "allowed_domains" - })); - } + .map(|config| config.config.allowed_domains.to_owned()) + .get_required_value("allowed_domains") + .change_context(errors::ApiErrorResponse::GenericConfigurationError { + message: "Payout links cannot be used without setting allowed_domains in profile" + .to_string(), + })?; // Form data to be injected in the link let (logo, merchant_name, theme) = match ui_config { @@ -304,7 +293,7 @@ pub async fn create_payout_link( enabled_payment_methods: req_enabled_payment_methods, amount: MinorUnit::from(*amount), currency: *currency, - allowed_domains: req_allowed_domains, + allowed_domains, }; create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await @@ -344,34 +333,12 @@ pub async fn create_payout_link_db_entry( } pub fn validate_payout_link_render_request( - state: &SessionState, request_headers: &header::HeaderMap, payout_link: &PayoutLink, ) -> RouterResult<()> { let link_id = payout_link.link_id.to_owned(); let link_data = payout_link.link_data.to_owned(); - // Fetch allowed domains - let (allowed_domains, should_validate_request_headers) = match state.conf.env { - Env::Production => { - (link_data.allowed_domains - .ok_or_else(|| report!(errors::ApiErrorResponse::AccessForbidden { - resource: "payout_link".to_string(), - })) - .attach_printable_lazy(|| { - format!("Access to payout_link [{}] is forbidden without setting up allowed_domains for the link", link_id) - })?, true) - }, - _ => { - link_data.allowed_domains - .map_or((HashSet::from(["*".to_string()]), false), |allowed_domains| (allowed_domains, true)) - } - }; - - if !should_validate_request_headers { - return Ok(()); - } - // Fetch destination is "iframe" match request_headers.get("sec-fetch-dest").and_then(|v| v.to_str().ok()) { Some("iframe") => Ok(()), @@ -409,7 +376,7 @@ pub fn validate_payout_link_render_request( ) })?; - if is_domain_allowed(domain_in_req, allowed_domains) { + if is_domain_allowed(domain_in_req, link_data.allowed_domains) { Ok(()) } else { Err(report!(errors::ApiErrorResponse::AccessForbidden { diff --git a/crates/router/src/types/api/admin.rs b/crates/router/src/types/api/admin.rs index cb801b934e14..a75d27c6970c 100644 --- a/crates/router/src/types/api/admin.rs +++ b/crates/router/src/types/api/admin.rs @@ -7,7 +7,7 @@ pub use api_models::admin::{ ToggleKVResponse, WebhookDetails, }; use common_utils::ext_traits::{Encode, ValueExt}; -use error_stack::ResultExt; +use error_stack::{report, ResultExt}; use masking::{ExposeInterface, Secret}; use crate::{ @@ -146,6 +146,21 @@ impl ForeignTryFrom<(domain::MerchantAccount, BusinessProfileCreate)> }) .transpose()?; + let payout_link_config = request + .payout_link_config + .as_ref() + .map(|payout_conf| match payout_conf.config.validate() { + Ok(_) => Encode::encode_to_value(payout_conf).change_context( + errors::ApiErrorResponse::InvalidDataValue { + field_name: "payout_link_config", + }, + ), + Err(e) => Err(report!(errors::ApiErrorResponse::InvalidRequestData { + message: e.to_string() + })), + }) + .transpose()?; + Ok(Self { profile_id, merchant_id: merchant_account.merchant_id, @@ -198,14 +213,7 @@ impl ForeignTryFrom<(domain::MerchantAccount, BusinessProfileCreate)> .change_context(errors::ApiErrorResponse::InvalidDataValue { field_name: "authentication_connector_details", })?, - payout_link_config: request - .payout_link_config - .as_ref() - .map(Encode::encode_to_value) - .transpose() - .change_context(errors::ApiErrorResponse::InvalidDataValue { - field_name: "payout_link_config", - })?, + payout_link_config, is_connector_agnostic_mit_enabled: request.is_connector_agnostic_mit_enabled, is_extended_card_info_enabled: None, extended_card_info_config: None, From 247ae456fff46f05030943b06ae082d11d2f7a7a Mon Sep 17 00:00:00 2001 From: "hyperswitch-bot[bot]" <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Date: Fri, 12 Jul 2024 09:47:18 +0000 Subject: [PATCH 10/17] docs(openapi): re-generate OpenAPI specification --- api-reference/openapi_spec.json | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/api-reference/openapi_spec.json b/api-reference/openapi_spec.json index 220949ea3a13..daba32df990a 100644 --- a/api-reference/openapi_spec.json +++ b/api-reference/openapi_spec.json @@ -6809,6 +6809,9 @@ }, { "type": "object", + "required": [ + "allowed_domains" + ], "properties": { "domain_name": { "type": "string", @@ -6821,8 +6824,7 @@ "type": "string" }, "description": "A list of allowed domains regexes where this link can be embedded / opened from", - "uniqueItems": true, - "nullable": true + "uniqueItems": true } } } @@ -17690,15 +17692,6 @@ "description": "List of payout methods shown on collect UI", "example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]", "nullable": true - }, - "allowed_domains": { - "type": "array", - "items": { - "type": "string" - }, - "description": "A list of allowed domains regexes where the payout link can be embedded / opened from", - "uniqueItems": true, - "nullable": true } } } From 3a444cb46bccdca879c83b6f397864bb53ed0816 Mon Sep 17 00:00:00 2001 From: Kashif Date: Fri, 12 Jul 2024 15:48:22 +0530 Subject: [PATCH 11/17] refactor(links): remove x-frame-options as it's obsolete --- crates/router/src/services/api.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index aabdbb53956d..280fbd10a7db 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -1044,12 +1044,8 @@ where Ok(rendered_html) => { let headers = boxed_generic_link_data.allowed_domains.map(|domains| { let domains_str = domains.into_iter().collect::>().join(" "); - let csp_header = format!("frame-ancestors 'self' {}", domains_str); - let frame_header = format!("ALLOW-FROM {}", domains_str); - HashSet::from([ - ("content-security-policy", csp_header), - ("x-frame-options", frame_header), - ]) + let csp_header = format!("frame-ancestors 'self' {};", domains_str); + HashSet::from([("content-security-policy", csp_header)]) }); http_response_html_data(rendered_html, headers) } From 7f6377e805f78be08d2f363576e6c3cbe4ea43a6 Mon Sep 17 00:00:00 2001 From: Kashif Date: Tue, 16 Jul 2024 16:41:25 +0530 Subject: [PATCH 12/17] refactor(payout_link): enhance code by resolving comments --- Cargo.lock | 1 + crates/api_models/src/admin.rs | 4 +- crates/common_utils/src/consts.rs | 4 +- crates/common_utils/src/link_utils.rs | 12 +- crates/hyperswitch_domain_models/src/api.rs | 2 +- .../src/errors/api_error_response.rs | 4 +- crates/router/Cargo.toml | 1 + crates/router/src/core/admin.rs | 2 +- crates/router/src/core/payment_methods.rs | 9 +- crates/router/src/core/payout_link.rs | 6 +- crates/router/src/core/payouts.rs | 163 ++++++++++++++++-- crates/router/src/core/payouts/validator.rs | 163 +----------------- crates/router/src/services/api.rs | 14 +- crates/router/src/types/api/admin.rs | 2 +- 14 files changed, 189 insertions(+), 198 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68792185b1bb..c690b8aa3662 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6106,6 +6106,7 @@ dependencies = [ "events", "external_services", "futures 0.3.30", + "globset", "hex", "http 0.2.12", "hyper 0.14.28", diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index c7622c8fa890..61a44a5b4f2b 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -1332,7 +1332,7 @@ pub struct BusinessGenericLinkConfig { /// Custom domain name to be used for hosting the link pub domain_name: Option, - /// A list of allowed domains regexes where this link can be embedded / opened from + /// A list of allowed domains (glob patterns) where this link can be embedded / opened from pub allowed_domains: HashSet, #[serde(flatten)] @@ -1356,7 +1356,7 @@ impl BusinessGenericLinkConfig { .allowed_domains .clone() .iter() - .any(|allowed_domain| link_utils::validate_wildcard_domain(allowed_domain)); + .all(|allowed_domain| link_utils::validate_wildcard_domain(allowed_domain)); if !are_allowed_domains_valid { return Err("Invalid allowed domain names received"); } diff --git a/crates/common_utils/src/consts.rs b/crates/common_utils/src/consts.rs index 6e1d4614bd5b..2e13c2b121be 100644 --- a/crates/common_utils/src/consts.rs +++ b/crates/common_utils/src/consts.rs @@ -105,7 +105,7 @@ pub const MIN_REQUIRED_MERCHANT_REFERENCE_ID_LENGTH: u8 = 1; /// https://www.example.com /// www.example.com /// example.io -pub const STRICT_DOMAIN_REGEX: &str = r"^((http)s?:\/\/)?([A-Za-z0-9]{1,63}\.?)+([A-Za-z0-9-]{1,63}\.?)+([A-Za-z0-9]{1,63}\.?)+(\.[A-Za-z]{2,6}|:[0-9]{1,4})?$"; +pub const STRICT_DOMAIN_REGEX: &str = r"^((http)s?://)?([A-Za-z0-9]{1,63}\.?)+([A-Za-z0-9-]{1,63}\.?)+([A-Za-z0-9]{1,63}\.?)+(\.[A-Za-z]{2,6}|:[0-9]{1,4})?$"; /// Regex for matching a wildcard domain /// Eg - @@ -113,7 +113,7 @@ pub const STRICT_DOMAIN_REGEX: &str = r"^((http)s?:\/\/)?([A-Za-z0-9]{1,63}\.?)+ /// *.subdomain.domain.com /// *://example.com /// *example.com -pub const WILDCARD_DOMAIN_REGEX: &str = r"^((((http)s|\*)?:\/\/)?([A-Za-z0-9]{1,63}\.?)+[A-Za-z0-9-*]{1,63}\.?)+([A-Za-z0-9]{1,63}\.?)+(\.[A-Za-z]{2,6}|:[0-9*]{1,4})?$"; +pub const WILDCARD_DOMAIN_REGEX: &str = r"^((((http)s|\*)?://)?([A-Za-z0-9]{1,63}\.?)+[A-Za-z0-9-*]{1,63}\.?)+([A-Za-z0-9]{1,63}\.?)+(\.[A-Za-z]{2,6}|:[0-9*]{1,4})?$"; /// Maximum allowed length for MerchantName pub const MAX_ALLOWED_MERCHANT_NAME_LENGTH: usize = 64; diff --git a/crates/common_utils/src/link_utils.rs b/crates/common_utils/src/link_utils.rs index 0923df876938..b1f67369a2db 100644 --- a/crates/common_utils/src/link_utils.rs +++ b/crates/common_utils/src/link_utils.rs @@ -165,7 +165,7 @@ pub struct PayoutLinkData { pub amount: MinorUnit, /// Payout currency pub currency: enums::Currency, - /// A list of allowed domains regexes where the payout link can be embedded / opened from + /// A list of allowed domains (glob patterns) where this link can be embedded / opened from pub allowed_domains: HashSet, } @@ -220,10 +220,7 @@ pub fn validate_strict_domain(domain: &str) -> bool { Regex::new(consts::STRICT_DOMAIN_REGEX) .map(|regex| regex.is_match(domain)) .map_err(|err| { - let err_msg = format!( - "Invalid regex found while checking host domain \"{}\" - {:?}", - domain, err - ); + let err_msg = format!("Invalid strict domain regex: {err:?}"); #[cfg(feature = "logs")] logger::error!(err_msg); err_msg @@ -236,10 +233,7 @@ pub fn validate_wildcard_domain(domain: &str) -> bool { Regex::new(consts::WILDCARD_DOMAIN_REGEX) .map(|regex| regex.is_match(domain)) .map_err(|err| { - let err_msg = format!( - "Invalid regex found while checking allowed domain \"{}\" - {:?}", - domain, err - ); + let err_msg = format!("Invalid strict domain regex: {err:?}"); #[cfg(feature = "logs")] logger::error!(err_msg); err_msg diff --git a/crates/hyperswitch_domain_models/src/api.rs b/crates/hyperswitch_domain_models/src/api.rs index ae460b41e436..07d9337e4508 100644 --- a/crates/hyperswitch_domain_models/src/api.rs +++ b/crates/hyperswitch_domain_models/src/api.rs @@ -60,7 +60,7 @@ pub struct PaymentLinkStatusData { #[derive(Debug, Eq, PartialEq)] pub struct GenericLinks { - pub allowed_domains: Option>, + pub allowed_domains: HashSet, pub data: GenericLinksData, } diff --git a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs index 00594ec4c695..b0697b382102 100644 --- a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs +++ b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs @@ -231,7 +231,7 @@ pub enum ApiErrorResponse { MissingFilePurpose, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "File content type not found / valid")] MissingFileContentType, - #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "{message}")] + #[error(error_type = ErrorType::InvalidRequestError, code = "IR_28", message = "{message}")] GenericConfigurationError { message: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_05", message = "{message}")] GenericNotFoundError { message: String }, @@ -526,7 +526,7 @@ impl ErrorSwitch for ApiErrorRespon AER::NotFound(ApiError::new("HE", 4, "Address does not exist in our records", None)) }, Self::GenericConfigurationError { message } => { - AER::BadRequest(ApiError::new("IR", 4, message, None)) + AER::BadRequest(ApiError::new("IR", 28, message, None)) }, Self::GenericNotFoundError { message } => { AER::NotFound(ApiError::new("HE", 5, message, None)) diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index 4373c7fb1693..a993761ad9db 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -57,6 +57,7 @@ dyn-clone = "1.0.17" encoding_rs = "0.8.33" error-stack = "0.4.1" futures = "0.3.30" +globset = "0.4.14" hex = "0.4.3" http = "0.2.12" hyper = "0.14.28" diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index 3e04486d9ab3..a32f7b8b7e08 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -1983,7 +1983,7 @@ pub async fn update_business_profile( .payout_link_config .as_ref() .map(|payout_conf| match payout_conf.config.validate() { - Ok(_) => Encode::encode_to_value(payout_conf).change_context( + Ok(_) => payout_conf.encode_to_value().change_context( errors::ApiErrorResponse::InvalidDataValue { field_name: "payout_link_config", }, diff --git a/crates/router/src/core/payment_methods.rs b/crates/router/src/core/payment_methods.rs index 4d2ce1ef0421..3fff53fd2c8a 100644 --- a/crates/router/src/core/payment_methods.rs +++ b/crates/router/src/core/payment_methods.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; pub mod cards; pub mod surcharge_decision_configs; pub mod transformers; @@ -248,7 +249,7 @@ pub async fn render_pm_collect_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: None, + allowed_domains: HashSet::from([]), data: GenericLinksData::ExpiredLink(expired_link_data), }, ))) @@ -309,7 +310,8 @@ pub async fn render_pm_collect_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: None, + allowed_domains: HashSet::from([]), + data: GenericLinksData::PaymentMethodCollect(generic_form_data), }, ))) @@ -353,7 +355,8 @@ pub async fn render_pm_collect_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: None, + allowed_domains: HashSet::from([]), + data: GenericLinksData::PaymentMethodCollectStatus(generic_status_data), }, ))) diff --git a/crates/router/src/core/payout_link.rs b/crates/router/src/core/payout_link.rs index 9f06ac026fbb..7fca84dbccf3 100644 --- a/crates/router/src/core/payout_link.rs +++ b/crates/router/src/core/payout_link.rs @@ -106,7 +106,7 @@ pub async fn initiate_payout_link( Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: Some(link_data.allowed_domains), + allowed_domains: (link_data.allowed_domains), data: GenericLinksData::ExpiredLink(expired_link_data), }, ))) @@ -206,7 +206,7 @@ pub async fn initiate_payout_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: Some(link_data.allowed_domains), + allowed_domains: (link_data.allowed_domains), data: GenericLinksData::PayoutLink(generic_form_data), }, ))) @@ -248,7 +248,7 @@ pub async fn initiate_payout_link( }; Ok(services::ApplicationResponse::GenericLinkForm(Box::new( GenericLinks { - allowed_domains: Some(link_data.allowed_domains), + allowed_domains: (link_data.allowed_domains), data: GenericLinksData::PayoutLinkStatus(generic_status_data), }, ))) diff --git a/crates/router/src/core/payouts.rs b/crates/router/src/core/payouts.rs index 7d5dd908fdb0..a2fa494b74e8 100644 --- a/crates/router/src/core/payouts.rs +++ b/crates/router/src/core/payouts.rs @@ -5,22 +5,26 @@ pub mod retry; pub mod validator; use std::vec::IntoIter; -use api_models::{self, enums as api_enums, payouts::PayoutLinkResponse}; +use api_models::{self, admin, enums as api_enums, payouts::PayoutLinkResponse}; use common_utils::{ consts, crypto::Encryptable, ext_traits::{AsyncExt, ValueExt}, - link_utils::PayoutLinkStatus, + id_type::CustomerId, + link_utils::{GenericLinkStatus, GenericLinkUiConfig, PayoutLinkData, PayoutLinkStatus}, pii, types::MinorUnit, }; -use diesel_models::{enums as storage_enums, generic_link::PayoutLink}; +use diesel_models::{ + enums as storage_enums, + generic_link::{GenericLinkNew, PayoutLink}, +}; use error_stack::{report, ResultExt}; #[cfg(feature = "olap")] use futures::future::join_all; #[cfg(feature = "olap")] use hyperswitch_domain_models::errors::StorageError; -use masking::PeekInterface; +use masking::{PeekInterface, Secret}; #[cfg(feature = "payout_retry")] use retry::GsmValidation; #[cfg(feature = "olap")] @@ -28,17 +32,16 @@ use router_env::logger; use router_env::{instrument, tracing}; use scheduler::utils as pt_utils; use serde_json; +use time::Duration; -use super::{ - errors::{ConnectorErrorExt, StorageErrorExt}, - payments::customers, -}; #[cfg(feature = "olap")] use crate::types::domain::behaviour::Conversion; use crate::{ core::{ - errors::{self, CustomResult, RouterResponse, RouterResult}, - payments::{self, helpers as payment_helpers}, + errors::{ + self, ConnectorErrorExt, CustomResult, RouterResponse, RouterResult, StorageErrorExt, + }, + payments::{self, customers, helpers as payment_helpers}, utils as core_utils, }, db::StorageInterface, @@ -1082,7 +1085,7 @@ pub async fn create_recipient( add_external_account_addition_task( &*state.store, payout_data, - common_utils::date_time::now().saturating_add(time::Duration::seconds(consts::STRIPE_ACCOUNT_ONBOARDING_DELAY_IN_SECONDS)), + common_utils::date_time::now().saturating_add(Duration::seconds(consts::STRIPE_ACCOUNT_ONBOARDING_DELAY_IN_SECONDS)), ) .await .change_context(errors::ApiErrorResponse::InternalServerError) @@ -2005,7 +2008,7 @@ pub async fn payout_create_db_entries( let payout_link = match req.payout_link { Some(true) => Some( - validator::create_payout_link( + create_payout_link( state, &business_profile, &customer_id, @@ -2312,3 +2315,139 @@ async fn validate_and_get_business_profile( }) } } + +#[allow(clippy::too_many_arguments)] +pub async fn create_payout_link( + state: &SessionState, + business_profile: &storage::BusinessProfile, + customer_id: &CustomerId, + merchant_id: &String, + req: &payouts::PayoutCreateRequest, + payout_id: &String, +) -> RouterResult { + let payout_link_config_req = req.payout_link_config.to_owned(); + + // Fetch all configs + let default_config = &state.conf.generic_link.payout_link; + let profile_config = business_profile + .payout_link_config + .as_ref() + .map(|config| { + config + .clone() + .parse_value::("BusinessPayoutLinkConfig") + }) + .transpose() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "payout_link_config in business_profile", + })?; + let profile_ui_config = profile_config.as_ref().map(|c| c.config.ui_config.clone()); + let ui_config = payout_link_config_req + .as_ref() + .and_then(|config| config.ui_config.clone()) + .or(profile_ui_config); + + // Validate allowed_domains presence + let allowed_domains = profile_config + .as_ref() + .map(|config| config.config.allowed_domains.to_owned()) + .get_required_value("allowed_domains") + .change_context(errors::ApiErrorResponse::GenericConfigurationError { + message: "Payout links cannot be used without setting allowed_domains in profile" + .to_string(), + })?; + + // Form data to be injected in the link + let (logo, merchant_name, theme) = match ui_config { + Some(config) => (config.logo, config.merchant_name, config.theme), + _ => (None, None, None), + }; + let payout_link_config = GenericLinkUiConfig { + logo, + merchant_name, + theme, + }; + let client_secret = utils::generate_id(consts::ID_LENGTH, "payout_link_secret"); + let base_url = profile_config + .as_ref() + .and_then(|c| c.config.domain_name.as_ref()) + .map(|domain| format!("https://{}", domain)) + .unwrap_or(state.base_url.clone()); + let session_expiry = req + .session_expiry + .as_ref() + .map_or(default_config.expiry, |expiry| *expiry); + let url = format!("{base_url}/payout_link/{merchant_id}/{payout_id}"); + let link = url::Url::parse(&url) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable_lazy(|| format!("Failed to form payout link URL - {}", url))?; + let req_enabled_payment_methods = payout_link_config_req + .as_ref() + .and_then(|req| req.enabled_payment_methods.to_owned()); + let amount = req + .amount + .as_ref() + .get_required_value("amount") + .attach_printable("amount is a required value when creating payout links")?; + let currency = req + .currency + .as_ref() + .get_required_value("currency") + .attach_printable("currency is a required value when creating payout links")?; + let payout_link_id = core_utils::get_or_generate_id( + "payout_link_id", + &payout_link_config_req + .as_ref() + .and_then(|config| config.payout_link_id.clone()), + "payout_link", + )?; + + let data = PayoutLinkData { + payout_link_id: payout_link_id.clone(), + customer_id: customer_id.clone(), + payout_id: payout_id.to_string(), + link, + client_secret: Secret::new(client_secret), + session_expiry, + ui_config: payout_link_config, + enabled_payment_methods: req_enabled_payment_methods, + amount: MinorUnit::from(*amount), + currency: *currency, + allowed_domains, + }; + + create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await +} + +pub async fn create_payout_link_db_entry( + state: &SessionState, + merchant_id: &String, + payout_link_data: &PayoutLinkData, + return_url: Option, +) -> RouterResult { + let db: &dyn StorageInterface = &*state.store; + + let link_data = serde_json::to_value(payout_link_data) + .map_err(|_| report!(errors::ApiErrorResponse::InternalServerError)) + .attach_printable("Failed to convert PayoutLinkData to Value")?; + + let payout_link = GenericLinkNew { + link_id: payout_link_data.payout_link_id.to_string(), + primary_reference: payout_link_data.payout_id.to_string(), + merchant_id: merchant_id.to_string(), + link_type: common_enums::GenericLinkType::PayoutLink, + link_status: GenericLinkStatus::PayoutLink(PayoutLinkStatus::Initiated), + link_data, + url: payout_link_data.link.to_string().into(), + return_url, + expiry: common_utils::date_time::now() + + Duration::seconds(payout_link_data.session_expiry.into()), + ..Default::default() + }; + + db.insert_payout_link(payout_link) + .await + .to_duplicate_response(errors::ApiErrorResponse::GenericDuplicateError { + message: "payout link already exists".to_string(), + }) +} diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index ff004faf852b..583568f13283 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -1,37 +1,24 @@ use std::collections::HashSet; use actix_web::http::header; -use api_models::admin; #[cfg(feature = "olap")] use common_utils::errors::CustomResult; -use common_utils::{ - ext_traits::ValueExt, - id_type::CustomerId, - link_utils::{GenericLinkStatus, GenericLinkUiConfig, PayoutLinkData, PayoutLinkStatus}, - types::MinorUnit, -}; -use diesel_models::{ - business_profile::BusinessProfile, - generic_link::{GenericLinkNew, PayoutLink}, -}; +use diesel_models::generic_link::PayoutLink; use error_stack::{report, ResultExt}; +use globset::Glob; pub use hyperswitch_domain_models::errors::StorageError; -use masking::Secret; -use regex::Regex; use router_env::{instrument, logger, tracing}; -use time::Duration; use super::helpers; use crate::{ - consts, core::{ - errors::{self, RouterResult, StorageErrorExt}, + errors::{self, RouterResult}, utils as core_utils, }, db::StorageInterface, routes::SessionState, types::{api::payouts, domain, storage}, - utils::{self, OptionExt}, + utils, }; #[instrument(skip(db))] @@ -196,142 +183,6 @@ pub(super) fn validate_payout_list_request_for_joins( Ok(()) } -#[allow(clippy::too_many_arguments)] -pub async fn create_payout_link( - state: &SessionState, - business_profile: &BusinessProfile, - customer_id: &CustomerId, - merchant_id: &String, - req: &payouts::PayoutCreateRequest, - payout_id: &String, -) -> RouterResult { - let payout_link_config_req = req.payout_link_config.to_owned(); - - // Fetch all configs - let default_config = &state.conf.generic_link.payout_link; - let profile_config = business_profile - .payout_link_config - .as_ref() - .map(|config| { - config - .clone() - .parse_value::("BusinessPayoutLinkConfig") - }) - .transpose() - .change_context(errors::ApiErrorResponse::InvalidDataValue { - field_name: "payout_link_config in business_profile", - })?; - let profile_ui_config = profile_config.as_ref().map(|c| c.config.ui_config.clone()); - let ui_config = payout_link_config_req - .as_ref() - .and_then(|config| config.ui_config.clone()) - .or(profile_ui_config); - - // Validate allowed_domains presence - let allowed_domains = profile_config - .as_ref() - .map(|config| config.config.allowed_domains.to_owned()) - .get_required_value("allowed_domains") - .change_context(errors::ApiErrorResponse::GenericConfigurationError { - message: "Payout links cannot be used without setting allowed_domains in profile" - .to_string(), - })?; - - // Form data to be injected in the link - let (logo, merchant_name, theme) = match ui_config { - Some(config) => (config.logo, config.merchant_name, config.theme), - _ => (None, None, None), - }; - let payout_link_config = GenericLinkUiConfig { - logo, - merchant_name, - theme, - }; - let client_secret = utils::generate_id(consts::ID_LENGTH, "payout_link_secret"); - let base_url = profile_config - .as_ref() - .and_then(|c| c.config.domain_name.as_ref()) - .map(|domain| format!("https://{}", domain)) - .unwrap_or(state.base_url.clone()); - let session_expiry = req - .session_expiry - .as_ref() - .map_or(default_config.expiry, |expiry| *expiry); - let url = format!("{base_url}/payout_link/{merchant_id}/{payout_id}"); - let link = url::Url::parse(&url) - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable_lazy(|| format!("Failed to form payout link URL - {}", url))?; - let req_enabled_payment_methods = payout_link_config_req - .as_ref() - .and_then(|req| req.enabled_payment_methods.to_owned()); - let amount = req - .amount - .as_ref() - .get_required_value("amount") - .attach_printable("amount is a required value when creating payout links")?; - let currency = req - .currency - .as_ref() - .get_required_value("currency") - .attach_printable("currency is a required value when creating payout links")?; - let payout_link_id = core_utils::get_or_generate_id( - "payout_link_id", - &payout_link_config_req - .as_ref() - .and_then(|config| config.payout_link_id.clone()), - "payout_link", - )?; - - let data = PayoutLinkData { - payout_link_id: payout_link_id.clone(), - customer_id: customer_id.clone(), - payout_id: payout_id.to_string(), - link, - client_secret: Secret::new(client_secret), - session_expiry, - ui_config: payout_link_config, - enabled_payment_methods: req_enabled_payment_methods, - amount: MinorUnit::from(*amount), - currency: *currency, - allowed_domains, - }; - - create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await -} - -pub async fn create_payout_link_db_entry( - state: &SessionState, - merchant_id: &String, - payout_link_data: &PayoutLinkData, - return_url: Option, -) -> RouterResult { - let db: &dyn StorageInterface = &*state.store; - - let link_data = serde_json::to_value(payout_link_data) - .map_err(|_| report!(errors::ApiErrorResponse::InternalServerError)) - .attach_printable("Failed to convert PayoutLinkData to Value")?; - - let payout_link = GenericLinkNew { - link_id: payout_link_data.payout_link_id.to_string(), - primary_reference: payout_link_data.payout_id.to_string(), - merchant_id: merchant_id.to_string(), - link_type: common_enums::GenericLinkType::PayoutLink, - link_status: GenericLinkStatus::PayoutLink(PayoutLinkStatus::Initiated), - link_data, - url: payout_link_data.link.to_string().into(), - return_url, - expiry: common_utils::date_time::now() - + Duration::seconds(payout_link_data.session_expiry.into()), - ..Default::default() - }; - - db.insert_payout_link(payout_link) - .await - .to_duplicate_response(errors::ApiErrorResponse::GenericDuplicateError { - message: "payout link already exists".to_string(), - }) -} - pub fn validate_payout_link_render_request( request_headers: &header::HeaderMap, payout_link: &PayoutLink, @@ -393,9 +244,9 @@ pub fn validate_payout_link_render_request( fn is_domain_allowed(domain: &str, allowed_domains: HashSet) -> bool { allowed_domains.iter().any(|allowed_domain| { - Regex::new(allowed_domain) - .map(|regex| regex.is_match(domain)) - .map_err(|err| logger::error!("Invalid regex! - {:?}", err)) + Glob::new(allowed_domain) + .map(|glob| glob.compile_matcher().is_match(domain)) + .map_err(|err| logger::error!("Invalid glob pattern! - {:?}", err)) .unwrap_or(false) }) } diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index 280fbd10a7db..a64184264da7 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -1042,12 +1042,14 @@ where let link_type = boxed_generic_link_data.data.to_string(); match build_generic_link_html(boxed_generic_link_data.data) { Ok(rendered_html) => { - let headers = boxed_generic_link_data.allowed_domains.map(|domains| { - let domains_str = domains.into_iter().collect::>().join(" "); - let csp_header = format!("frame-ancestors 'self' {};", domains_str); - HashSet::from([("content-security-policy", csp_header)]) - }); - http_response_html_data(rendered_html, headers) + let domains_str = boxed_generic_link_data + .allowed_domains + .into_iter() + .collect::>() + .join(" "); + let csp_header = format!("frame-ancestors 'self' {};", domains_str); + let headers = HashSet::from([("content-security-policy", csp_header)]); + http_response_html_data(rendered_html, Some(headers)) } Err(_) => { http_response_err(format!("Error while rendering {} HTML page", link_type)) diff --git a/crates/router/src/types/api/admin.rs b/crates/router/src/types/api/admin.rs index 55f7f71e9847..229494b1fd87 100644 --- a/crates/router/src/types/api/admin.rs +++ b/crates/router/src/types/api/admin.rs @@ -175,7 +175,7 @@ impl ForeignTryFrom<(domain::MerchantAccount, BusinessProfileCreate)> .payout_link_config .as_ref() .map(|payout_conf| match payout_conf.config.validate() { - Ok(_) => Encode::encode_to_value(payout_conf).change_context( + Ok(_) => payout_conf.encode_to_value().change_context( errors::ApiErrorResponse::InvalidDataValue { field_name: "payout_link_config", }, From fc0440c1ca391ab6ae87808d8486d17af4b27dd6 Mon Sep 17 00:00:00 2001 From: Kashif Date: Tue, 16 Jul 2024 16:42:45 +0530 Subject: [PATCH 13/17] refactor(docs): regenerate openAPI specs --- api-reference/openapi_spec.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-reference/openapi_spec.json b/api-reference/openapi_spec.json index daba32df990a..d7f559178338 100644 --- a/api-reference/openapi_spec.json +++ b/api-reference/openapi_spec.json @@ -6823,7 +6823,7 @@ "items": { "type": "string" }, - "description": "A list of allowed domains regexes where this link can be embedded / opened from", + "description": "A list of allowed domains (glob patterns) where this link can be embedded / opened from", "uniqueItems": true } } From 8ef79fe6d98319c6b269e21b109bffa5fc65b79b Mon Sep 17 00:00:00 2001 From: Kashif Date: Wed, 17 Jul 2024 13:57:26 +0530 Subject: [PATCH 14/17] chore(links): add unit test cases for domain regexes chore(links): add migration query for payout links --- crates/common_utils/src/consts.rs | 4 +- crates/common_utils/src/link_utils.rs | 111 ++++++++++++++++++ .../src/errors/api_error_response.rs | 4 +- .../router/src/compatibility/stripe/errors.rs | 8 +- crates/router/src/core/payouts.rs | 2 +- .../down.sql | 3 + .../up.sql | 5 + 7 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql create mode 100644 migrations/2024-07-17-064610_add_allowed_domains_to_link_data/up.sql diff --git a/crates/common_utils/src/consts.rs b/crates/common_utils/src/consts.rs index 2e13c2b121be..848189cd8995 100644 --- a/crates/common_utils/src/consts.rs +++ b/crates/common_utils/src/consts.rs @@ -105,7 +105,7 @@ pub const MIN_REQUIRED_MERCHANT_REFERENCE_ID_LENGTH: u8 = 1; /// https://www.example.com /// www.example.com /// example.io -pub const STRICT_DOMAIN_REGEX: &str = r"^((http)s?://)?([A-Za-z0-9]{1,63}\.?)+([A-Za-z0-9-]{1,63}\.?)+([A-Za-z0-9]{1,63}\.?)+(\.[A-Za-z]{2,6}|:[0-9]{1,4})?$"; +pub const STRICT_DOMAIN_REGEX: &str = r"^(https?://)?(([A-Za-z0-9][-A-Za-z0-9]\.)*[A-Za-z0-9][-A-Za-z0-9]*|(\d{1,3}\.){3}\d{1,3})+(:[0-9]{2,4})?$"; /// Regex for matching a wildcard domain /// Eg - @@ -113,7 +113,7 @@ pub const STRICT_DOMAIN_REGEX: &str = r"^((http)s?://)?([A-Za-z0-9]{1,63}\.?)+([ /// *.subdomain.domain.com /// *://example.com /// *example.com -pub const WILDCARD_DOMAIN_REGEX: &str = r"^((((http)s|\*)?://)?([A-Za-z0-9]{1,63}\.?)+[A-Za-z0-9-*]{1,63}\.?)+([A-Za-z0-9]{1,63}\.?)+(\.[A-Za-z]{2,6}|:[0-9*]{1,4})?$"; +pub const WILDCARD_DOMAIN_REGEX: &str = r"^((\*|https?)?://)?((\*\.|[A-Za-z0-9][-A-Za-z0-9]*\.)*[A-Za-z0-9][-A-Za-z0-9]*|((\d{1,3}|\*)\.){3}(\d{1,3}|\*)|\*)(:\*|:[0-9]{2,4})?(/\*)?$"; /// Maximum allowed length for MerchantName pub const MAX_ALLOWED_MERCHANT_NAME_LENGTH: usize = 64; diff --git a/crates/common_utils/src/link_utils.rs b/crates/common_utils/src/link_utils.rs index b1f67369a2db..e95832eeba9b 100644 --- a/crates/common_utils/src/link_utils.rs +++ b/crates/common_utils/src/link_utils.rs @@ -240,3 +240,114 @@ pub fn validate_wildcard_domain(domain: &str) -> bool { }) .unwrap_or(false) } + +#[cfg(test)] +mod domain_tests { + use regex::Regex; + + use super::*; + + #[test] + fn test_validate_strict_domain_regex() { + assert!( + Regex::new(consts::STRICT_DOMAIN_REGEX).is_ok(), + "Strict domain regex is invalid" + ); + } + + #[test] + fn test_validate_wildcard_domain_regex() { + assert!( + Regex::new(consts::WILDCARD_DOMAIN_REGEX).is_ok(), + "Wildcard domain regex is invalid" + ); + } + + #[test] + fn test_validate_strict_domain() { + let valid_domains = vec![ + "example.com", + "example.subdomain.com", + "https://example.com:8080", + "http://example.com", + "example.com:8080", + "example.com:443", + "localhost:443", + "127.0.0.1:443", + ]; + + for domain in valid_domains { + assert!( + validate_strict_domain(domain), + "Could not validate strict domain: {}", + domain + ); + } + + let invalid_domains = vec![ + "", + "invalid.domain.", + "not_a_domain", + "http://example.com/path?query=1#fragment", + "127.0.0.1.2:443", + ]; + + for domain in invalid_domains { + assert!( + !validate_strict_domain(domain), + "Could not validate invalid strict domain: {}", + domain + ); + } + } + + #[test] + fn test_validate_wildcard_domain() { + let valid_domains = vec![ + "example.com", + "example.subdomain.com", + "https://example.com:8080", + "http://example.com", + "example.com:8080", + "example.com:443", + "localhost:443", + "127.0.0.1:443", + "*.com", + "example.*.com", + "example.com:*", + "*:443", + "localhost:*", + "127.0.0.*:*", + "*:*", + ]; + + for domain in valid_domains { + assert!( + validate_wildcard_domain(domain), + "Could not validate wildcard domain: {}", + domain + ); + } + + let invalid_domains = vec![ + "", + "invalid.domain.", + "not_a_domain", + "http://example.com/path?query=1#fragment", + "*.", + ".*", + "example.com:*:", + "*:443:", + ":localhost:*", + "127.00.*:*", + ]; + + for domain in invalid_domains { + assert!( + !validate_wildcard_domain(domain), + "Could not validate invalid wildcard domain: {}", + domain + ); + } + } +} diff --git a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs index b0697b382102..fed781ad0464 100644 --- a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs +++ b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs @@ -232,7 +232,7 @@ pub enum ApiErrorResponse { #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "File content type not found / valid")] MissingFileContentType, #[error(error_type = ErrorType::InvalidRequestError, code = "IR_28", message = "{message}")] - GenericConfigurationError { message: String }, + LinkConfigurationError { message: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_05", message = "{message}")] GenericNotFoundError { message: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_01", message = "{message}")] @@ -525,7 +525,7 @@ impl ErrorSwitch for ApiErrorRespon Self::AddressNotFound => { AER::NotFound(ApiError::new("HE", 4, "Address does not exist in our records", None)) }, - Self::GenericConfigurationError { message } => { + Self::LinkConfigurationError { message } => { AER::BadRequest(ApiError::new("IR", 28, message, None)) }, Self::GenericNotFoundError { message } => { diff --git a/crates/router/src/compatibility/stripe/errors.rs b/crates/router/src/compatibility/stripe/errors.rs index 52bb399e828c..effbd4618f8a 100644 --- a/crates/router/src/compatibility/stripe/errors.rs +++ b/crates/router/src/compatibility/stripe/errors.rs @@ -100,7 +100,7 @@ pub enum StripeErrorCode { PaymentMethodNotFound, #[error(error_type = StripeErrorType::InvalidRequestError, code = "not_configured", message = "{message}")] - GenericConfigurationError { message: String }, + LinkConfigurationError { message: String }, #[error(error_type = StripeErrorType::InvalidRequestError, code = "resource_missing", message = "{message}")] GenericNotFoundError { message: String }, @@ -465,8 +465,8 @@ impl From for StripeErrorCode { } } - errors::ApiErrorResponse::GenericConfigurationError { message } => { - Self::GenericConfigurationError { message } + errors::ApiErrorResponse::LinkConfigurationError { message } => { + Self::LinkConfigurationError { message } } errors::ApiErrorResponse::GenericNotFoundError { message } => { Self::GenericNotFoundError { message } @@ -750,7 +750,7 @@ impl actix_web::ResponseError for StripeErrorCode { | Self::CurrencyConversionFailed | Self::PaymentMethodDeleteFailed | Self::ExtendedCardInfoNotFound - | Self::GenericConfigurationError { .. } => StatusCode::BAD_REQUEST, + | Self::LinkConfigurationError { .. } => StatusCode::BAD_REQUEST, Self::RefundFailed | Self::PayoutFailed | Self::PaymentLinkNotFound diff --git a/crates/router/src/core/payouts.rs b/crates/router/src/core/payouts.rs index a2fa494b74e8..a1b72d04f686 100644 --- a/crates/router/src/core/payouts.rs +++ b/crates/router/src/core/payouts.rs @@ -2352,7 +2352,7 @@ pub async fn create_payout_link( .as_ref() .map(|config| config.config.allowed_domains.to_owned()) .get_required_value("allowed_domains") - .change_context(errors::ApiErrorResponse::GenericConfigurationError { + .change_context(errors::ApiErrorResponse::LinkConfigurationError { message: "Payout links cannot be used without setting allowed_domains in profile" .to_string(), })?; diff --git a/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql new file mode 100644 index 000000000000..e2ba625dcc9d --- /dev/null +++ b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql @@ -0,0 +1,3 @@ +UPDATE generic_link +SET link_data = link_data - 'allowed_domains' +WHERE link_data -> 'allowed_domains' = '["*"]'::jsonb; \ No newline at end of file diff --git a/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/up.sql b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/up.sql new file mode 100644 index 000000000000..affa2755d7ed --- /dev/null +++ b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/up.sql @@ -0,0 +1,5 @@ +UPDATE generic_link +SET link_data = jsonb_set(link_data, '{allowed_domains}', '["*"]'::jsonb) +WHERE + NOT link_data ? 'allowed_domains' + AND link_type = 'payout_link'; \ No newline at end of file From a08ba9c24c2abe9cf590de0de94e2583d84b95a0 Mon Sep 17 00:00:00 2001 From: Kashif Date: Wed, 17 Jul 2024 14:08:16 +0530 Subject: [PATCH 15/17] chore(links): update migration down query --- .../2024-07-17-064610_add_allowed_domains_to_link_data/down.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql index e2ba625dcc9d..623bec2a2f0f 100644 --- a/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql +++ b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql @@ -1,3 +1,3 @@ UPDATE generic_link SET link_data = link_data - 'allowed_domains' -WHERE link_data -> 'allowed_domains' = '["*"]'::jsonb; \ No newline at end of file +WHERE link_data -> 'allowed_domains' = '["*"]'::jsonb AND link_type = 'payout_link'; \ No newline at end of file From 05aa9487dfbbb6d333e7f2c180d31be9bdb92ef7 Mon Sep 17 00:00:00 2001 From: Kashif Date: Wed, 17 Jul 2024 15:09:00 +0530 Subject: [PATCH 16/17] refactor(payout_link): update domain validation for payout link's render request --- crates/router/src/core/payouts/validator.rs | 54 ++++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index 583568f13283..a1332181f6de 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -8,6 +8,7 @@ use error_stack::{report, ResultExt}; use globset::Glob; pub use hyperswitch_domain_models::errors::StorageError; use router_env::{instrument, logger, tracing}; +use url::Url; use super::helpers; use crate::{ @@ -214,20 +215,47 @@ pub fn validate_payout_link_render_request( }?; // Validate origin / referer - let domain_in_req = 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 both origin and referer headers are missing from the request headers", - link_id - ) - })?; + 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 + ) + })?; + + 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 is_domain_allowed(domain_in_req, link_data.allowed_domains) { + if is_domain_allowed(&domain_in_req, link_data.allowed_domains) { Ok(()) } else { Err(report!(errors::ApiErrorResponse::AccessForbidden { From 74689ebac147da9bae6b5d4077072028f5e17f27 Mon Sep 17 00:00:00 2001 From: Kashif Date: Wed, 17 Jul 2024 15:41:57 +0530 Subject: [PATCH 17/17] refactor(payout_link): reorder LinkConfigurationError --- .../src/errors/api_error_response.rs | 10 +++++----- crates/router/src/compatibility/stripe/errors.rs | 12 +++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs index fed781ad0464..e660850a9839 100644 --- a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs +++ b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs @@ -231,8 +231,6 @@ pub enum ApiErrorResponse { MissingFilePurpose, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "File content type not found / valid")] MissingFileContentType, - #[error(error_type = ErrorType::InvalidRequestError, code = "IR_28", message = "{message}")] - LinkConfigurationError { message: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_05", message = "{message}")] GenericNotFoundError { message: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_01", message = "{message}")] @@ -273,6 +271,8 @@ pub enum ApiErrorResponse { InvalidCookie, #[error(error_type = ErrorType::InvalidRequestError, code = "IR_27", message = "Extended card info does not exist")] ExtendedCardInfoNotFound, + #[error(error_type = ErrorType::InvalidRequestError, code = "IR_28", message = "{message}")] + LinkConfigurationError { message: String }, #[error(error_type = ErrorType::ServerNotAvailable, code = "IE", message = "{reason} as data mismatched for {field_names}", ignore = "status_code")] IntegrityCheckFailed { reason: String, @@ -525,9 +525,6 @@ impl ErrorSwitch for ApiErrorRespon Self::AddressNotFound => { AER::NotFound(ApiError::new("HE", 4, "Address does not exist in our records", None)) }, - Self::LinkConfigurationError { message } => { - AER::BadRequest(ApiError::new("IR", 28, message, None)) - }, Self::GenericNotFoundError { message } => { AER::NotFound(ApiError::new("HE", 5, message, None)) }, @@ -620,6 +617,9 @@ impl ErrorSwitch for ApiErrorRespon Self::ExtendedCardInfoNotFound => { AER::NotFound(ApiError::new("IR", 27, "Extended card info does not exist", None)) } + Self::LinkConfigurationError { message } => { + AER::BadRequest(ApiError::new("IR", 28, message, None)) + }, Self::IntegrityCheckFailed { reason, field_names, diff --git a/crates/router/src/compatibility/stripe/errors.rs b/crates/router/src/compatibility/stripe/errors.rs index effbd4618f8a..e0a9b6c8f87a 100644 --- a/crates/router/src/compatibility/stripe/errors.rs +++ b/crates/router/src/compatibility/stripe/errors.rs @@ -99,9 +99,6 @@ pub enum StripeErrorCode { #[error(error_type = StripeErrorType::InvalidRequestError, code = "resource_missing", message = "No such payment method")] PaymentMethodNotFound, - #[error(error_type = StripeErrorType::InvalidRequestError, code = "not_configured", message = "{message}")] - LinkConfigurationError { message: String }, - #[error(error_type = StripeErrorType::InvalidRequestError, code = "resource_missing", message = "{message}")] GenericNotFoundError { message: String }, @@ -267,6 +264,8 @@ pub enum StripeErrorCode { PaymentMethodDeleteFailed, #[error(error_type = StripeErrorType::InvalidRequestError, code = "", message = "Extended card info does not exist")] ExtendedCardInfoNotFound, + #[error(error_type = StripeErrorType::InvalidRequestError, code = "not_configured", message = "{message}")] + LinkConfigurationError { message: String }, #[error(error_type = StripeErrorType::ConnectorError, code = "CE", message = "{reason} as data mismatched for {field_names}")] IntegrityCheckFailed { reason: String, @@ -464,10 +463,6 @@ impl From for StripeErrorCode { param: field_names.clone().join(", "), } } - - errors::ApiErrorResponse::LinkConfigurationError { message } => { - Self::LinkConfigurationError { message } - } errors::ApiErrorResponse::GenericNotFoundError { message } => { Self::GenericNotFoundError { message } } @@ -663,6 +658,9 @@ impl From for StripeErrorCode { Self::InvalidWalletToken { wallet_name } } errors::ApiErrorResponse::ExtendedCardInfoNotFound => Self::ExtendedCardInfoNotFound, + errors::ApiErrorResponse::LinkConfigurationError { message } => { + Self::LinkConfigurationError { message } + } errors::ApiErrorResponse::IntegrityCheckFailed { reason, field_names,