fix(connector): [NEXIXPAY] Add Validation Checks for Request Fields (#8345)

Co-authored-by: Sayak Bhattacharya <sayak.b@Sayak-Bhattacharya-G092THXJ34.local>
This commit is contained in:
Sayak Bhattacharya
2025-06-23 15:07:31 +05:30
committed by GitHub
parent 7338a8db85
commit 6fd7626c99
6 changed files with 323 additions and 95 deletions

View File

@ -27,6 +27,7 @@ use hyperswitch_domain_models::{
}; };
use hyperswitch_interfaces::{consts::NO_ERROR_CODE, errors}; use hyperswitch_interfaces::{consts::NO_ERROR_CODE, errors};
use masking::{ExposeInterface, Secret}; use masking::{ExposeInterface, Secret};
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use strum::Display; use strum::Display;
@ -40,6 +41,224 @@ use crate::{
}, },
}; };
fn get_random_string() -> String {
Alphanumeric.sample_string(&mut rand::thread_rng(), MAX_ORDER_ID_LENGTH)
}
#[derive(Clone, Copy, Debug)]
enum AddressKind {
Billing,
Shipping,
}
trait AddressConstructor {
fn new(
name: Option<Secret<String>>,
street: Option<Secret<String>>,
city: Option<String>,
post_code: Option<Secret<String>>,
country: Option<enums::CountryAlpha2>,
) -> Self;
}
impl AddressConstructor for BillingAddress {
fn new(
name: Option<Secret<String>>,
street: Option<Secret<String>>,
city: Option<String>,
post_code: Option<Secret<String>>,
country: Option<enums::CountryAlpha2>,
) -> Self {
Self {
name,
street,
city,
post_code,
country,
}
}
}
impl AddressConstructor for ShippingAddress {
fn new(
name: Option<Secret<String>>,
street: Option<Secret<String>>,
city: Option<String>,
post_code: Option<Secret<String>>,
country: Option<enums::CountryAlpha2>,
) -> Self {
Self {
name,
street,
city,
post_code,
country,
}
}
}
fn get_validated_address_details_generic<RouterContextDataAlias, AddressOutput>(
data: &RouterContextDataAlias,
address_kind: AddressKind,
) -> Result<Option<AddressOutput>, error_stack::Report<errors::ConnectorError>>
where
RouterContextDataAlias: crate::utils::RouterData,
AddressOutput: AddressConstructor + Sized,
{
let (
opt_line1,
opt_line2,
opt_full_name,
opt_city,
opt_zip,
opt_country,
has_address_details_check,
address_type_str,
max_name_len,
max_street_len,
max_city_len,
max_post_code_len,
max_country_len,
) = match address_kind {
AddressKind::Billing => (
data.get_optional_billing_line1(),
data.get_optional_billing_line2(),
data.get_optional_billing_full_name(),
data.get_optional_billing_city(),
data.get_optional_billing_zip(),
data.get_optional_billing_country(),
data.get_optional_billing().is_some(),
"billing",
MAX_BILLING_ADDRESS_NAME_LENGTH,
MAX_BILLING_ADDRESS_STREET_LENGTH,
MAX_BILLING_ADDRESS_CITY_LENGTH,
MAX_BILLING_ADDRESS_POST_CODE_LENGTH,
MAX_BILLING_ADDRESS_COUNTRY_LENGTH,
),
AddressKind::Shipping => (
data.get_optional_shipping_line1(),
data.get_optional_shipping_line2(),
data.get_optional_shipping_full_name(),
data.get_optional_shipping_city(),
data.get_optional_shipping_zip(),
data.get_optional_shipping_country(),
data.get_optional_shipping().is_some(),
"shipping",
MAX_BILLING_ADDRESS_NAME_LENGTH,
MAX_BILLING_ADDRESS_STREET_LENGTH,
MAX_BILLING_ADDRESS_CITY_LENGTH,
MAX_BILLING_ADDRESS_POST_CODE_LENGTH,
MAX_BILLING_ADDRESS_COUNTRY_LENGTH,
),
};
let street_val = match (opt_line1.clone(), opt_line2.clone()) {
(Some(l1), Some(l2)) => Some(Secret::new(format!("{}, {}", l1.expose(), l2.expose()))),
(Some(l1), None) => Some(l1),
(None, Some(l2)) => Some(l2),
(None, None) => None,
};
if has_address_details_check {
let name_val = opt_full_name;
if let Some(ref val) = name_val {
let length = val.clone().expose().len();
if length > max_name_len {
return Err(error_stack::Report::from(
errors::ConnectorError::MaxFieldLengthViolated {
field_name: format!(
"{0}.address.first_name & {0}.address.last_name",
address_type_str
),
connector: "Nexixpay".to_string(),
max_length: max_name_len,
received_length: length,
},
));
}
}
if let Some(ref val) = street_val {
let length = val.clone().expose().len();
if length > max_street_len {
return Err(error_stack::Report::from(
errors::ConnectorError::MaxFieldLengthViolated {
field_name: format!(
"{0}.address.line1 & {0}.address.line2",
address_type_str
),
connector: "Nexixpay".to_string(),
max_length: max_street_len,
received_length: length,
},
));
}
}
let city_val = opt_city;
if let Some(ref val) = city_val {
let length = val.len();
if length > max_city_len {
return Err(error_stack::Report::from(
errors::ConnectorError::MaxFieldLengthViolated {
field_name: format!("{}.address.city", address_type_str),
connector: "Nexixpay".to_string(),
max_length: max_city_len,
received_length: length,
},
));
}
}
let post_code_val = opt_zip;
if let Some(ref val) = post_code_val {
let length = val.clone().expose().len();
if length > max_post_code_len {
return Err(error_stack::Report::from(
errors::ConnectorError::MaxFieldLengthViolated {
field_name: format!("{}.address.zip", address_type_str),
connector: "Nexixpay".to_string(),
max_length: max_post_code_len,
received_length: length,
},
));
}
}
let country_val = opt_country;
if let Some(ref val) = country_val {
let length = val.to_string().len();
if length > max_country_len {
return Err(error_stack::Report::from(
errors::ConnectorError::MaxFieldLengthViolated {
field_name: format!("{}.address.country", address_type_str),
connector: "Nexixpay".to_string(),
max_length: max_country_len,
received_length: length,
},
));
}
}
Ok(Some(AddressOutput::new(
name_val,
street_val,
city_val,
post_code_val,
country_val,
)))
} else {
Ok(None)
}
}
const MAX_ORDER_ID_LENGTH: usize = 18;
const MAX_CARD_HOLDER_LENGTH: usize = 255;
const MAX_BILLING_ADDRESS_NAME_LENGTH: usize = 50;
const MAX_BILLING_ADDRESS_STREET_LENGTH: usize = 50;
const MAX_BILLING_ADDRESS_CITY_LENGTH: usize = 40;
const MAX_BILLING_ADDRESS_POST_CODE_LENGTH: usize = 16;
const MAX_BILLING_ADDRESS_COUNTRY_LENGTH: usize = 3;
pub struct NexixpayRouterData<T> { pub struct NexixpayRouterData<T> {
pub amount: StringMinorUnit, pub amount: StringMinorUnit,
pub router_data: T, pub router_data: T,
@ -480,60 +699,50 @@ impl TryFrom<&NexixpayRouterData<&PaymentsAuthorizeRouterData>> for NexixpayPaym
fn try_from( fn try_from(
item: &NexixpayRouterData<&PaymentsAuthorizeRouterData>, item: &NexixpayRouterData<&PaymentsAuthorizeRouterData>,
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
let billing_address_street = match ( let order_id = if item.router_data.payment_id.len() > MAX_ORDER_ID_LENGTH {
item.router_data.get_optional_billing_line1(), if item.router_data.payment_id.starts_with("pay_") {
item.router_data.get_optional_billing_line2(), get_random_string()
) { } else {
(Some(line1), Some(line2)) => Some(Secret::new(format!( return Err(error_stack::Report::from(
"{}, {}", errors::ConnectorError::MaxFieldLengthViolated {
line1.expose(), field_name: "payment_id".to_string(),
line2.expose() connector: "Nexixpay".to_string(),
))), max_length: MAX_ORDER_ID_LENGTH,
(Some(line1), None) => Some(line1), received_length: item.router_data.payment_id.len(),
(None, Some(line2)) => Some(line2), },
(None, None) => None, ));
}; }
let billing_address = item } else {
.router_data item.router_data.payment_id.clone()
.get_optional_billing()
.map(|_| BillingAddress {
name: item.router_data.get_optional_billing_full_name(),
street: billing_address_street,
city: item.router_data.get_optional_billing_city(),
post_code: item.router_data.get_optional_billing_zip(),
country: item.router_data.get_optional_billing_country(),
});
let shipping_address_street = match (
item.router_data.get_optional_shipping_line1(),
item.router_data.get_optional_shipping_line2(),
) {
(Some(line1), Some(line2)) => Some(Secret::new(format!(
"{}, {}",
line1.expose(),
line2.expose()
))),
(Some(line1), None) => Some(Secret::new(line1.expose())),
(None, Some(line2)) => Some(Secret::new(line2.expose())),
(None, None) => None,
}; };
let shipping_address = item let billing_address = get_validated_billing_address(item.router_data)?;
.router_data let shipping_address = get_validated_shipping_address(item.router_data)?;
.get_optional_shipping()
.map(|_| ShippingAddress {
name: item.router_data.get_optional_shipping_full_name(),
street: shipping_address_street,
city: item.router_data.get_optional_shipping_city(),
post_code: item.router_data.get_optional_shipping_zip(),
country: item.router_data.get_optional_shipping_country(),
});
let customer_info = CustomerInfo { let customer_info = CustomerInfo {
card_holder_name: item.router_data.get_billing_full_name()?, card_holder_name: match item.router_data.get_billing_full_name()? {
name if name.clone().expose().len() <= MAX_CARD_HOLDER_LENGTH => name,
_ => {
return Err(error_stack::Report::from(
errors::ConnectorError::MaxFieldLengthViolated {
field_name: "billing.address.first_name & billing.address.last_name"
.to_string(),
connector: "Nexixpay".to_string(),
max_length: MAX_CARD_HOLDER_LENGTH,
received_length: item
.router_data
.get_billing_full_name()?
.expose()
.len(),
},
))
}
},
billing_address: billing_address.clone(), billing_address: billing_address.clone(),
shipping_address: shipping_address.clone(), shipping_address: shipping_address.clone(),
}; };
let order = Order { let order = Order {
order_id: item.router_data.connector_request_reference_id.clone(), order_id,
amount: item.amount.clone(), amount: item.amount.clone(),
currency: item.router_data.request.currency, currency: item.router_data.request.currency,
description: item.router_data.description.clone(), description: item.router_data.description.clone(),
@ -1089,55 +1298,27 @@ impl TryFrom<&NexixpayRouterData<&PaymentsCompleteAuthorizeRouterData>>
)?; )?;
let capture_type = get_nexixpay_capture_type(item.router_data.request.capture_method)?; let capture_type = get_nexixpay_capture_type(item.router_data.request.capture_method)?;
let order_id = item.router_data.connector_request_reference_id.clone(); let order_id = if item.router_data.payment_id.len() > MAX_ORDER_ID_LENGTH {
if item.router_data.payment_id.starts_with("pay_") {
get_random_string()
} else {
return Err(error_stack::Report::from(
errors::ConnectorError::MaxFieldLengthViolated {
field_name: "payment_id".to_string(),
connector: "Nexixpay".to_string(),
max_length: MAX_ORDER_ID_LENGTH,
received_length: item.router_data.payment_id.len(),
},
));
}
} else {
item.router_data.payment_id.clone()
};
let amount = item.amount.clone(); let amount = item.amount.clone();
let billing_address_street = match (
item.router_data.get_optional_billing_line1(),
item.router_data.get_optional_billing_line2(),
) {
(Some(line1), Some(line2)) => Some(Secret::new(format!(
"{}, {}",
line1.expose(),
line2.expose()
))),
(Some(line1), None) => Some(line1),
(None, Some(line2)) => Some(line2),
(None, None) => None,
};
let billing_address = item
.router_data
.get_optional_billing()
.map(|_| BillingAddress {
name: item.router_data.get_optional_billing_full_name(),
street: billing_address_street,
city: item.router_data.get_optional_billing_city(),
post_code: item.router_data.get_optional_billing_zip(),
country: item.router_data.get_optional_billing_country(),
});
let shipping_address_street = match (
item.router_data.get_optional_shipping_line1(),
item.router_data.get_optional_shipping_line2(),
) {
(Some(line1), Some(line2)) => Some(Secret::new(format!(
"{}, {}",
line1.expose(),
line2.expose()
))),
(Some(line1), None) => Some(Secret::new(line1.expose())),
(None, Some(line2)) => Some(Secret::new(line2.expose())),
(None, None) => None,
};
let shipping_address = item let billing_address = get_validated_billing_address(item.router_data)?;
.router_data let shipping_address = get_validated_shipping_address(item.router_data)?;
.get_optional_shipping()
.map(|_| ShippingAddress {
name: item.router_data.get_optional_shipping_full_name(),
street: shipping_address_street,
city: item.router_data.get_optional_shipping_city(),
post_code: item.router_data.get_optional_shipping_zip(),
country: item.router_data.get_optional_shipping_country(),
});
let customer_info = CustomerInfo { let customer_info = CustomerInfo {
card_holder_name: item.router_data.get_billing_full_name()?, card_holder_name: item.router_data.get_billing_full_name()?,
billing_address: billing_address.clone(), billing_address: billing_address.clone(),
@ -1227,6 +1408,24 @@ impl TryFrom<&NexixpayRouterData<&PaymentsCompleteAuthorizeRouterData>>
} }
} }
fn get_validated_shipping_address<RouterContextDataAlias>(
data: &RouterContextDataAlias,
) -> Result<Option<ShippingAddress>, error_stack::Report<errors::ConnectorError>>
where
RouterContextDataAlias: crate::utils::RouterData,
{
get_validated_address_details_generic(data, AddressKind::Shipping)
}
fn get_validated_billing_address<RouterContextDataAlias>(
data: &RouterContextDataAlias,
) -> Result<Option<BillingAddress>, error_stack::Report<errors::ConnectorError>>
where
RouterContextDataAlias: crate::utils::RouterData,
{
get_validated_address_details_generic(data, AddressKind::Billing)
}
impl<F> impl<F>
TryFrom< TryFrom<
ResponseRouterData<F, NexixpayTransactionResponse, PaymentsSyncData, PaymentsResponseData>, ResponseRouterData<F, NexixpayTransactionResponse, PaymentsSyncData, PaymentsResponseData>,

View File

@ -292,6 +292,13 @@ pub enum ApiErrorResponse {
ExternalVaultFailed, ExternalVaultFailed,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_46", message = "Field {fields} doesn't match with the ones used during mandate creation")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_46", message = "Field {fields} doesn't match with the ones used during mandate creation")]
MandatePaymentDataMismatch { fields: String }, MandatePaymentDataMismatch { fields: String },
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_47", message = "Connector '{connector}' rejected field '{field_name}': length {received_length} exceeds maximum of {max_length}")]
MaxFieldLengthViolated {
connector: String,
field_name: String,
max_length: usize,
received_length: usize,
},
#[error(error_type = ErrorType::InvalidRequestError, code = "WE_01", message = "Failed to authenticate the webhook")] #[error(error_type = ErrorType::InvalidRequestError, code = "WE_01", message = "Failed to authenticate the webhook")]
WebhookAuthenticationFailed, WebhookAuthenticationFailed,
#[error(error_type = ErrorType::InvalidRequestError, code = "WE_02", message = "Bad request received in webhook")] #[error(error_type = ErrorType::InvalidRequestError, code = "WE_02", message = "Bad request received in webhook")]
@ -656,7 +663,9 @@ impl ErrorSwitch<api_models::errors::types::ApiErrorResponse> for ApiErrorRespon
Self::MandatePaymentDataMismatch { fields} => { Self::MandatePaymentDataMismatch { fields} => {
AER::BadRequest(ApiError::new("IR", 46, format!("Field {fields} doesn't match with the ones used during mandate creation"), Some(Extra {fields: Some(fields.to_owned()), ..Default::default()}))) //FIXME: error message AER::BadRequest(ApiError::new("IR", 46, format!("Field {fields} doesn't match with the ones used during mandate creation"), Some(Extra {fields: Some(fields.to_owned()), ..Default::default()}))) //FIXME: error message
} }
Self::MaxFieldLengthViolated { connector, field_name, max_length, received_length} => {
AER::BadRequest(ApiError::new("IR", 47, format!("Connector '{connector}' rejected field '{field_name}': length {received_length} exceeds maximum of {max_length}"), Some(Extra {connector: Some(connector.to_string()), ..Default::default()})))
}
Self::WebhookAuthenticationFailed => { Self::WebhookAuthenticationFailed => {
AER::Unauthorized(ApiError::new("WE", 1, "Webhook authentication failed", None)) AER::Unauthorized(ApiError::new("WE", 1, "Webhook authentication failed", None))
} }

View File

@ -127,6 +127,13 @@ pub enum ConnectorError {
}, },
#[error("Field {fields} doesn't match with the ones used during mandate creation")] #[error("Field {fields} doesn't match with the ones used during mandate creation")]
MandatePaymentDataMismatch { fields: String }, MandatePaymentDataMismatch { fields: String },
#[error("Field '{field_name}' is too long for connector '{connector}'")]
MaxFieldLengthViolated {
connector: String,
field_name: String,
max_length: usize,
received_length: usize,
},
} }
impl ConnectorError { impl ConnectorError {

View File

@ -601,6 +601,7 @@ impl From<errors::ApiErrorResponse> for StripeErrorCode {
errors::ApiErrorResponse::NotImplemented { .. } => Self::Unauthorized, errors::ApiErrorResponse::NotImplemented { .. } => Self::Unauthorized,
errors::ApiErrorResponse::FlowNotSupported { .. } => Self::InternalServerError, errors::ApiErrorResponse::FlowNotSupported { .. } => Self::InternalServerError,
errors::ApiErrorResponse::MandatePaymentDataMismatch { .. } => Self::PlatformBadRequest, errors::ApiErrorResponse::MandatePaymentDataMismatch { .. } => Self::PlatformBadRequest,
errors::ApiErrorResponse::MaxFieldLengthViolated { .. } => Self::PlatformBadRequest,
errors::ApiErrorResponse::PaymentUnexpectedState { errors::ApiErrorResponse::PaymentUnexpectedState {
current_flow, current_flow,
field_name, field_name,

View File

@ -156,6 +156,7 @@ impl<T> ConnectorErrorExt<T> for error_stack::Result<T, errors::ConnectorError>
| errors::ConnectorError::NoConnectorMetaData | errors::ConnectorError::NoConnectorMetaData
| errors::ConnectorError::NoConnectorWalletDetails | errors::ConnectorError::NoConnectorWalletDetails
| errors::ConnectorError::FailedToObtainCertificateKey | errors::ConnectorError::FailedToObtainCertificateKey
| errors::ConnectorError::MaxFieldLengthViolated { .. }
| errors::ConnectorError::FlowNotSupported { .. } | errors::ConnectorError::FlowNotSupported { .. }
| errors::ConnectorError::MissingConnectorMandateID | errors::ConnectorError::MissingConnectorMandateID
| errors::ConnectorError::MissingConnectorMandateMetadata | errors::ConnectorError::MissingConnectorMandateMetadata
@ -242,6 +243,9 @@ impl<T> ConnectorErrorExt<T> for error_stack::Result<T, errors::ConnectorError>
errors::ConnectorError::FlowNotSupported{ flow, connector } => { errors::ConnectorError::FlowNotSupported{ flow, connector } => {
errors::ApiErrorResponse::FlowNotSupported { flow: flow.to_owned(), connector: connector.to_owned() } errors::ApiErrorResponse::FlowNotSupported { flow: flow.to_owned(), connector: connector.to_owned() }
}, },
errors::ConnectorError::MaxFieldLengthViolated{ connector, field_name, max_length, received_length} => {
errors::ApiErrorResponse::MaxFieldLengthViolated { connector: connector.to_string(), field_name: field_name.to_string(), max_length: *max_length, received_length: *received_length }
},
errors::ConnectorError::InvalidDataFormat { field_name } => { errors::ConnectorError::InvalidDataFormat { field_name } => {
errors::ApiErrorResponse::InvalidDataValue { field_name } errors::ApiErrorResponse::InvalidDataValue { field_name }
}, },
@ -362,6 +366,7 @@ impl<T> ConnectorErrorExt<T> for error_stack::Result<T, errors::ConnectorError>
| errors::ConnectorError::FailedToObtainCertificateKey | errors::ConnectorError::FailedToObtainCertificateKey
| errors::ConnectorError::NotImplemented(_) | errors::ConnectorError::NotImplemented(_)
| errors::ConnectorError::NotSupported { .. } | errors::ConnectorError::NotSupported { .. }
| errors::ConnectorError::MaxFieldLengthViolated { .. }
| errors::ConnectorError::FlowNotSupported { .. } | errors::ConnectorError::FlowNotSupported { .. }
| errors::ConnectorError::MissingConnectorMandateID | errors::ConnectorError::MissingConnectorMandateID
| errors::ConnectorError::MissingConnectorMandateMetadata | errors::ConnectorError::MissingConnectorMandateMetadata

View File

@ -161,6 +161,13 @@ pub enum ConnectorError {
}, },
#[error("{flow} flow not supported by {connector} connector")] #[error("{flow} flow not supported by {connector} connector")]
FlowNotSupported { flow: String, connector: String }, FlowNotSupported { flow: String, connector: String },
#[error("Connector '{connector}' rejected field '{field_name}': length {received_length} exceeds maximum of {max_length}'")]
MaxFieldLengthViolated {
connector: String,
field_name: String,
max_length: usize,
received_length: usize,
},
#[error("Capture method not supported")] #[error("Capture method not supported")]
CaptureMethodNotSupported, CaptureMethodNotSupported,
#[error("Missing connector transaction ID")] #[error("Missing connector transaction ID")]