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
6 changes: 6 additions & 0 deletions weixin-java-common/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@
<artifactId>assertj-guava</artifactId>
<scope>test</scope>
</dependency>
<!-- 测试中生成自签名证书,用于验证TLS证书校验行为 -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.dom4j</groupId>
<artifactId>dom4j</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package me.chanjar.weixin.common.util;

import java.security.SecureRandom;

public class RandomUtils {

private static final String RANDOM_STR = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

private static volatile java.util.Random random;
private static volatile SecureRandom random;

private static java.util.Random getRandom() {
private static SecureRandom getRandom() {
if (random == null) {
synchronized (RandomUtils.class) {
if (random == null) {
random = new java.util.Random();
random = new SecureRandom();
}
}
}
Expand All @@ -19,7 +21,7 @@ private static java.util.Random getRandom() {

public static String getRandomStr() {
StringBuilder sb = new StringBuilder();
java.util.Random r = getRandom();
SecureRandom r = getRandom();
for (int i = 0; i < 16; i++) {
sb.append(RANDOM_STR.charAt(r.nextInt(RANDOM_STR.length())));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
import java.io.StringReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Random;

/**
* <pre>
Expand All @@ -37,13 +38,13 @@ public class WxCryptUtil {
private static final Base64 BASE64 = new Base64();
private static final Charset CHARSET = StandardCharsets.UTF_8;

private static volatile Random random;
private static volatile SecureRandom random;

private static Random getRandom() {
private static SecureRandom getRandom() {
if (random == null) {
synchronized (WxCryptUtil.class) {
if (random == null) {
random = new Random();
random = new SecureRandom();
}
}
}
Expand Down Expand Up @@ -122,7 +123,7 @@ private static int bytesNetworkOrder2Number(byte[] bytesInNetworkOrder) {
*/
private static String genRandomStr() {
String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
Random r = getRandom();
SecureRandom r = getRandom();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 16; i++) {
int number = r.nextInt(base.length());
Expand All @@ -131,6 +132,20 @@ private static String genRandomStr() {
return sb.toString();
}

/**
* 以固定时间的方式比较两个签名,避免时序攻击.
*
* @param expected 本地计算出的签名
* @param actual 请求中携带的签名
* @return 两者是否一致
*/
private static boolean isSignatureEqual(String expected, String actual) {
if (expected == null || actual == null) {
return false;
}
return MessageDigest.isEqual(expected.getBytes(CHARSET), actual.getBytes(CHARSET));
}

/**
* 生成xml消息.
*
Expand Down Expand Up @@ -296,7 +311,7 @@ public String decrypt(String msgSignature, String timeStamp, String nonce, Strin
public String decryptContent(String msgSignature, String timeStamp, String nonce, String encryptedContent) {
// 验证安全签名
String signature = SHA1.gen(this.token, timeStamp, nonce, encryptedContent);
if (!signature.equals(msgSignature)) {
if (!isSignatureEqual(signature, msgSignature)) {
throw new WxRuntimeException("加密消息签名校验失败");
}
// 解密
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ public class DefaultApacheHttpClientBuilder implements ApacheHttpClientBuilder {
*/
private String[] supportedProtocols = {"TLSv1.2", "TLSv1.3", "TLSv1.1", "TLSv1"};

/**
* 是否跳过服务器端证书校验,默认false,即校验证书。
* <p>
* 仅在自签名证书的抓包代理等特殊调试场景下才可以设置为true,生产环境开启会导致中间人攻击风险。
* </p>
*/
private boolean skipServerCertificateVerification = false;

/**
* 自定义请求拦截器
*/
Expand All @@ -121,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;
Expand Down Expand Up @@ -199,9 +213,10 @@ private synchronized void prepare() {
if (prepared.get()) {
return;
}
SSLConnectionSocketFactory httpsSocketFactory = this.resolveSSLConnectionSocketFactory();
Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
.register("http", this.plainConnectionSocketFactory)
.register("https", this.sslConnectionSocketFactory)
.register("https", httpsSocketFactory)
.build();

PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(registry);
Expand All @@ -221,7 +236,7 @@ private synchronized void prepare() {
HttpClientBuilder httpClientBuilder = HttpClients.custom()
.setConnectionManager(connectionManager)
.setConnectionManagerShared(true)
.setSSLSocketFactory(this.buildSSLConnectionSocketFactory())
.setSSLSocketFactory(httpsSocketFactory)
.setDefaultRequestConfig(RequestConfig.custom()
.setSocketTimeout(this.soTimeout)
.setConnectTimeout(this.connectionTimeout)
Expand Down Expand Up @@ -261,11 +276,27 @@ private synchronized void prepare() {
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 = SSLContexts.custom()
SSLContext sslcontext;
if (this.skipServerCertificateVerification) {
//忽略掉对服务器端证书的校验
.loadTrustMaterial((TrustStrategy) (chain, authType) -> true).build();
sslcontext = SSLContexts.custom().loadTrustMaterial((TrustStrategy) (chain, authType) -> true).build();
Comment on lines +294 to +296

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 将跳过证书开关接入 Apache 连接池

当调用者把 DefaultApacheHttpClientBuilder.get().setSkipServerCertificateVerification(true) 用于自签名抓包或测试环境时,这里创建的 trust-all SSLConnectionSocketFactory 实际不会参与建连:prepare() 已经用注册表里的 this.sslConnectionSocketFactory 构造了 PoolingHttpClientConnectionManager,随后在 builder 上调用的 setSSLSocketFactory(...) 会被该 connection manager 覆盖。因此 Apache 4 默认客户端仍会按默认 trust store 校验证书,和本次改好的 HC5 客户端行为不一致;应把根据开关生成的 socket factory 注册进 connection manager。

Useful? React with 👍 / 👎.

} else {
sslcontext = SSLContexts.createSystemDefault();
}

return new SSLConnectionSocketFactory(
sslcontext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
import org.apache.hc.client5.http.ssl.TrustAllStrategy;
import org.apache.hc.core5.http.HttpHost;
Expand Down Expand Up @@ -94,6 +95,14 @@ public class DefaultHttpComponentsClientBuilder implements HttpComponentsClientB
*/
private String userAgent;

/**
* 是否跳过服务器端证书校验,默认false,即校验证书。
* <p>
* 仅在自签名证书的抓包代理等特殊调试场景下才可以设置为true,生产环境开启会导致中间人攻击风险。
* </p>
*/
private boolean skipServerCertificateVerification = false;

/**
* 自定义请求拦截器
*/
Expand Down Expand Up @@ -173,16 +182,21 @@ private synchronized void prepare() {

SSLContext sslcontext;
try {
sslcontext = SSLContexts.custom()
.loadTrustMaterial(TrustAllStrategy.INSTANCE) // 忽略对服务器端证书的校验
.build();
if (this.skipServerCertificateVerification) {
sslcontext = SSLContexts.custom()
.loadTrustMaterial(TrustAllStrategy.INSTANCE) // 忽略对服务器端证书的校验
.build();
} else {
sslcontext = SSLContexts.createSystemDefault();
}
} catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException e) {
log.error("构建 SSLContext 时发生异常!", e);
throw new RuntimeException(e);
}

PoolingHttpClientConnectionManager connManager = PoolingHttpClientConnectionManagerBuilder.create()
.setTlsSocketStrategy(new DefaultClientTlsStrategy(sslcontext, NoopHostnameVerifier.INSTANCE))
.setTlsSocketStrategy(new DefaultClientTlsStrategy(sslcontext,
this.skipServerCertificateVerification ? NoopHostnameVerifier.INSTANCE : new DefaultHostnameVerifier()))
.setMaxConnTotal(this.maxTotalConn)
.setMaxConnPerRoute(this.maxConnPerHost)
.setDefaultSocketConfig(SocketConfig.custom()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +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 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<DefaultApacheHttpClientBuilder> constructor =
DefaultApacheHttpClientBuilder.class.getDeclaredConstructor();
constructor.setAccessible(true);
return constructor.newInstance();
}

private DefaultHttpComponentsClientBuilder newHttpComponentsBuilder() throws Exception {
Constructor<DefaultHttpComponentsClientBuilder> constructor =
DefaultHttpComponentsClientBuilder.class.getDeclaredConstructor();
constructor.setAccessible(true);
return constructor.newInstance();
}

private KeyStore createSelfSignedKeyStore() throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
KeyPair keyPair = keyPairGenerator.generateKeyPair();

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;
}
}
Loading