CLOVER🍀

That was when it all began.

Apache HttpComponents/Clientで、SSL証明書の検証、ホスト名の検証を無効化する

開発中のテスト環境とかで、よくあるネタ?的な。

Apache HttpComponents/Clientを使って、SSL自己署名証明書を使って通信したり、ホスト名の検証を無効化する方法について、メモとして書いておきます。

Apache HttpComponents – Apache HttpComponents

いつも微妙にやり方を忘れて、毎度毎度調べることになっているので、備忘録的にと。

もちろん、ご利用はテストなどでの範囲で、ですね。

ちなみに、java.net.URLConnectionを使う場合については、以前書きました。

JavaでSSL証明書の検証無効化、ホスト名検証の無効化…とデバッグ - CLOVER

準備

Apache HttpComponentsって、バージョンでけっこうコロコロAPIが変わるので、「これ!」というのは言いづらいのですが、今回はApache HttpComponents(というかClient)の4.5.1を対象にします。

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.1</version>
        </dependency>

テストコードには、JUnit+AssertJを利用。

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.2.0</version>
            <scope>test</scope>
        </dependency>

また、テスト用のSSLを有効にしたWebサーバーは、Ubuntu LinuxでインストールできるSSLを有効にしたApacheとしました。

サンプルコードで使うimport文

以降のサンプルコードでは、以下のimport文があることを前提にしています。

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;

import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.TrustSelfSignedStrategy;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.ssl.SSLContexts;
import org.apache.http.ssl.TrustStrategy;
import org.apache.http.util.EntityUtils;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

ではでは、書いていってみます。

事象その1、自己署名証明書なのでエラーになる

通信時に、こんなスタックトレースが出力されるようなケース。

javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at sun.security.ssl.Alerts.getSSLException(Alerts.java:192)
	at sun.security.ssl.SSLSocketImpl.fatal(SSLSocketImpl.java:1949)
	at sun.security.ssl.Handshaker.fatalSE(Handshaker.java:302)
	at sun.security.ssl.Handshaker.fatalSE(Handshaker.java:296)
	at sun.security.ssl.ClientHandshaker.serverCertificate(ClientHandshaker.java:1509)
	at sun.security.ssl.ClientHandshaker.processMessage(ClientHandshaker.java:216)
	at sun.security.ssl.Handshaker.processLoop(Handshaker.java:979)
	at sun.security.ssl.Handshaker.process_record(Handshaker.java:914)
	at sun.security.ssl.SSLSocketImpl.readRecord(SSLSocketImpl.java:1062)
	at sun.security.ssl.SSLSocketImpl.performInitialHandshake(SSLSocketImpl.java:1375)
	at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1403)
	at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1387)

SSL証明書の検証で、NGとなる場合ですね。自己署名証明書だと、これに遭遇すると思います。

テストコードは、こんな感じ。

    @Test
    public void testSelfSignedFailure() throws IOException {
        try (CloseableHttpClient client = HttpClients.createDefault()) {
            HttpGet get = new HttpGet("https://localhost");

            assertThatThrownBy(() -> {
                try (CloseableHttpResponse response = client.execute(get)) {
                    assertThat(response.getStatusLine().getStatusCode())
                            .isEqualTo(HttpStatus.SC_OK);
                    assertThat(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))
                            .contains("apache2");
                }
            })
                    .isInstanceOf(SSLHandshakeException.class)
                    .hasMessageContaining("sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target");
        }
    }

自己署名証明書でもOKにするには

これをパスするには、以下のようなコードを書いてSSLContextを作成します。

        TrustStrategy trustStrategy = new TrustSelfSignedStrategy();

        SSLContext sslContext =
                SSLContexts
                        .custom()
                        .loadTrustMaterial(trustStrategy)
                        .build();

TrustStrategyの実装である、TrustSelfSignedStrategyを使用することで自己署名証明書でもOKになりますよ、と。

で、こちらを使ってHttpClientを作成する、と。

            try (CloseableHttpClient client =
                         HttpClients
                                 .custom()
                                 .setSSLContext(sslContext)
                                 .build()) {
                HttpGet get = new HttpGet("https://localhost");

これで、証明書のエラーは回避できます。

事象その2、ホスト名の検証でエラーになる

これだけではまだエラーになる場合が、SSL証明書に書かれているホスト名が、実際にアクセスしているホストと合わない場合。

このケースだと、このようなスタックトレースが得られます。

javax.net.ssl.SSLPeerUnverifiedException: Host name 'localhost' does not match the certificate subject provided by the peer (CN=e611e15f9c9d)
	at org.apache.http.conn.ssl.SSLConnectionSocketFactory.verifyHostname(SSLConnectionSocketFactory.java:465)
	at org.apache.http.conn.ssl.SSLConnectionSocketFactory.createLayeredSocket(SSLConnectionSocketFactory.java:395)
	at org.apache.http.conn.ssl.SSLConnectionSocketFactory.connectSocket(SSLConnectionSocketFactory.java:353)
	at org.apache.http.impl.conn.DefaultHttpClientConnectionOperator.connect(DefaultHttpClientConnectionOperator.java:134)
	at org.apache.http.impl.conn.PoolingHttpClientConnectionManager.connect(PoolingHttpClientConnectionManager.java:353)
	at org.apache.http.impl.execchain.MainClientExec.establishRoute(MainClientExec.java:380)
	at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:236)
	at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:184)
	at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:88)
	at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:110)
	at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:184)
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:82)
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:107)

