feat(security): add XSS and sqli validation for dashboard metadata fields (#9104)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Kanika Bansal
2025-10-01 13:23:44 +05:30
committed by GitHub
parent 2d580b3afb
commit cba489ffa9
8 changed files with 506 additions and 57 deletions

View File

@ -1,4 +1,5 @@
use common_enums::{CountryAlpha2, MerchantProductType};
use common_types::primitive_wrappers::SafeString;
use common_utils::{id_type, pii};
use masking::Secret;
use strum::EnumString;
@ -50,16 +51,16 @@ pub struct ProcessorConnected {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct OnboardingSurvey {
pub designation: Option<String>,
pub about_business: Option<String>,
pub business_website: Option<String>,
pub hyperswitch_req: Option<String>,
pub major_markets: Option<Vec<String>>,
pub business_size: Option<String>,
pub required_features: Option<Vec<String>>,
pub required_processors: Option<Vec<String>>,
pub planned_live_date: Option<String>,
pub miscellaneous: Option<String>,
pub designation: Option<SafeString>,
pub about_business: Option<SafeString>,
pub business_website: Option<SafeString>,
pub hyperswitch_req: Option<SafeString>,
pub major_markets: Option<Vec<SafeString>>,
pub business_size: Option<SafeString>,
pub required_features: Option<Vec<SafeString>>,
pub required_processors: Option<Vec<SafeString>>,
pub planned_live_date: Option<SafeString>,
pub miscellaneous: Option<SafeString>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
@ -85,27 +86,27 @@ pub enum ConfigurationType {
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct Feedback {
pub email: pii::Email,
pub description: Option<String>,
pub description: Option<SafeString>,
pub rating: Option<i32>,
pub category: Option<String>,
pub category: Option<SafeString>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct ProdIntent {
pub legal_business_name: Option<String>,
pub business_label: Option<String>,
pub legal_business_name: Option<SafeString>,
pub business_label: Option<SafeString>,
pub business_location: Option<CountryAlpha2>,
pub display_name: Option<String>,
pub poc_email: Option<Secret<String>>,
pub business_type: Option<String>,
pub business_identifier: Option<String>,
pub business_website: Option<String>,
pub poc_name: Option<Secret<String>>,
pub poc_contact: Option<Secret<String>>,
pub comments: Option<String>,
pub display_name: Option<SafeString>,
pub poc_email: Option<pii::Email>,
pub business_type: Option<SafeString>,
pub business_identifier: Option<SafeString>,
pub business_website: Option<SafeString>,
pub poc_name: Option<Secret<SafeString>>,
pub poc_contact: Option<Secret<SafeString>>,
pub comments: Option<SafeString>,
pub is_completed: bool,
#[serde(default)]
pub product_type: MerchantProductType,
pub business_country_name: Option<String>,
pub business_country_name: Option<SafeString>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]

View File

@ -1,4 +1,5 @@
pub use bool_wrappers::*;
pub use safe_string::*;
pub use u32_wrappers::*;
mod bool_wrappers {
use std::ops::Deref;
@ -433,3 +434,106 @@ mod u32_wrappers {
}
}
}
/// Safe string wrapper that validates input against XSS attacks
mod safe_string {
use std::ops::Deref;
use common_utils::validation::contains_potential_xss_or_sqli;
use masking::SerializableSecret;
use serde::{de::Error, Deserialize, Serialize};
/// String wrapper that prevents XSS and SQLi attacks
#[derive(Clone, Debug, Eq, PartialEq, Default)]
pub struct SafeString(String);
impl SafeString {
/// Creates a new SafeString after XSS and SQL injection validation
pub fn new(value: String) -> Result<Self, String> {
if contains_potential_xss_or_sqli(&value) {
return Err("Input contains potentially malicious content".into());
}
Ok(Self(value))
}
/// Creates a SafeString from a string slice
pub fn from_string_slice(value: &str) -> Result<Self, String> {
Self::new(value.to_string())
}
/// Returns the inner string as a string slice
pub fn as_str(&self) -> &str {
&self.0
}
/// Consumes self and returns the inner String
pub fn into_inner(self) -> String {
self.0
}
/// Returns true if the string is empty
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Returns the length of the string
pub fn len(&self) -> usize {
self.0.len()
}
}
impl Deref for SafeString {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
// Custom serialization and deserialization
impl Serialize for SafeString {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SafeString {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(D::Error::custom)
}
}
// Implement SerializableSecret for SafeString to work with Secret<SafeString>
impl SerializableSecret for SafeString {}
// Diesel implementations for database operations
impl<DB> diesel::serialize::ToSql<diesel::sql_types::Text, DB> for SafeString
where
DB: diesel::backend::Backend,
String: diesel::serialize::ToSql<diesel::sql_types::Text, DB>,
{
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, DB>,
) -> diesel::serialize::Result {
self.0.to_sql(out)
}
}
impl<DB> diesel::deserialize::FromSql<diesel::sql_types::Text, DB> for SafeString
where
DB: diesel::backend::Backend,
String: diesel::deserialize::FromSql<diesel::sql_types::Text, DB>,
{
fn from_sql(value: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
String::from_sql(value).map(Self)
}
}
}

View File

@ -60,8 +60,10 @@ thiserror = "1.0.69"
time = { version = "0.3.41", features = ["serde", "serde-well-known", "std"] }
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"], optional = true }
url = { version = "2.5.4", features = ["serde"] }
urlencoding = "2.1.3"
utoipa = { version = "4.2.3", features = ["preserve_order", "preserve_path_order"] }
uuid = { version = "1.17.0", features = ["v7"] }
ammonia = "3.3"
# First party crates
common_enums = { version = "0.1.0", path = "../common_enums" }

View File

@ -1,5 +1,7 @@
//! Custom validations for some shared types.
#![deny(clippy::invalid_regex)]
use std::{collections::HashSet, sync::LazyLock};
use error_stack::report;
@ -82,6 +84,78 @@ pub fn validate_domain_against_allowed_domains(
})
}
/// checks whether the input string contains potential XSS or SQL injection attack vectors
pub fn contains_potential_xss_or_sqli(input: &str) -> bool {
let decoded = urlencoding::decode(input).unwrap_or_else(|_| input.into());
// Check for suspicious percent-encoded patterns
static PERCENT_ENCODED: LazyLock<Option<Regex>> = LazyLock::new(|| {
Regex::new(r"%[0-9A-Fa-f]{2}")
.map_err(|_err| {
#[cfg(feature = "logs")]
logger::error!(?_err);
})
.ok()
});
if decoded.contains('%') {
match PERCENT_ENCODED.as_ref() {
Some(regex) => {
if regex.is_match(&decoded) {
return true;
}
}
None => return true,
}
}
if ammonia::is_html(&decoded) {
return true;
}
static XSS: LazyLock<Option<Regex>> = LazyLock::new(|| {
Regex::new(
r"(?is)\bon[a-z]+\s*=|\bjavascript\s*:|\bdata\s*:\s*text/html|\b(alert|prompt|confirm|eval)\s*\(",
)
.map_err(|_err| {
#[cfg(feature = "logs")]
logger::error!(?_err);
})
.ok()
});
static SQLI: LazyLock<Option<Regex>> = LazyLock::new(|| {
Regex::new(
r"(?is)(?:')\s*or\s*'?\d+'?=?\d*|union\s+select|insert\s+into|drop\s+table|information_schema|sleep\s*\(|--|;",
)
.map_err(|_err| {
#[cfg(feature = "logs")]
logger::error!(?_err);
})
.ok()
});
match XSS.as_ref() {
Some(regex) => {
if regex.is_match(&decoded) {
return true;
}
}
None => return true,
}
match SQLI.as_ref() {
Some(regex) => {
if regex.is_match(&decoded) {
return true;
}
}
None => return true,
}
false
}
#[cfg(test)]
mod tests {
use fake::{faker::internet::en::SafeEmail, Fake};
@ -150,4 +224,103 @@ mod tests {
prop_assert!(validate_email(&email).is_err());
}
}
#[test]
fn detects_basic_script_tags() {
assert!(contains_potential_xss_or_sqli(
"<script>alert('xss')</script>"
));
}
#[test]
fn detects_event_handlers() {
assert!(contains_potential_xss_or_sqli(
"onload=alert('xss') onclick=alert('xss') onmouseover=alert('xss')",
));
}
#[test]
fn detects_data_url_payload() {
assert!(contains_potential_xss_or_sqli(
"data:text/html,<script>alert('xss')</script>",
));
}
#[test]
fn detects_iframe_javascript_src() {
assert!(contains_potential_xss_or_sqli(
"<iframe src=javascript:alert('xss')></iframe>",
));
}
#[test]
fn detects_svg_with_script() {
assert!(contains_potential_xss_or_sqli(
"<svg><script>alert('xss')</script></svg>",
));
}
#[test]
fn detects_object_with_js() {
assert!(contains_potential_xss_or_sqli(
"<object data=javascript:alert('xss')></object>",
));
}
#[test]
fn detects_mixed_case_tags() {
assert!(contains_potential_xss_or_sqli(
"<ScRiPt>alert('xss')</ScRiPt>"
));
}
#[test]
fn detects_embedded_script_in_text() {
assert!(contains_potential_xss_or_sqli(
"Test<script>alert('xss')</script>Company",
));
}
#[test]
fn detects_math_with_script() {
assert!(contains_potential_xss_or_sqli(
"<math><script>alert('xss')</script></math>",
));
}
#[test]
fn detects_basic_sql_tautology() {
assert!(contains_potential_xss_or_sqli("' OR '1'='1"));
}
#[test]
fn detects_time_based_sqli() {
assert!(contains_potential_xss_or_sqli("' OR SLEEP(5) --"));
}
#[test]
fn detects_percent_encoded_sqli() {
// %27 OR %271%27=%271 is a typical encoded variant
assert!(contains_potential_xss_or_sqli("%27%20OR%20%271%27%3D%271"));
}
#[test]
fn detects_benign_html_as_suspicious() {
assert!(contains_potential_xss_or_sqli("<b>Hello</b>"));
}
#[test]
fn allows_legitimate_plain_text() {
assert!(!contains_potential_xss_or_sqli("My Test Company Ltd."));
}
#[test]
fn allows_normal_url() {
assert!(!contains_potential_xss_or_sqli("https://example.com"));
}
#[test]
fn allows_percent_char_without_encoding() {
assert!(!contains_potential_xss_or_sqli("Get 50% off today"));
}
}

View File

@ -1,9 +1,6 @@
use std::str::FromStr;
use api_models::user::dashboard_metadata::{self as api, GetMultipleMetaDataPayload};
#[cfg(feature = "email")]
use common_enums::EntityType;
use common_utils::pii;
use diesel_models::{
enums::DashboardMetadata as DBEnum, user::dashboard_metadata::DashboardMetadata,
};
@ -11,7 +8,7 @@ use error_stack::{report, ResultExt};
use hyperswitch_interfaces::crm::CrmPayload;
#[cfg(feature = "email")]
use masking::ExposeInterface;
use masking::PeekInterface;
use masking::{PeekInterface, Secret};
use router_env::logger;
use crate::{
@ -456,11 +453,6 @@ async fn insert_metadata(
metadata
}
types::MetaData::ProdIntent(data) => {
if let Some(poc_email) = &data.poc_email {
let inner_poc_email = poc_email.peek().as_str();
pii::Email::from_str(inner_poc_email)
.change_context(UserErrors::EmailParsingError)?;
}
let mut metadata = utils::insert_merchant_scoped_metadata_to_db(
state,
user.user_id.clone(),
@ -523,19 +515,23 @@ async fn insert_metadata(
let hubspot_body = state
.crm_client
.make_body(CrmPayload {
legal_business_name: data.legal_business_name,
business_label: data.business_label,
legal_business_name: data.legal_business_name.map(|s| s.into_inner()),
business_label: data.business_label.map(|s| s.into_inner()),
business_location: data.business_location,
display_name: data.display_name,
poc_email: data.poc_email,
business_type: data.business_type,
business_identifier: data.business_identifier,
business_website: data.business_website,
poc_name: data.poc_name,
poc_contact: data.poc_contact,
comments: data.comments,
display_name: data.display_name.map(|s| s.into_inner()),
poc_email: data.poc_email.map(|s| Secret::new(s.peek().clone())),
business_type: data.business_type.map(|s| s.into_inner()),
business_identifier: data.business_identifier.map(|s| s.into_inner()),
business_website: data.business_website.map(|s| s.into_inner()),
poc_name: data
.poc_name
.map(|s| Secret::new(s.peek().clone().into_inner())),
poc_contact: data
.poc_contact
.map(|s| Secret::new(s.peek().clone().into_inner())),
comments: data.comments.map(|s| s.into_inner()),
is_completed: data.is_completed,
business_country_name: data.business_country_name,
business_country_name: data.business_country_name.map(|s| s.into_inner()),
})
.await;
let base_url = user_utils::get_base_url(state);

View File

@ -3,7 +3,7 @@ use common_enums::{EntityType, MerchantProductType};
use common_utils::{errors::CustomResult, pii, types::user::EmailThemeConfig};
use error_stack::ResultExt;
use external_services::email::{EmailContents, EmailData, EmailError};
use masking::{ExposeInterface, Secret};
use masking::{ExposeInterface, PeekInterface, Secret};
use crate::{configs, consts, routes::SessionState};
#[cfg(feature = "olap")]
@ -567,14 +567,26 @@ impl BizEmailProd {
state.conf.email.prod_intent_recipient_email.clone(),
)?,
settings: state.conf.clone(),
user_name: data.poc_name.unwrap_or_default(),
poc_email: data.poc_email.unwrap_or_default(),
legal_business_name: data.legal_business_name.unwrap_or_default(),
user_name: data
.poc_name
.map(|s| Secret::new(s.peek().clone().into_inner()))
.unwrap_or_default(),
poc_email: data
.poc_email
.map(|s| Secret::new(s.peek().clone()))
.unwrap_or_default(),
legal_business_name: data
.legal_business_name
.map(|s| s.into_inner())
.unwrap_or_default(),
business_location: data
.business_location
.unwrap_or(common_enums::CountryAlpha2::AD)
.to_string(),
business_website: data.business_website.unwrap_or_default(),
business_website: data
.business_website
.map(|s| s.into_inner())
.unwrap_or_default(),
theme_id,
theme_config,
product_type: data.product_type,

View File

@ -287,8 +287,12 @@ pub fn is_prod_email_required(data: &ProdIntent, user_email: String) -> bool {
data.poc_email.as_ref().map(|email| email.peek().as_str()),
"juspay",
);
let business_website_check = not_contains_string(data.business_website.as_deref(), "juspay")
&& not_contains_string(data.business_website.as_deref(), "hyperswitch");
let business_website_check =
not_contains_string(data.business_website.as_ref().map(|s| s.as_str()), "juspay")
&& not_contains_string(
data.business_website.as_ref().map(|s| s.as_str()),
"hyperswitch",
);
let user_email_check = not_contains_string(Some(&user_email), "juspay");
if (poc_email_check && business_website_check && user_email_check).not() {