HttpClientで異なるホスト名のSSL証明書を許可する方法

#はじめに
Apache HttpComponentsにより、SSL通信を行う際には当然ですが、ホスト名の検証が行われます。したがって、例えば`https://google.com/`の代わりに`https://216.58.220.227/`に接続した場合、SSLExceptionが発生します。ただし、環境の移行時等にはIPアドレスでアクセスする必要が生じたりするため、SSL証明書を無視することなく接続する方法を調べてみました。

#簡単に
ここで、証明書のホスト名を無視するだけであれば、`SSLSocketFactory`クラスの`setHostnameVerifier`メソッドに`ALLOW_ALL_HOSTNAME_VERIFIER`を渡せば、例外は生じなくなります。次に`HttpClient`の生成例を示します。

```java
HttpParams params = new BasicHttpParams();
SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
SSLSocketFactory sslSocketFactory = SSLSocketFactory.getSocketFactory();
// ホスト名の検証を行わない。
sslSocketFactory.setHostnameVerifier(ALLOW_ALL_HOSTNAME_VERIFIER);
registry.register(new Scheme("https", sslSocketFactory, 443));
ThreadSafeClientConnManager clientConnManager = new ThreadSafeClientConnManager(params, registry);
HttpClient httpClient = new DefaultHttpClient(clientConnManager , params);
```

#安全に
前述の方法によると、ホスト名の検証を行わないので、証明書がルート証明書から正しくつながっていれば、その通信を許可してしまいます。ですから、宛先ホスト以外の任意のドメインに属するSSL証明書を持った人間でも、通信を傍受することが可能になります。

そこで、ホスト名の変換テーブルを検証器に持たせ、IPアドレスで接続したいホストを変換テーブルに登録しておくことにしました。これにより、意図したSSL証明書でのみ、ホスト名の問題を無視して通信を行うことができます。次にこの実装を行った検証器、`CustomHostnameVerifier`クラスを示します。

```java:CustomHostnameVerifier.java
package net.leak4mk0.util;

import org.apache.http.conn.ssl.BrowserCompatHostnameVerifier;
import org.apache.http.conn.ssl.X509HostnameVerifier;

import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.Map;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;

public class CustomHostnameVerifier implements X509HostnameVerifier {
    private BrowserCompatHostnameVerifier mHostnameVerifier;
    private Map<String, String> mHostMap;

    public CustomHostnameVerifier(Map<String, String> hostMap) {
        mHostnameVerifier = new BrowserCompatHostnameVerifier();
        mHostMap = hostMap;
    }

    @Override
    public boolean verify(String host, SSLSession session) {
        return mHostnameVerifier.verify(getCustomHost(host), session);
    }

    @Override
    public void verify(String host, SSLSocket ssl) throws IOException {
        mHostnameVerifier.verify(getCustomHost(host), ssl);
    }

    @Override
    public void verify(String host, X509Certificate cert) throws SSLException {
        mHostnameVerifier.verify(getCustomHost(host), cert);
    }

    @Override
    public void verify(String host, String[] cns, String[] subjectAlts) throws SSLException {
        mHostnameVerifier.verify(getCustomHost(host), cns, subjectAlts);
    }

    private String getCustomHost(String originalHost) {
        if (!mHostMap.containsKey(originalHost)) {
            return originalHost;
        }

        return mHostMap.get(originalHost);
    }
}
```

`X509HostnameVerifier`を実装した`BrowserCompatHostnameVerifier`クラスを内部で利用しています。継承するつもりでしたが、`verify`メソッドが`final`でしたので、このような形になっています。コードを見ればわかることですが、`getCustomHost`メソッドで変換テーブルにあれば置換後のホストを返し、検証を続行しています。

前述の`setHostnameVerifier`メソッドで変換テーブルを持った`CustomHostnameVerifier`のインスタンスを渡せば、検証器を変えることができます。

```java
Map<String, String> HOST_MAP =
        new HashMap<String, String>() {{
            put("216.58.220.227", "google.com");
        }};

HttpParams params = new BasicHttpParams();
SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
SSLSocketFactory sslSocketFactory = SSLSocketFactory.getSocketFactory();
// ホスト名の変換テーブルを持った検証器を利用する。
sslSocketFactory.setHostnameVerifier(new CustomHostnameVerifier(HOST_MAP));
registry.register(new Scheme("https", sslSocketFactory, 443));
ThreadSafeClientConnManager clientConnManager = new ThreadSafeClientConnManager(params, registry);
HttpClient httpClient = new DefaultHttpClient(clientConnManager , params);
```

#さいごに
私は、この方法で安全性を確保したまま、サーバー移行時の問題を解決することができました。
この記事で示したコードはご自由に使っていただいて大丈夫です。ただし、一切の責任を取りかねますのでご了承ください。