-
-
Notifications
You must be signed in to change notification settings - Fork 9.1k
修复:代理转发场景下微信支付 V3 API Authorization 头丢失导致 401 #3910
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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"); | ||
|
|
@@ -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
|
||
| } | ||
|
|
||
| @Override | ||
| public CloseableHttpClient build() { | ||
| if (credentials == null) { | ||
|
|
@@ -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
|
||
| } | ||
| 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
|
||
|
|
||
| /** | ||
| * 测试: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 头"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.