diff --git a/Cargo.lock b/Cargo.lock index b29894b49c..0188b3d6f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6129,6 +6129,7 @@ dependencies = [ "events", "external_services", "futures 0.3.30", + "globset", "hex", "http 0.2.12", "hyper 0.14.28", diff --git a/api-reference/openapi_spec.json b/api-reference/openapi_spec.json index 12b3d2a05f..0014042b23 100644 --- a/api-reference/openapi_spec.json +++ b/api-reference/openapi_spec.json @@ -6824,11 +6824,22 @@ }, { "type": "object", + "required": [ + "allowed_domains" + ], "properties": { "domain_name": { "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 (glob patterns) where this link can be embedded / opened from", + "uniqueItems": true } } } diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index 27bd098953..b22e028639 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}; #[cfg(feature = "v2")] use common_utils::new_type; @@ -1390,11 +1390,39 @@ pub struct BusinessGenericLinkConfig { /// Custom domain name to be used for hosting the link pub domain_name: Option, + /// A list of allowed domains (glob patterns) where this link can be embedded / opened from + 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() + .all(|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/common_utils/src/consts.rs b/crates/common_utils/src/consts.rs index a9229c7402..848189cd89 100644 --- a/crates/common_utils/src/consts.rs +++ b/crates/common_utils/src/consts.rs @@ -99,5 +99,21 @@ 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"^(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 - +/// *.example.com +/// *.subdomain.domain.com +/// *://example.com +/// *example.com +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 2960209dfc..e95832eeba 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; +#[cfg(feature = "logs")] +use router_env::logger; use serde::Serialize; 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, @@ -162,6 +165,8 @@ pub struct PayoutLinkData { pub amount: MinorUnit, /// Payout currency pub currency: enums::Currency, + /// A list of allowed domains (glob patterns) where this link can be embedded / opened from + pub allowed_domains: HashSet, } crate::impl_to_sql_from_sql_json!(PayoutLinkData); @@ -209,3 +214,140 @@ 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 strict domain regex: {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 strict domain regex: {err:?}"); + #[cfg(feature = "logs")] + logger::error!(err_msg); + err_msg + }) + .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/api.rs b/crates/hyperswitch_domain_models/src/api.rs index bb768d21dd..07d9337e45 100644 --- a/crates/hyperswitch_domain_models/src/api.rs +++ b/crates/hyperswitch_domain_models/src/api.rs @@ -1,4 +1,4 @@ -use std::fmt::Display; +use std::{collections::HashSet, fmt::Display}; use common_utils::{ events::{ApiEventMetric, ApiEventsType}, @@ -59,7 +59,13 @@ pub struct PaymentLinkStatusData { } #[derive(Debug, Eq, PartialEq)] -pub enum GenericLinks { +pub struct GenericLinks { + pub allowed_domains: HashSet, + pub data: GenericLinksData, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum GenericLinksData { ExpiredLink(GenericExpiredLinkData), PaymentMethodCollect(GenericLinkFormData), PayoutLink(GenericLinkFormData), @@ -67,12 +73,12 @@ pub enum GenericLinks { PaymentMethodCollectStatus(GenericLinkStatusData), } -impl Display for GenericLinks { +impl Display for GenericLinksData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", - match self { + match *self { Self::ExpiredLink(_) => "ExpiredLink", Self::PaymentMethodCollect(_) => "PaymentMethodCollect", Self::PayoutLink(_) => "PayoutLink", 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 782376519c..e660850a98 100644 --- a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs +++ b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs @@ -271,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, @@ -615,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/Cargo.toml b/crates/router/Cargo.toml index 108b035ec0..1212869600 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -58,6 +58,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/compatibility/stripe/errors.rs b/crates/router/src/compatibility/stripe/errors.rs index f72b71570a..e0a9b6c8f8 100644 --- a/crates/router/src/compatibility/stripe/errors.rs +++ b/crates/router/src/compatibility/stripe/errors.rs @@ -264,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, @@ -656,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, @@ -742,7 +747,8 @@ impl actix_web::ResponseError for StripeErrorCode { | Self::InvalidConnectorConfiguration { .. } | Self::CurrencyConversionFailed | Self::PaymentMethodDeleteFailed - | Self::ExtendedCardInfoNotFound => StatusCode::BAD_REQUEST, + | Self::ExtendedCardInfoNotFound + | Self::LinkConfigurationError { .. } => StatusCode::BAD_REQUEST, Self::RefundFailed | Self::PayoutFailed | Self::PaymentLinkNotFound diff --git a/crates/router/src/compatibility/wrap.rs b/crates/router/src/compatibility/wrap.rs index 7c1bd7e671..9ed03c6d2c 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 d8eb140066..af6d469fd3 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -2061,6 +2061,21 @@ pub async fn update_business_profile( .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Unable to encrypt outgoing webhook custom HTTP headers")?; + let payout_link_config = request + .payout_link_config + .as_ref() + .map(|payout_conf| match payout_conf.config.validate() { + Ok(_) => payout_conf.encode_to_value().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, modified_at: Some(date_time::now()), @@ -2089,14 +2104,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 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 a8050e90d6..731f3f76d0 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,171 @@ // @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", - ]; - - 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(); - - // @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; + // If parent's window object is restricted, DOMException is + // thrown which concludes that the webpage is iframed +} catch (err) { + isFramed = true; } -/** - * Trigger - init - * Uses - * - Initialize SDK - * - Update document's icon - */ -function boot() { - // Initialize SDK - // @ts-ignore - if (window.Hyper) { - initializePayoutSDK(); +// Remove the script from DOM incase it's not iframed +if (!isFramed) { + function initializePayoutSDK() { + 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; + } } - // @ts-ignore - var payoutDetails = window.__PAYOUT_DETAILS; + // 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", + ]; - // 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(); - -/** - * 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, - }); - - // 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"); + 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(); + + // @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; + } + + /** + * 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); + } + } + 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 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); + + // Mount + if (payoutWidget !== null) { + payoutWidget.mount("#payout-link"); + } } } 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 cf2d89b6a3..d566855312 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 00b51b9f09..b9e21b616c 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 migration; pub mod surcharge_decision_configs; @@ -13,7 +14,10 @@ use diesel_models::{ enums, GenericLinkNew, PaymentMethodCollectLink, PaymentMethodCollectLinkData, }; use error_stack::{report, ResultExt}; -use hyperswitch_domain_models::payments::{payment_attempt::PaymentAttempt, PaymentIntent}; +use hyperswitch_domain_models::{ + api::{GenericLinks, GenericLinksData}, + payments::{payment_attempt::PaymentAttempt, PaymentIntent}, +}; use masking::PeekInterface; use router_env::{instrument, tracing}; use time::Duration; @@ -27,7 +31,7 @@ use crate::{ pm_auth as core_pm_auth, }, routes::{app::StorageInterface, SessionState}, - services::{self, GenericLinks}, + services, types::{ api::{self, payments}, domain, storage, @@ -246,7 +250,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: HashSet::from([]), + data: GenericLinksData::ExpiredLink(expired_link_data), + }, ))) // else, send back form link @@ -304,7 +311,11 @@ 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: HashSet::from([]), + + data: GenericLinksData::PaymentMethodCollect(generic_form_data), + }, ))) } } @@ -345,7 +356,11 @@ 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: 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 e91a55e860..3de8e50549 100644 --- a/crates/router/src/core/payout_link.rs +++ b/crates/router/src/core/payout_link.rs @@ -1,5 +1,9 @@ -use std::collections::{HashMap, HashSet}; +use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, +}; +use actix_web::http::header; use api_models::payouts; use common_utils::{ ext_traits::{Encode, OptionExt}, @@ -8,14 +12,15 @@ use common_utils::{ }; use diesel_models::PayoutLinkUpdate; use error_stack::ResultExt; +use hyperswitch_domain_models::api::{GenericLinks, GenericLinksData}; use super::errors::{RouterResponse, StorageErrorExt}; use crate::{ configs::settings::{PaymentMethodFilterKey, PaymentMethodFilters}, - core::payments::helpers, + core::{payments::helpers, payouts::validator}, errors, routes::{app::StorageInterface, SessionState}, - services::{self, GenericLinks}, + services, types::domain, }; @@ -24,6 +29,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; @@ -59,6 +65,8 @@ pub async fn initiate_payout_link( message: "payout link not found".to_string(), })?; + 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; let status = payout_link.link_status.clone(); @@ -97,7 +105,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), + }, ))) } @@ -146,9 +157,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: masking::Secret::new(merchant_account.publishable_key), client_secret: link_data.client_secret.clone(), @@ -186,7 +205,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), + }, ))) } @@ -225,7 +247,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.rs b/crates/router/src/core/payouts.rs index 848d57bce8..e36036eb79 100644 --- a/crates/router/src/core/payouts.rs +++ b/crates/router/src/core/payouts.rs @@ -5,38 +5,41 @@ 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; use router_env::{instrument, logger, 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, @@ -1106,7 +1109,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) @@ -2172,7 +2175,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, @@ -2479,3 +2482,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::LinkConfigurationError { + 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 3e69216167..a1332181f6 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -1,33 +1,25 @@ -use api_models::admin; +use std::collections::HashSet; + +use actix_web::http::header; #[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 router_env::{instrument, tracing}; -use time::Duration; +use router_env::{instrument, logger, tracing}; +use url::Url; 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))] @@ -192,128 +184,97 @@ 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(); - // 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", - )?; +pub fn validate_payout_link_render_request( + 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 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") + // 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 = { + 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) { + 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 + ) }) - .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); - - // 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 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, - }; - - 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(), - }) +fn is_domain_allowed(domain: &str, allowed_domains: HashSet) -> bool { + allowed_domains.iter().any(|allowed_domain| { + 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/routes/payout_link.rs b/crates/router/src/routes/payout_link.rs index 34850bdea6..e3a0327c32 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 5456d4c250..5dc46d3565 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::{HeaderName, HeaderValue}, + web, FromRequest, HttpRequest, HttpResponse, Responder, ResponseError, }; use api_models::enums::{CaptureMethod, PaymentMethodType}; pub use client::{proxy_bypass_urls, ApiClient, MockApiClient, ProxyClient}; @@ -1049,9 +1050,18 @@ 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 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)) } @@ -1062,7 +1072,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": { @@ -1074,7 +1084,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": { @@ -1231,8 +1241,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) = HeaderValue::try_from(value) { + res_builder.insert_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 fb8b3b3ec7..497926481a 100644 --- a/crates/router/src/services/api/generic_link_response.rs +++ b/crates/router/src/services/api/generic_link_response.rs @@ -1,25 +1,27 @@ use common_utils::errors::CustomResult; use error_stack::ResultExt; +use hyperswitch_domain_models::api::{ + GenericExpiredLinkData, GenericLinkFormData, GenericLinkStatusData, GenericLinksData, +}; use tera::{Context, Tera}; -use super::{GenericExpiredLinkData, GenericLinkFormData, GenericLinkStatusData, GenericLinks}; 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) } } diff --git a/crates/router/src/types/api/admin.rs b/crates/router/src/types/api/admin.rs index 55288e02f8..884e47c6fc 100644 --- a/crates/router/src/types/api/admin.rs +++ b/crates/router/src/types/api/admin.rs @@ -9,7 +9,7 @@ pub use api_models::admin::{ ToggleKVResponse, WebhookDetails, }; use common_utils::ext_traits::{AsyncExt, Encode, ValueExt}; -use error_stack::ResultExt; +use error_stack::{report, ResultExt}; use hyperswitch_domain_models::{merchant_key_store::MerchantKeyStore, type_encryption::decrypt}; use masking::{ExposeInterface, PeekInterface, Secret}; @@ -194,6 +194,21 @@ pub async fn create_business_profile( .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Unable to encrypt outgoing webhook custom HTTP headers")?; + let payout_link_config = request + .payout_link_config + .as_ref() + .map(|payout_conf| match payout_conf.config.validate() { + Ok(_) => payout_conf.encode_to_value().change_context( + errors::ApiErrorResponse::InvalidDataValue { + field_name: "payout_link_config", + }, + ), + Err(e) => Err(report!(errors::ApiErrorResponse::InvalidRequestData { + message: e.to_string() + })), + }) + .transpose()?; + Ok(storage::business_profile::BusinessProfileNew { profile_id, merchant_id: merchant_account.merchant_id, @@ -246,14 +261,7 @@ pub async fn create_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, is_connector_agnostic_mit_enabled: request.is_connector_agnostic_mit_enabled, is_extended_card_info_enabled: None, extended_card_info_config: None, 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 0000000000..623bec2a2f --- /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 AND link_type = 'payout_link'; \ 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 0000000000..affa2755d7 --- /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