fix: enforce WeChat Pay JSAPI field limits
This commit is contained in:
@@ -26,6 +26,12 @@ const WECHAT_PAY_PROVIDER_REAL: &str = "real";
|
||||
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
|
||||
const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
|
||||
const WECHAT_PAY_NOTIFY_SUCCESS: &str = "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
|
||||
const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127;
|
||||
const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255;
|
||||
const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WechatPayClient {
|
||||
@@ -77,7 +83,6 @@ pub enum WechatPayError {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WechatJsapiOrderRequest<'a> {
|
||||
appid: &'a str,
|
||||
mchid: &'a str,
|
||||
@@ -196,6 +201,7 @@ impl WechatPayClient {
|
||||
config.wechat_pay_notify_url.as_deref(),
|
||||
"WECHAT_PAY_NOTIFY_URL",
|
||||
)?;
|
||||
validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?;
|
||||
let jsapi_endpoint = normalize_required_url(
|
||||
&config.wechat_pay_jsapi_endpoint,
|
||||
"WECHAT_PAY_JSAPI_ENDPOINT",
|
||||
@@ -244,6 +250,7 @@ impl RealWechatPayClient {
|
||||
&self,
|
||||
request: WechatMiniProgramOrderRequest,
|
||||
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
|
||||
validate_jsapi_order_request(self, &request)?;
|
||||
let amount_total = i64::try_from(request.amount_cents)
|
||||
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
|
||||
let body = serde_json::to_string(&WechatJsapiOrderRequest {
|
||||
@@ -595,6 +602,105 @@ fn normalize_required_url(value: &str, key: &str) -> Result<String, WechatPayErr
|
||||
)))
|
||||
}
|
||||
|
||||
fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> {
|
||||
if value.chars().count() > WECHAT_PAY_NOTIFY_URL_MAX_CHARS {
|
||||
return Err(WechatPayError::InvalidConfig(format!(
|
||||
"{key} 不能超过 {WECHAT_PAY_NOTIFY_URL_MAX_CHARS} 字符"
|
||||
)));
|
||||
}
|
||||
if value.contains('?') || value.contains('#') {
|
||||
return Err(WechatPayError::InvalidConfig(format!(
|
||||
"{key} 不能包含 query 或 fragment"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_jsapi_order_request(
|
||||
client: &RealWechatPayClient,
|
||||
request: &WechatMiniProgramOrderRequest,
|
||||
) -> Result<(), WechatPayError> {
|
||||
validate_non_empty_max_chars(
|
||||
&client.app_id,
|
||||
WECHAT_PAY_APP_ID_MAX_CHARS,
|
||||
"微信支付 appid",
|
||||
)?;
|
||||
if !client.app_id.starts_with("wx") {
|
||||
return Err(WechatPayError::InvalidConfig(
|
||||
"微信支付 appid 必须使用小程序 AppID".to_string(),
|
||||
));
|
||||
}
|
||||
validate_non_empty_max_chars(
|
||||
&client.mch_id,
|
||||
WECHAT_PAY_MCH_ID_MAX_CHARS,
|
||||
"微信支付 mchid",
|
||||
)?;
|
||||
if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return Err(WechatPayError::InvalidConfig(
|
||||
"微信支付 mchid 必须是数字字符串".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_non_empty_max_chars(
|
||||
&request.description,
|
||||
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
|
||||
"微信支付商品描述",
|
||||
)?;
|
||||
validate_out_trade_no(&request.order_id)?;
|
||||
if request.amount_cents == 0 {
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信支付金额必须大于 0 分".to_string(),
|
||||
));
|
||||
}
|
||||
validate_non_empty_max_chars(
|
||||
&request.payer_openid,
|
||||
WECHAT_PAY_OPENID_MAX_CHARS,
|
||||
"微信支付 payer.openid",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_non_empty_max_chars(
|
||||
value: &str,
|
||||
max_chars: usize,
|
||||
field_name: &str,
|
||||
) -> Result<(), WechatPayError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(WechatPayError::InvalidRequest(format!(
|
||||
"{field_name} 不能为空"
|
||||
)));
|
||||
}
|
||||
if value.chars().count() > max_chars {
|
||||
return Err(WechatPayError::InvalidRequest(format!(
|
||||
"{field_name} 不能超过 {max_chars} 字符"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_out_trade_no(value: &str) -> Result<(), WechatPayError> {
|
||||
validate_non_empty_max_chars(
|
||||
value,
|
||||
WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS,
|
||||
"微信支付 out_trade_no",
|
||||
)?;
|
||||
if value.chars().count() < 6 {
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信支付 out_trade_no 不能少于 6 字符".to_string(),
|
||||
));
|
||||
}
|
||||
if !value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '|' | '*'))
|
||||
{
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信支付 out_trade_no 只能包含数字、大小写字母、_、-、|、*".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_private_key_pem(
|
||||
inline_pem: Option<&str>,
|
||||
path: Option<&Path>,
|
||||
@@ -768,6 +874,63 @@ mod tests {
|
||||
assert!(!params.pay_sign.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsapi_order_request_uses_wechat_v3_snake_case_fields() {
|
||||
let body = serde_json::to_value(WechatJsapiOrderRequest {
|
||||
appid: "wx-test-app",
|
||||
mchid: "1900000001",
|
||||
description: "陶泥儿 - 60泥点",
|
||||
out_trade_no: "rcgtest001",
|
||||
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
|
||||
amount: WechatJsapiAmount {
|
||||
total: 600,
|
||||
currency: "CNY",
|
||||
},
|
||||
payer: WechatJsapiPayer {
|
||||
openid: "openid-test",
|
||||
},
|
||||
})
|
||||
.expect("JSAPI order request should serialize");
|
||||
|
||||
assert_eq!(body["out_trade_no"], "rcgtest001");
|
||||
assert_eq!(
|
||||
body["notify_url"],
|
||||
"https://api.example.com/api/profile/recharge/wechat/notify"
|
||||
);
|
||||
assert!(body.get("outTradeNo").is_none());
|
||||
assert!(body.get("notifyUrl").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsapi_order_request_rejects_provider_field_limit_violations() {
|
||||
assert!(validate_out_trade_no("abc12").is_err());
|
||||
assert!(validate_out_trade_no("abc123").is_ok());
|
||||
assert!(validate_out_trade_no("abc123_-|*").is_ok());
|
||||
assert!(validate_out_trade_no("abc123中文").is_err());
|
||||
assert!(validate_out_trade_no("a".repeat(33).as_str()).is_err());
|
||||
|
||||
assert!(validate_notify_url("https://api.example.com/pay/notify", "notify").is_ok());
|
||||
assert!(validate_notify_url("https://api.example.com/pay/notify?x=1", "notify").is_err());
|
||||
assert!(validate_notify_url(&format!("https://{}", "a".repeat(248)), "notify").is_err());
|
||||
|
||||
validate_non_empty_max_chars("陶泥儿 - 60泥点", WECHAT_PAY_DESCRIPTION_MAX_CHARS, "描述")
|
||||
.expect("short description should pass");
|
||||
assert!(
|
||||
validate_non_empty_max_chars(
|
||||
&"泥".repeat(128),
|
||||
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
|
||||
"描述"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
validate_non_empty_max_chars("openid-test", WECHAT_PAY_OPENID_MAX_CHARS, "openid")
|
||||
.expect("short openid should pass");
|
||||
assert!(
|
||||
validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mock_notify_defaults_success_state() {
|
||||
let notify =
|
||||
|
||||
Reference in New Issue
Block a user