先ほどのSSL証明書の検証を行わないだけのコードは、このような感じになっています。

    @Test
    public void testHostNameFailure() throws IOException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
        TrustStrategy trustStrategy = new TrustSelfSignedStrategy();

        SSLContext sslContext =
                SSLContexts
                        .custom()
                        .loadTrustMaterial(trustStrategy)
                        .build();
        assertThatThrownBy(() -> {
            try (CloseableHttpClient client =
                         HttpClients
                                 .custom()
                                 .setSSLContext(sslContext)
                                 .build()) {
                HttpGet get = new HttpGet("https://localhost");

                try (CloseableHttpResponse response = client.execute(get)) {
                    assertThat(response.getStatusLine().getStatusCode())
                            .isEqualTo(HttpStatus.SC_OK);
                    assertThat(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))
                            .contains("apache2");
                }
            }
        })
                .isInstanceOf(SSLPeerUnverifiedException.class)
                .hasMessageContaining("Host name 'localhost' does not match the certificate subject provided by the peer (CN=e611e15f9c9d)");
    }

が、これだと今回用意した環境では、ホスト名が証明書とアクセスパスで不一致のため、エラーになると。

そこで、ここではHostnameVerifierを使用します。

        HostnameVerifier hostnameVerifier = NoopHostnameVerifier.INSTANCE;

NoopHostnameVerifierを使用すると、ホスト名の検証を無効化できます。

SSL証明書の検証と、ホスト名の検証を全部合わせたコードは、このような形になります。

    @Test
    public void testSelfSignedSuccess() throws IOException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
        TrustStrategy trustStrategy = new TrustSelfSignedStrategy();

        SSLContext sslContext =
                SSLContexts
                        .custom()
                        .loadTrustMaterial(trustStrategy)
                        .build();

        HostnameVerifier hostnameVerifier = NoopHostnameVerifier.INSTANCE;
        try (CloseableHttpClient client =
                     HttpClients
                             .custom()
                             .setSSLContext(sslContext)
                             .setSSLHostnameVerifier(hostnameVerifier)
                             .build()) {
            HttpGet get = new HttpGet("https://localhost");

            try (CloseableHttpResponse response = client.execute(get)) {
                assertThat(response.getStatusLine().getStatusCode())
                        .isEqualTo(HttpStatus.SC_OK);
                assertThat(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))
                        .contains("apache2");
            }
        }
    }

HostnameVerifierは、setSSLHostnameVerifierで指定します。

        try (CloseableHttpClient client =
                     HttpClients
                             .custom()
                             .setSSLContext(sslContext)
                             .setSSLHostnameVerifier(hostnameVerifier)
                             .build()) {

とりあえず、目的は達成できましたよっと。

別解

SSLContextとSSLConnectionSocketFactoryの組み合わせでも、同様のことが実現できます。

    @Test
    public void testSelfSignedSuccessAnother() throws KeyStoreException, NoSuchAlgorithmException, KeyManagementException, IOException {
        TrustStrategy trustStrategy = new TrustSelfSignedStrategy();

        SSLContext sslContext =
                SSLContexts
                        .custom()
                        .loadTrustMaterial(trustStrategy)
                        .build();

        HostnameVerifier hostnameVerifier = NoopHostnameVerifier.INSTANCE;

        SSLConnectionSocketFactory sslConnSocketFactory = new
                SSLConnectionSocketFactory(sslContext,
                hostnameVerifier);

        try (CloseableHttpClient client =
                     HttpClients
                             .custom()
                             .setSSLSocketFactory(sslConnSocketFactory)
                             .build()) {
            HttpGet get = new HttpGet("https://localhost");

            try (CloseableHttpResponse response = client.execute(get)) {
                assertThat(response.getStatusLine().getStatusCode())
                        .isEqualTo(HttpStatus.SC_OK);
                assertThat(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))
                        .contains("apache2");
            }
        }
    }

この場合は、setSSLSocketFactoryを使用します。

        try (CloseableHttpClient client =
                     HttpClients
                             .custom()
                             .setSSLSocketFactory(sslConnSocketFactory)
                             .build()) {

以上でしたー。

参考)
JSSE_Fortify_SCA_Rules/ApacheHTTPClientFluentExample.java at master · GDSSecurity/JSSE_Fortify_SCA_Rules · GitHub
How do you create SSL socket factory in new Apache Http Client 4.3? - Stack Overflow