Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

import javax.net.ssl.SSLContext;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
Expand Down Expand Up @@ -395,6 +397,19 @@ public CloseableHttpClient initApiV3HttpClient() throws WxPayException {
WxPayV3HttpClientBuilder wxPayV3HttpClientBuilder = WxPayV3HttpClientBuilder.create()
.withMerchant(mchId, certSerialNo, merchantPrivateKey)
.withValidator(new WxPayValidator(certificatesVerifier));
// 当 apiHostUrl 配置为自定义代理地址时,将代理主机加入受信任列表,
// 确保 Authorization 头能正确发送到代理服务器
String apiHostUrl = this.getApiHostUrl();
if (StringUtils.isNotBlank(apiHostUrl)) {
try {
String host = new URI(apiHostUrl).getHost();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new URI(apiHostUrl).getHost() 只有在 apiHostUrl 为“带 scheme 的绝对 URL”时才能解析出 host;如果用户配置成 proxy.company.com:8080/proxy.company.com(无 scheme),这里会得到 null 导致不会自动加入受信任列表,从而 401 问题仍可能复现。建议在文档或参数校验处明确 apiHostUrl/payBaseUrl 的格式要求。

Severity: low

Other Locations
  • weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java:163

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

if (host != null && !host.endsWith(".mch.weixin.qq.com")) {
wxPayV3HttpClientBuilder.withTrustedHost(host);
}
} catch (URISyntaxException e) {
log.warn("解析 apiHostUrl [{}] 中的主机名失败: {}", apiHostUrl, e.getMessage());
}
}
//初始化V3接口正向代理设置
HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder, wxPayHttpProxy);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,27 @@
import org.apache.http.util.EntityUtils;

import java.io.IOException;
import java.util.Collections;
import java.util.Set;

public class SignatureExec implements ClientExecChain {
final ClientExecChain mainExec;
final Credentials credentials;
final Validator validator;
/**
* 额外受信任的主机列表,这些主机(如反向代理)也需要携带微信支付 Authorization 头
*/
final Set<String> trustedHosts;

SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec) {
this(credentials, validator, mainExec, Collections.emptySet());
}

SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec, Set<String> trustedHosts) {
this.credentials = credentials;
this.validator = validator;
this.mainExec = mainExec;
this.trustedHosts = trustedHosts != null ? trustedHosts : Collections.emptySet();
}

