From 3da9f649f6781ed6059afa5c8b53639a71d670fa Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Fri, 31 Jul 2026 03:52:41 +0000 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E8=AF=81=E4=B9=A6=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=BC=80=E5=85=B3=E6=8E=A5=E5=85=A5=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=B1=A0=E5=B9=B6=E8=A1=A5=E5=85=85=E7=9C=9F=E5=AE=9ETLS?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- weixin-java-common/pom.xml | 6 + .../DefaultApacheHttpClientBuilder.java | 25 ++- .../ServerCertificateVerificationTest.java | 146 +++++++++++++++--- .../src/test/resources/testng.xml | 1 + 4 files changed, 157 insertions(+), 21 deletions(-) diff --git a/weixin-java-common/pom.xml b/weixin-java-common/pom.xml index 5916ab6e3..bc79369bc 100644 --- a/weixin-java-common/pom.xml +++ b/weixin-java-common/pom.xml @@ -122,6 +122,12 @@ assertj-guava test + + + org.bouncycastle + bcpkix-jdk18on + test + org.dom4j dom4j diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/apache/DefaultApacheHttpClientBuilder.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/apache/DefaultApacheHttpClientBuilder.java index 59176557d..1ef22e782 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/apache/DefaultApacheHttpClientBuilder.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/apache/DefaultApacheHttpClientBuilder.java @@ -129,7 +129,13 @@ public class DefaultApacheHttpClientBuilder implements ApacheHttpClientBuilder { private final HttpRequestRetryHandler defaultHttpRequestRetryHandler = (exception, executionCount, context) -> false; - private SSLConnectionSocketFactory sslConnectionSocketFactory = SSLConnectionSocketFactory.getSocketFactory(); + /** + * 默认的SSL连接工厂,用于判断使用方是否自定义过连接工厂 + */ + private static final SSLConnectionSocketFactory DEFAULT_SSL_CONNECTION_SOCKET_FACTORY = + SSLConnectionSocketFactory.getSocketFactory(); + + private SSLConnectionSocketFactory sslConnectionSocketFactory = DEFAULT_SSL_CONNECTION_SOCKET_FACTORY; private final PlainConnectionSocketFactory plainConnectionSocketFactory = PlainConnectionSocketFactory.getSocketFactory(); private String httpProxyHost; private int httpProxyPort; @@ -207,9 +213,10 @@ public class DefaultApacheHttpClientBuilder implements ApacheHttpClientBuilder { if (prepared.get()) { return; } + SSLConnectionSocketFactory httpsSocketFactory = this.resolveSSLConnectionSocketFactory(); Registry registry = RegistryBuilder.create() .register("http", this.plainConnectionSocketFactory) - .register("https", this.sslConnectionSocketFactory) + .register("https", httpsSocketFactory) .build(); PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(registry); @@ -229,7 +236,7 @@ public class DefaultApacheHttpClientBuilder implements ApacheHttpClientBuilder { HttpClientBuilder httpClientBuilder = HttpClients.custom() .setConnectionManager(connectionManager) .setConnectionManagerShared(true) - .setSSLSocketFactory(this.buildSSLConnectionSocketFactory()) + .setSSLSocketFactory(httpsSocketFactory) .setDefaultRequestConfig(RequestConfig.custom() .setSocketTimeout(this.soTimeout) .setConnectTimeout(this.connectionTimeout) @@ -269,6 +276,18 @@ public class DefaultApacheHttpClientBuilder implements ApacheHttpClientBuilder { prepared.set(true); } + /** + * 使用方自定义的连接工厂优先,否则按照证书校验开关构造连接工厂. + */ + private SSLConnectionSocketFactory resolveSSLConnectionSocketFactory() { + if (this.sslConnectionSocketFactory != DEFAULT_SSL_CONNECTION_SOCKET_FACTORY) { + return this.sslConnectionSocketFactory; + } + + SSLConnectionSocketFactory factory = this.buildSSLConnectionSocketFactory(); + return factory == null ? DEFAULT_SSL_CONNECTION_SOCKET_FACTORY : factory; + } + private SSLConnectionSocketFactory buildSSLConnectionSocketFactory() { try { SSLContext sslcontext; diff --git a/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/ServerCertificateVerificationTest.java b/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/ServerCertificateVerificationTest.java index 8d86785d1..fa161b507 100644 --- a/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/ServerCertificateVerificationTest.java +++ b/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/ServerCertificateVerificationTest.java @@ -1,44 +1,154 @@ package me.chanjar.weixin.common.util.http; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; import me.chanjar.weixin.common.util.http.apache.DefaultApacheHttpClientBuilder; import me.chanjar.weixin.common.util.http.hc.DefaultHttpComponentsClientBuilder; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import java.io.OutputStream; import java.lang.reflect.Constructor; +import java.math.BigInteger; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; /** - * 验证默认情况下不会跳过服务器端证书校验,避免中间人攻击. + * 验证默认情况下会校验服务器端证书(避免中间人攻击),且显式跳过校验的开关确实生效. */ public class ServerCertificateVerificationTest { + private static final char[] KEY_STORE_PASSWORD = "wxjava".toCharArray(); + + private HttpsServer httpsServer; + private String selfSignedUrl; + + @BeforeClass + public void startSelfSignedHttpsServer() throws Exception { + KeyStore keyStore = createSelfSignedKeyStore(); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, KEY_STORE_PASSWORD); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), null, null); + + this.httpsServer = HttpsServer.create(new InetSocketAddress(0), 0); + this.httpsServer.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + this.httpsServer.createContext("/", exchange -> { + byte[] body = "ok".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + this.httpsServer.start(); + this.selfSignedUrl = "https://localhost:" + this.httpsServer.getAddress().getPort() + "/"; + } + + @AfterClass(alwaysRun = true) + public void stopSelfSignedHttpsServer() { + if (this.httpsServer != null) { + this.httpsServer.stop(0); + } + } + @Test - public void testApacheBuilderVerifiesCertificateByDefault() throws Exception { + public void testApacheBuilderRejectsUntrustedCertificateByDefault() throws Exception { + DefaultApacheHttpClientBuilder builder = newApacheBuilder(); + Assert.assertFalse(builder.isSkipServerCertificateVerification(), "默认应校验服务器端证书"); + + try (CloseableHttpClient client = builder.build()) { + client.execute(new HttpGet(this.selfSignedUrl)); + Assert.fail("默认配置下应拒绝自签名证书"); + } catch (SSLException e) { + // 期望的结果:证书校验失败 + } + } + + @Test + public void testApacheBuilderAcceptsUntrustedCertificateWhenSkipEnabled() throws Exception { + DefaultApacheHttpClientBuilder builder = newApacheBuilder(); + builder.setSkipServerCertificateVerification(true); + + try (CloseableHttpClient client = builder.build(); + CloseableHttpResponse response = client.execute(new HttpGet(this.selfSignedUrl))) { + Assert.assertEquals(response.getStatusLine().getStatusCode(), 200); + } + } + + @Test + public void testHttpComponentsBuilderRejectsUntrustedCertificateByDefault() throws Exception { + DefaultHttpComponentsClientBuilder builder = newHttpComponentsBuilder(); + Assert.assertFalse(builder.isSkipServerCertificateVerification(), "默认应校验服务器端证书"); + + try (org.apache.hc.client5.http.impl.classic.CloseableHttpClient client = builder.build()) { + client.execute(new org.apache.hc.client5.http.classic.methods.HttpGet(this.selfSignedUrl)); + Assert.fail("默认配置下应拒绝自签名证书"); + } catch (SSLException e) { + // 期望的结果:证书校验失败 + } + } + + @Test + public void testHttpComponentsBuilderAcceptsUntrustedCertificateWhenSkipEnabled() throws Exception { + DefaultHttpComponentsClientBuilder builder = newHttpComponentsBuilder(); + builder.setSkipServerCertificateVerification(true); + + try (org.apache.hc.client5.http.impl.classic.CloseableHttpClient client = builder.build(); + org.apache.hc.client5.http.impl.classic.CloseableHttpResponse response = + client.execute(new org.apache.hc.client5.http.classic.methods.HttpGet(this.selfSignedUrl))) { + Assert.assertEquals(response.getCode(), 200); + } + } + + private DefaultApacheHttpClientBuilder newApacheBuilder() throws Exception { Constructor constructor = DefaultApacheHttpClientBuilder.class.getDeclaredConstructor(); constructor.setAccessible(true); - DefaultApacheHttpClientBuilder builder = constructor.newInstance(); - - Assert.assertFalse(builder.isSkipServerCertificateVerification(), - "默认应校验服务器端证书"); - - builder.setSkipServerCertificateVerification(true); - Assert.assertTrue(builder.isSkipServerCertificateVerification(), - "特殊调试场景下应允许显式跳过证书校验"); + return constructor.newInstance(); } - @Test - public void testHttpComponentsBuilderVerifiesCertificateByDefault() throws Exception { + private DefaultHttpComponentsClientBuilder newHttpComponentsBuilder() throws Exception { Constructor constructor = DefaultHttpComponentsClientBuilder.class.getDeclaredConstructor(); constructor.setAccessible(true); - DefaultHttpComponentsClientBuilder builder = constructor.newInstance(); + return constructor.newInstance(); + } - Assert.assertFalse(builder.isSkipServerCertificateVerification(), - "默认应校验服务器端证书"); + private KeyStore createSelfSignedKeyStore() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); - builder.setSkipServerCertificateVerification(true); - Assert.assertTrue(builder.isSkipServerCertificateVerification(), - "特殊调试场景下应允许显式跳过证书校验"); + long now = System.currentTimeMillis(); + X500Name subject = new X500Name("CN=localhost"); + X509Certificate certificate = new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder(subject, BigInteger.valueOf(now), + new Date(now - 86400_000L), new Date(now + 86400_000L), subject, keyPair.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()))); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setKeyEntry("wxjava-test", keyPair.getPrivate(), KEY_STORE_PASSWORD, + new Certificate[]{certificate}); + return keyStore; } } diff --git a/weixin-java-common/src/test/resources/testng.xml b/weixin-java-common/src/test/resources/testng.xml index 9eeba0df4..ec91705e3 100644 --- a/weixin-java-common/src/test/resources/testng.xml +++ b/weixin-java-common/src/test/resources/testng.xml @@ -9,6 +9,7 @@ +