dev/Java&Spring

Spring에서 insecure SSL 요청(RestTemplate, WebClient)

lugi 2019. 4. 6. 12:37

회사의 네트워크 환경이 프록시를 통해서 외부로 나가는 환경이며, 내부망으로 구성된 환경이 많다보니 TLS/SSL과 관련하여 꼭 self-signed 인증서와 관련된 문제를 마주치게 된다.

 

proxy를 통해서 외부와 접속할 경우에는 proxy에서 SSL 인증서가 회사의 사설 인증서로 교체되는 것이 원인이며 내부에서 필요에 의해 SSL 연결을 지원해야 할 경우에는, 내부에서는 self-signed 인증서를 발급하여 사용하기 때문이다.

어떤 툴을 쓰거나, 아니면 개발을 할 때도 꼭 시작은 이 문제를 짚고 넘어가는 것이 그 다음인데 오늘은 Spring 에서 마주치는 경우를 다뤄보려 한다.

 

Spring4 까지 사용하던 RestTemplate는 HTTP(s)요청을 날리기 위해 JDK HttpURLConnection이나 Apache HttpComponents 등에 의존을 했다. 그래서 일반적으로 insecure SSL과 관련된 처리를 할 때는 JDK에서 제공하는 기능에 기반한 HttpRequestFactory를 구현하여 이 안에서 hostname의 검증 없이 모든 것을 신뢰하는 처리를 하고, X.509 공개키 암호화 검증을 모두 패스하게 하는 식으로 우회 로직을 구현해야한다.

 

Spring4 에서 RestTemplate에 RequestFactory를 구현하여 insecure SSL을 구현하는 방법은 다음과 같다.

template.getRestTemplate().setRequestFactory(new SimpleClientHttpRequestFactory() {

    @Override
    protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException {
        if (connection instanceof HttpsURLConnection) {
            ((HttpsURLConnection) connection).setHostnameVerifier((hostname, session) -> true); // 호스트 검증을 항상 pass하고
            SSLContext sc;
            try {
                sc = SSLContext.getInstance("SSL"); // SSLContext를 생성하여
                sc.init(null, new TrustManager[] { new X509TrustManager() { // 공개키 암호화 설정을 무력화시킨다.

                    @Override
                    public X509Certificate[] getAcceptedIssuers() {
                        return null;
                    }

                    @Override
                    public void checkServerTrusted(X509Certificate[] chain, String authType)
                            throws CertificateException {

                    }

                    @Override
                    public void checkClientTrusted(X509Certificate[] chain, String authType)
                            throws CertificateException {

                    }
                } }, new SecureRandom());
                ((HttpsURLConnection) connection).setSSLSocketFactory(sc.getSocketFactory());
            } catch (NoSuchAlgorithmException e) {
                e.printStackTrace();
            } catch (KeyManagementException e) {
                e.printStackTrace();
            }
        }
        super.prepareConnection(connection, httpMethod);
    }

});

Spring5 에서는 Non-blocking/Reactive가 떠오르면서 내부적으로 Netty,Project Reactor 등이 도입되었다.

 

그래서 사용하는 것이 WebClient인데, Spring framework docs에 따르면 As of 5.0, the non-blocking, reactive org.springframework.web.reactive.client.WebClient offers a modern alternative to the RestTemplate with efficient support for both sync and async, as well as streaming scenarios. The RestTemplate will be deprecated in a future version and will not have major new features added going forward. See the WebClient section of the Spring Framework reference documentation for more details and example code. - WebClient가 스트리밍 시나리오를 통해 동기/비동기를 모두 지원하는 RestTemplate의 대안이기 때문에, RestTemplate은 추후 deprecated 될 것이며, 새로운 기능을 그다지 추가하지 않을 것이라고 한다. 그러므로 앞으로는 WebClient로 자연스럽게 옮겨가야할 것 같다.

 

WebClient를 사용할 경우 Connector는 org.springframework.http.client.reactive.ClientHttpConnector인데, 이를 구현한 것들은 Jetty Reactive Stream을 이용한 JettyClientHttpConnector나 Reactor-Netty를 이용한 ReactorClientHttpConnector이다.


ReactorClientHttpConnector를 예로 들자면, reactor.netty.http.client.HttpClient를 HttpClient로 사용하여 reactor.netty.tcp.TcpClient를 통해 요청을 날리게 된다. 이 과정을 거치면서, TcpClient의 secure(Consumer<? super SslProvider.SslContextSpec> sslProviderBuilder)를 통해 SSL프로바이더를 설정하게 되어 있다.

 

그러므로 WebClient에서는 기존에 사용하던 insecure SSL 방식이 먹지 않고, secure 메소드에 io.netty.handler.ssl.SslContext을 전달해줘야 하는데, 이것의 구현체는 JDK의 TLS/SSL 구현을 사용하는 io.netty.handler.ssl.SslContext혹은 io.netty.handler.ssl.OpenSslContext의 구현체인 OpenSSL TLS/SSL을 사용하는 녀석들인데, 전자의 녀석을 사용할 경우 실제로 내부적으로 사용되는 SSLContext는 javax.net.ssl.SSLContext이고 내부 구현 부분도

SSLContext ctx = sslContextProvider == null ? SSLContext.getInstance(PROTOCOL)
                : SSLContext.getInstance(PROTOCOL, sslContextProvider);

ctx.init(keyManagerFactory == null ? null : keyManagerFactory.getKeyManagers(),
            trustManagerFactory == null ? null : trustManagerFactory.getTrustManagers(), null);

Spring4 에서 보던 것과 비슷하게 되어 있긴하다. 사용하면서 실제로 구현해주어야 하는 부분은

SslContext ssl = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build();
HttpClient httpClient = HttpClient.create().secure(builder -> builder.sslContext(ssl));
ClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);

과 같이 SslContext를 InsecureTrustManagerFactory.INSTANCE를 사용해서 build하고 이것을 HttpClient의 secure 메소드에 전달해주면 끝나는 아주아주 간편한 방법이다.

 

https://github.com/gnu-gnu/spring-insecure-ssl-connect-test 의 프로젝트에서 test 패키지의 테스트들을 구동시켜보면 Exception이 나는 경우와 성공하는 경우를 확인할 수 있다. 사실 self-signed 인증서를 사용한다고 하더라도 ROOT CA를 적절하게 등록시켜주는 방법으로 이러한 오류는 충분히 해결할 수 있지만, 이런 식으로 회피하는 것이 어찌보면 보안상의 취약점을 조장하는 방법이라는 점도 염두에 둬야할 것이다.