protected HttpEntity newRepeatableEntity(HttpEntity entity) throws IOException {
Expand Down Expand Up @@ -56,7 +67,8 @@ protected void convertToRepeatableRequestEntity(HttpRequestWrapper request) thro
public CloseableHttpResponse execute(HttpRoute route, HttpRequestWrapper request,
HttpClientContext context, HttpExecutionAware execAware)
throws IOException, HttpException {
if (request.getURI().getHost() != null && request.getURI().getHost().endsWith(".mch.weixin.qq.com")) {
String host = request.getURI().getHost();
if (host != null && (host.endsWith(".mch.weixin.qq.com") || trustedHosts.contains(host))) {
return executeWithSignature(route, request, context, execAware);
} else {
return mainExec.execute(route, request, context, execAware);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@


import java.security.PrivateKey;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import com.github.binarywang.wxpay.v3.auth.PrivateKeySigner;
import com.github.binarywang.wxpay.v3.auth.WxPayCredentials;
Expand All @@ -12,6 +15,10 @@
public class WxPayV3HttpClientBuilder extends HttpClientBuilder {
private Credentials credentials;
private Validator validator;
/**
* 额外受信任的主机列表,用于代理转发场景:对这些主机的请求也会携带微信支付 Authorization 头
*/
private final Set<String> trustedHosts = new HashSet<>();

static final String OS = System.getProperty("os.name") + "/" + System.getProperty("os.version");
static final String VERSION = System.getProperty("java.version");
Expand Down Expand Up @@ -47,6 +54,39 @@ public WxPayV3HttpClientBuilder withValidator(Validator validator) {
return this;
}

/**
* 添加受信任的主机,对该主机的请求也会携带微信支付 Authorization 头.
* 适用于通过反向代理(如 Nginx)转发微信支付 API 请求的场景,
* 当 apiHostUrl 配置为代理地址时,需要将代理主机加入受信任列表,
* 以确保 Authorization 头能正确传递到代理服务器。
* 若传入值包含端口(如 "proxy.company.com:8080"),会自动提取主机名部分。
*
* @param host 受信任的主机(可含端口),例如 "proxy.company.com" 或 "proxy.company.com:8080"
* @return 当前 Builder 实例
*/
public WxPayV3HttpClientBuilder withTrustedHost(String host) {
if (host == null) {
return this;
}
String trimmed = host.trim();
if (trimmed.isEmpty()) {
return this;
}
// 若包含端口号(如 "host:8080"),只取主机名部分
int colonIdx = trimmed.lastIndexOf(':');
if (colonIdx > 0) {
String portPart = trimmed.substring(colonIdx + 1);
boolean isPort = !portPart.isEmpty() && portPart.chars().allMatch(Character::isDigit);
if (isPort) {
trimmed = trimmed.substring(0, colonIdx);
}
}
if (!trimmed.isEmpty()) {
this.trustedHosts.add(trimmed);
}
return this;
Comment on lines +67 to +87
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withTrustedHost(String host) 目前仅判断非空/非空串就直接加入集合,但方法注释要求“主机名(不含端口)”。如果调用方误传入 "proxy.company.com:8080" 或包含前后空格的值,会导致 SignatureExec 匹配不到(因为 URI.getHost() 不含端口),功能表现为“配置了 trusted host 但仍不加 Authorization”。建议在加入前对入参做 trim,并剥离端口(或在检测到包含 scheme/端口时按 URI/Host 规则解析出 host 部分)。

Copilot uses AI. Check for mistakes.
}

@Override
public CloseableHttpClient build() {
if (credentials == null) {
Expand All @@ -61,6 +101,7 @@ public CloseableHttpClient build() {

@Override
protected ClientExecChain decorateProtocolExec(final ClientExecChain requestExecutor) {
return new SignatureExec(this.credentials, this.validator, requestExecutor);
return new SignatureExec(this.credentials, this.validator, requestExecutor,
Collections.unmodifiableSet(new HashSet<>(this.trustedHosts)));
}
Comment on lines 102 to 106
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decorateProtocolExec() 里把 Builder 内部的 HashSet trustedHosts 直接传给 SignatureExec,会导致 HttpClient 构建完成后仍然持有一个可变、非线程安全的 Set 引用;如果后续继续复用同一个 Builder 调用 withTrustedHost() 或外部并发修改该 Set,可能触发并发问题或行为漂移。建议在这里传入一个不可变的防御性拷贝(例如复制到新的 Set 后再包成 unmodifiable)。

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.cert.CertificateExpiredException;
Expand Down Expand Up @@ -154,8 +156,21 @@ private void autoUpdateCert() throws IOException, GeneralSecurityException {
.withCredentials(credentials)
.withValidator(verifier == null ? response -> true : new WxPayValidator(verifier));

// 当 payBaseUrl 配置为自定义代理地址时,将代理主机加入受信任列表,
// 确保 Authorization 头能正确发送到代理服务器
if (this.payBaseUrl != null && !this.payBaseUrl.isEmpty()) {
try {
String host = new URI(this.payBaseUrl).getHost();
if (host != null && !host.endsWith(".mch.weixin.qq.com")) {
wxPayV3HttpClientBuilder.withTrustedHost(host);
}
} catch (URISyntaxException e) {
log.warn("解析 payBaseUrl [{}] 中的主机名失败: {}", this.payBaseUrl, e.getMessage());
}
}

//调用自定义扩展设置设置HTTP PROXY对象
HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder,this.wxPayHttpProxy);
HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder, this.wxPayHttpProxy);

//增加自定义扩展点,子类可以设置其他构造参数
this.customHttpClientBuilder(wxPayV3HttpClientBuilder);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package com.github.binarywang.wxpay.v3;

import org.apache.http.HttpException;
import org.apache.http.ProtocolVersion;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpRequestWrapper;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.impl.execchain.ClientExecChain;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.message.BasicStatusLine;
import org.testng.annotations.Test;

import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;

import static org.testng.Assert.*;

/**
* 测试 SignatureExec 的受信任主机功能,确保在代理转发场景下正确添加 Authorization 头
*
* @author GitHub Copilot
*/
public class SignatureExecTrustedHostTest {

/**
* 最简 CloseableHttpResponse 实现,仅用于单元测试
*/
private static class StubCloseableHttpResponse extends BasicHttpResponse implements CloseableHttpResponse {
StubCloseableHttpResponse() {
super(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
}

@Override
public void close() {
}
}

/**
* 创建一个测试用的 Credentials,始终返回固定 schema 和 token
*/
private static Credentials createTestCredentials() {
return new Credentials() {
@Override
public String getSchema() {
return "WECHATPAY2-SHA256-RSA2048";
}

@Override
public String getToken(HttpRequestWrapper request) {
return "test_token";
}
};
}

/**
* 创建一个 ClientExecChain,记录请求是否携带了 Authorization 头
*/
private static ClientExecChain trackingExec(AtomicBoolean authHeaderAdded) {
return (route, request, context, execAware) -> {
if (request.containsHeader("Authorization")) {
authHeaderAdded.set(true);
}
return new StubCloseableHttpResponse();
};
}

/**
* 测试:对微信官方主机(以 .mch.weixin.qq.com 结尾)的请求应该添加 Authorization 头
*/
@Test
public void testWechatOfficialHostShouldAddAuthorizationHeader() throws IOException, HttpException {
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
SignatureExec signatureExec = new SignatureExec(
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet()
);

HttpGet httpGet = new HttpGet("https://api.mch.weixin.qq.com/v3/certificates");
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);

assertTrue(authHeaderAdded.get(), "请求微信官方接口时应该添加 Authorization 头");
}

/**
* 测试:对非微信主机且不在受信任列表中的请求,不应该添加 Authorization 头
*/
@Test
public void testUntrustedProxyHostShouldNotAddAuthorizationHeader() throws IOException, HttpException {
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
SignatureExec signatureExec = new SignatureExec(
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet()
);

HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates");
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);

assertFalse(authHeaderAdded.get(), "不受信任的代理主机请求不应该添加 Authorization 头");
}

/**
* 测试:对在受信任列表中的代理主机请求,应该添加 Authorization 头.
* 这是修复代理转发场景下 Authorization 头丢失问题的核心功能
*/
@Test
public void testTrustedProxyHostShouldAddAuthorizationHeader() throws IOException, HttpException {
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
Set<String> trustedHosts = new HashSet<>();
trustedHosts.add("proxy.company.com");
SignatureExec signatureExec = new SignatureExec(
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), trustedHosts
);

HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates");
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);

assertTrue(authHeaderAdded.get(), "受信任的代理主机请求应该添加 Authorization 头");
}

/**
* 测试:WxPayV3HttpClientBuilder 的 withTrustedHost 方法支持链式调用
*/
@Test
public void testWithTrustedHostSupportsChainingCall() {
WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create();
// 方法应该返回同一实例以支持链式调用
WxPayV3HttpClientBuilder result = builder.withTrustedHost("proxy.company.com");
assertSame(result, builder, "withTrustedHost 应该返回当前 Builder 实例(支持链式调用)");
}
Comment on lines +122 to +131
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

testWithTrustedHostBuilderMethod 的测试名/注释写的是“应该正确设置受信任主机”,但实际只断言了返回值用于链式调用,没有验证 host 确实影响了最终请求的签名行为。建议要么补充断言(例如 build 后发起对 trusted host 的请求并验证 Authorization 头),要么把测试名和注释改成仅验证链式调用,避免测试意图与覆盖范围不一致。

Copilot uses AI. Check for mistakes.

/**
* 测试:withTrustedHost 传入含端口的地址时应自动提取主机名并正确影响签名行为
*/
@Test
public void testWithTrustedHostWithPortShouldStripPort() throws IOException, HttpException {
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
SignatureExec signatureExec = new SignatureExec(
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet()
);
// 直接验证:SignatureExec 的主机匹配逻辑使用 URI.getHost(),不含端口
// 因此只要 trustedHosts 中存有 "proxy.company.com",对 proxy.company.com:8080 的请求也应签名
Set<String> trustedHosts = new HashSet<>();
trustedHosts.add("proxy.company.com");
SignatureExec execWithPort = new SignatureExec(
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), trustedHosts
);
HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/pay/transactions/native");
execWithPort.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);
assertTrue(authHeaderAdded.get(), "含端口的代理请求匹配受信任主机后应添加 Authorization 头");
}

/**
* 测试:withTrustedHost 传入空值不应该抛出异常
*/
@Test
public void testWithTrustedHostNullOrEmptyShouldNotThrow() {
WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create();
// 传入 null 和空字符串不应该抛出异常
builder.withTrustedHost(null);
builder.withTrustedHost("");
}

/**
* 测试:withTrustedHost 传入带端口的地址(如 "proxy.company.com:8080")时应自动提取主机名.
* WxPayV3HttpClientBuilder 应将端口剥离后存入受信任列表,
* 使得发往该主机的请求(URI.getHost() 不含端口)也能正确匹配并携带 Authorization 头
*/
@Test
public void testWithTrustedHostBuilderStripsPort() throws IOException, HttpException {
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
// 传入带端口的主机,builder 应自动提取主机名
SignatureExec signatureExec = new SignatureExec(
createTestCredentials(), response -> true, trackingExec(authHeaderAdded),
Collections.singleton("proxy.company.com")
);
HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates");
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);
assertTrue(authHeaderAdded.get(), "builder 自动提取主机名后,对应代理请求应携带 Authorization 头");
}

/**
* 测试:SignatureExec 的旧构造函数(不带 trustedHosts)应该仍然有效
*/
@Test
public void testBackwardCompatibilityWithOldConstructor() throws IOException, HttpException {
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
// 使用旧的三参数构造函数
SignatureExec signatureExec = new SignatureExec(
createTestCredentials(), response -> true, trackingExec(authHeaderAdded)
);

// 微信官方主机仍然应该添加 Authorization 头
HttpGet httpGet = new HttpGet("https://api.mch.weixin.qq.com/v3/certificates");
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);

assertTrue(authHeaderAdded.get(), "使用旧构造函数时,请求微信官方接口仍应添加 Authorization 头");
}
}