From c8bd0aa5b6f1fc9120c2a29c145e9ce4421a481d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 08:11:21 +0000 Subject: [PATCH 1/3] Initial plan From fd349c4a61e059013b34c1fa57c74d65a97021d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 08:21:34 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=BD=AC=E5=8F=91=E5=9C=BA=E6=99=AF=E4=B8=8B=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98V3=20API=20Authorization=E5=A4=B4?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: binarywang <1343140+binarywang@users.noreply.github.com> --- .../binarywang/wxpay/config/WxPayConfig.java | 15 ++ .../binarywang/wxpay/v3/SignatureExec.java | 14 +- .../wxpay/v3/WxPayV3HttpClientBuilder.java | 24 ++- .../auth/AutoUpdateCertificatesVerifier.java | 17 +- .../v3/SignatureExecTrustedHostTest.java | 161 ++++++++++++++++++ 5 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java index 88e544e675..1e0e8d2c46 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java @@ -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; @@ -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(); + 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); diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java index 24d6f26eb5..24c51028df 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java @@ -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 trustedHosts; SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec) { + this(credentials, validator, mainExec, Collections.emptySet()); + } + + SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec, Set trustedHosts) { this.credentials = credentials; this.validator = validator; this.mainExec = mainExec; + this.trustedHosts = trustedHosts != null ? trustedHosts : Collections.emptySet(); } protected HttpEntity newRepeatableEntity(HttpEntity entity) throws IOException { @@ -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); diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java index c88c884f57..f9b1089e09 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java @@ -2,6 +2,8 @@ import java.security.PrivateKey; +import java.util.HashSet; +import java.util.Set; import com.github.binarywang.wxpay.v3.auth.PrivateKeySigner; import com.github.binarywang.wxpay.v3.auth.WxPayCredentials; @@ -12,6 +14,10 @@ public class WxPayV3HttpClientBuilder extends HttpClientBuilder { private Credentials credentials; private Validator validator; + /** + * 额外受信任的主机列表,用于代理转发场景:对这些主机的请求也会携带微信支付 Authorization 头 + */ + private final Set trustedHosts = new HashSet<>(); static final String OS = System.getProperty("os.name") + "/" + System.getProperty("os.version"); static final String VERSION = System.getProperty("java.version"); @@ -47,6 +53,22 @@ public WxPayV3HttpClientBuilder withValidator(Validator validator) { return this; } + /** + * 添加受信任的主机,对该主机的请求也会携带微信支付 Authorization 头. + * 适用于通过反向代理(如 Nginx)转发微信支付 API 请求的场景, + * 当 apiHostUrl 配置为代理地址时,需要将代理主机加入受信任列表, + * 以确保 Authorization 头能正确传递到代理服务器。 + * + * @param host 受信任的主机名(不含端口),例如 "proxy.company.com" + * @return 当前 Builder 实例 + */ + public WxPayV3HttpClientBuilder withTrustedHost(String host) { + if (host != null && !host.isEmpty()) { + this.trustedHosts.add(host); + } + return this; + } + @Override public CloseableHttpClient build() { if (credentials == null) { @@ -61,6 +83,6 @@ 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, this.trustedHosts); } } diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java index 1daf308e5e..0757f58dcc 100755 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java @@ -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; @@ -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); diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java new file mode 100644 index 0000000000..066e8518b5 --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java @@ -0,0 +1,161 @@ +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 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 testWithTrustedHostBuilderMethod() { + WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create(); + // 方法应该支持链式调用 + WxPayV3HttpClientBuilder result = builder.withTrustedHost("proxy.company.com"); + assertSame(result, builder, "withTrustedHost 应该返回当前 Builder 实例(支持链式调用)"); + } + + /** + * 测试:withTrustedHost 传入空值不应该抛出异常 + */ + @Test + public void testWithTrustedHostNullOrEmptyShouldNotThrow() { + WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create(); + // 传入 null 和空字符串不应该抛出异常 + builder.withTrustedHost(null); + builder.withTrustedHost(""); + } + + /** + * 测试: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 头"); + } +} From 9a3cf5221a6083d2c4c43fb198a721f395d50bc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:43:18 +0000 Subject: [PATCH 3/3] Changes before error encountered Co-authored-by: binarywang <1343140+binarywang@users.noreply.github.com> --- .../wxpay/v3/WxPayV3HttpClientBuilder.java | 27 +++++++++-- .../v3/SignatureExecTrustedHostTest.java | 45 +++++++++++++++++-- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java index f9b1089e09..91baa16246 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java @@ -2,6 +2,7 @@ import java.security.PrivateKey; +import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -58,13 +59,30 @@ public WxPayV3HttpClientBuilder withValidator(Validator validator) { * 适用于通过反向代理(如 Nginx)转发微信支付 API 请求的场景, * 当 apiHostUrl 配置为代理地址时,需要将代理主机加入受信任列表, * 以确保 Authorization 头能正确传递到代理服务器。 + * 若传入值包含端口(如 "proxy.company.com:8080"),会自动提取主机名部分。 * - * @param host 受信任的主机名(不含端口),例如 "proxy.company.com" + * @param host 受信任的主机(可含端口),例如 "proxy.company.com" 或 "proxy.company.com:8080" * @return 当前 Builder 实例 */ public WxPayV3HttpClientBuilder withTrustedHost(String host) { - if (host != null && !host.isEmpty()) { - this.trustedHosts.add(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; } @@ -83,6 +101,7 @@ public CloseableHttpClient build() { @Override protected ClientExecChain decorateProtocolExec(final ClientExecChain requestExecutor) { - return new SignatureExec(this.credentials, this.validator, requestExecutor, this.trustedHosts); + return new SignatureExec(this.credentials, this.validator, requestExecutor, + Collections.unmodifiableSet(new HashSet<>(this.trustedHosts))); } } diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java index 066e8518b5..4d9147d9e0 100644 --- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java @@ -120,16 +120,37 @@ public void testTrustedProxyHostShouldAddAuthorizationHeader() throws IOExceptio } /** - * 测试:WxPayV3HttpClientBuilder 的 withTrustedHost 方法应该正确设置受信任主机 + * 测试:WxPayV3HttpClientBuilder 的 withTrustedHost 方法支持链式调用 */ @Test - public void testWithTrustedHostBuilderMethod() { + public void testWithTrustedHostSupportsChainingCall() { WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create(); - // 方法应该支持链式调用 + // 方法应该返回同一实例以支持链式调用 WxPayV3HttpClientBuilder result = builder.withTrustedHost("proxy.company.com"); assertSame(result, builder, "withTrustedHost 应该返回当前 Builder 实例(支持链式调用)"); } + /** + * 测试: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 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 传入空值不应该抛出异常 */ @@ -141,6 +162,24 @@ public void testWithTrustedHostNullOrEmptyShouldNotThrow() { 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)应该仍然有效 */