📶

Java(Jakarta EE)でProxy経由でAPI通信しようとして詰まった話

に公開

はじめに

私はアプリケーションエンジニアで、ネットワーク周りは他部署が担当しています。
担当しているJava(Jakarta EE)のアプリから社内の別システムのAPIをProxyサーバ経由でHTTPリクエストを送信した際、エラーに詰まって解決に時間がかかってしまいました。
その内容を記録として記事に残そうと思います。
理解が誤っていたら優しく教えて頂けると嬉しいです。

やりたかったこと(想定していた暗号化範囲)

担当アプリのサーバと社内の別システムのAPIサーバの間に、Proxyサーバがある標準的なネットワーク構成です(改変しており実際の構成とは異なります)。
勘違いで担当アプリのサーバとProxyサーバまでは平文通信、Proxyサーバから先は暗号化通信というイメージを持っていました。


※当社作成のイメージ図

このときのアプリ側のコードはこんなイメージです(改変し最小構成部分だけ抜粋)。

ResteasyClient client = new ResteasyClientBuilder()
    .defaultProxy("proxyHostName", 8080, "http")  // 平文プロキシ指定
    .build();

Invocation.Builder res = client.target("https://api.target.path.com/api")
                   .request()
                   .accept(MediaType.APPLICATION_JSON_TYPE)
                   .get();

発生したエラー

外部API呼び出し部分でExceptionが発生しました。スタックトレースを眺めていると気になる文字が...

SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

「Proxyサーバまでは平文だと思っていたのに、どうしてアプリ側でSSL(TLS)のハンドシェイクに失敗するの?」が最初に持った疑問でした。

原因

以下の2点を理解していなかったことが原因でした。

① 接続先APIがHTTPS接続の場合、アプリ側から接続先APIにSSL(TLS)接続を試みる

少し調べた結果、間にProxyサーバを挟んだ場合、最初のCONNECTメソッド以降、アプリサーバと接続先APIとの間でエンドツーエンドのSSL(TLS)通信を試みる挙動をとることを理解しました。

暗号化通信となる範囲が想定と異なり、アプリサーバから暗号化通信されるというわけです。

ここでまた疑問が浮かびました。じゃあどうしてSSLHandshakeExceptionが発生したのか?という点です。

アプリサーバと接続先APIがエンドツーエンドで暗号化通信していたとして、特にクライアント認証などをしているわけでもないので、自然体で通信が成功するはずだと考えていました。

その原因が以下でした。

② ProxyのSSL(TLS)インスペクション機能がONの場合、ProxyCAの証明書をアプリ側が信頼する必要がある

利用している当社のProxyにはどうやらSSLインスペクションという機能があり、一度復号されているようでした。
アプリサーバ→Proxyの間で暗号化通信し、一度復号し、さらにProxy→接続先APIの間で暗号化通信しているという構図です。

その際、アプリサーバから接続先APIと通信しているように見えるよう、Proxyからは接続先APIのサーバ証明書に偽装した自己証明書(いわゆるオレオレ証明書)が提示されます。


※当社作成のイメージ図

SSL(TLS)におけるサーバ証明書の検証には、その妥当性を担保するルート証明書を利用します。
このルート証明書は、一般的なものであればクライアント側にデフォルトでインストールされています。

しかし、今回は自己証明書なのでProxyがCAとなって作ったものです。必然的にルート証明書もProxyのものになります。
Proxyが作成した証明書がデフォルトでインストールされているわけがないので、提示された自己証明書の検証ができずSSLHandshakeExceptionが発生したというわけでした。

解決方法

原因より、Proxyの証明書をJavaアプリに読み込ませ、通信時に利用できるようにする必要があります。

残念ながらアプリサーバのファイル書き込み権限がなかったため、以下の方法をとりました。

  1. ProxyCA証明書をアプリサーバ(コンテナ)内に内包してデプロイ
  2. Java(Jakarta EE)コード上から参照し、トラストストアに登録

書き込み権限がないのでSCPができず1は仕方ないかな~なんて思っていましたが、2にたどり着くまでも結構詰まりました。

Javaのトラストストアへの登録は、update-ca-trust でOSにインストールさせたり、keytoolコマンドで登録したりする便利なやり方もあるみたいですが、これらのコマンドはファイル変更を伴うので軒並みNGでした。

そこで、保守性は落ちますがコード上からアプリサーバ内の証明書を直接参照して読み込み、通信クラスで利用することにしました。

2のアプリのコードはこんなイメージです(改変し最小構成部分だけ抜粋)。


// CA証明書を読み込むSSLContextの作成クラス (import文は省略しています)
public final class SSLContextBuilder {
  private SSLContextBuilder() {}

  /** ProxyCAを信頼するSSLContext を作る */
  public static SSLContext build(Path caPath) throws Exception {
    byte[] bytes = Files.readAllBytes(caPath);

    // X.509 として証明書群を読み込み
    CertificateFactory cf = CertificateFactory.getInstance("X.509");
    Collection<? extends Certificate> certs =
        cf.generateCertificates(new ByteArrayInputStream(bytes));

    // キーストアに証明書を登録
    KeyStore ks = KeyStore.getInstance("JKS");
    ks.load(null, null);
    int i = 0;
    for (Certificate c : certs) {
      ks.setCertificateEntry("ca-cert" + (i++), c);
    }

    // TrustManager を作成
    TrustManagerFactory tmf =
        TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    tmf.init(ks);

    // コンテキストの初期化
    SSLContext ctx = SSLContext.getInstance("TLS");
    ctx.init(null, tmf.getTrustManagers(), new SecureRandom());
    return ctx;
  }
}
// 通信クラス内 (import文は省略しています)
SSLContext ssl = SSLContextBuilder.build(Paths.get("/path/to/ca.crt"));

ResteasyClient client = new ResteasyClientBuilder()
    .sslContext(ssl)
    .defaultProxy("proxyHostName", 8080, "http")
    .build();

Invocation.Builder res = client.target("https://api.target.path.com/api")
                   .request()
                   .accept(MediaType.APPLICATION_JSON_TYPE)
                   .get();

おわりに

ネットワーク関連の知見がないと解決が難しい問題でした。
ネットワークは通信の発生するアプリケーションの基礎となる部分なので、自分の担当外だったとしてもしっかりと知見をつけていかなきゃなと実感させられました。

まだまだ勉強途中ですので、誤っている内容ありましたらぜひ優しく教えてください。本記事が同じように詰まっている方の助けになると嬉しいです。

※掲載したソースコードなどはサンプルになります。本ソースコードを使用することで発生するいかなる損害や不利益について、当社は一切の責任を負いませんので自己の責任においてご利用ください。

参考記事

基本的な理解のために以下の2記事を参考にしました。

  1. Yusuke Arai.HTTP プロキシを実装して理解する CONNECT メソッド. Zenn. 2025-01-10. https://zenn.dev/arailly/articles/8f91ec2c22109c, (参照 2024-10-09).
  2. nesuke. 【図解】httpプロキシサーバの仕組み(http GET/https CONNECTメソッド)や必要性・役割・メリットデメリット・DNSの名前解決の順序. SEの道標. 2025-08-01. https://milestone-of-se.nesuke.com/nw-basic/grasp-nw/proxy/, (参照 2024-10-09).

Discussion