(TK-97) Expose configuration settings for protocols/cipher suites

This commit adds configuration settings for the SSL protocols
and cipher suites, in both the java and clojure clients.  It
also adds a list of default protocols which will be used if
the protocols setting is not explicitly set.
This commit is contained in:
Chris Price 2014-10-17 23:35:05 -07:00
parent 180a4bf78d
commit 251d859d10
7 changed files with 213 additions and 33 deletions

View file

@ -12,7 +12,8 @@
;; these methods. ;; these methods.
(ns puppetlabs.http.client.async (ns puppetlabs.http.client.async
(:import (com.puppetlabs.http.client HttpMethod HttpClientException) (:import (com.puppetlabs.http.client HttpMethod HttpClientException
RequestOptions)
(org.apache.http.nio.client HttpAsyncClient) (org.apache.http.nio.client HttpAsyncClient)
(org.apache.http.impl.nio.client HttpAsyncClients) (org.apache.http.impl.nio.client HttpAsyncClients)
(org.apache.http.client.methods HttpGet HttpHead HttpPost HttpPut HttpTrace HttpDelete HttpOptions HttpPatch) (org.apache.http.client.methods HttpGet HttpHead HttpPost HttpPut HttpTrace HttpDelete HttpOptions HttpPatch)
@ -26,7 +27,8 @@
(com.puppetlabs.http.client.impl Compression) (com.puppetlabs.http.client.impl Compression)
(org.apache.http.client RedirectStrategy) (org.apache.http.client RedirectStrategy)
(org.apache.http.impl.client LaxRedirectStrategy DefaultRedirectStrategy) (org.apache.http.impl.client LaxRedirectStrategy DefaultRedirectStrategy)
(org.apache.http.nio.conn.ssl SSLIOSessionStrategy)) (org.apache.http.nio.conn.ssl SSLIOSessionStrategy)
(org.apache.http.conn.ssl SSLContexts))
(:require [puppetlabs.certificate-authority.core :as ssl] (:require [puppetlabs.certificate-authority.core :as ssl]
[clojure.string :as str] [clojure.string :as str]
[puppetlabs.kitchensink.core :as ks] [puppetlabs.kitchensink.core :as ks]
@ -64,7 +66,7 @@
[req] [req]
(initialize-ssl-context-from-ca-pem req)) (initialize-ssl-context-from-ca-pem req))
(schema/defn configure-ssl :- (schema/either {} common/SslContextOptions) (schema/defn configure-ssl-ctxt :- (schema/either {} common/SslContextOptions)
"Configures a request map to have an SSLContext. It will use an existing one "Configures a request map to have an SSLContext. It will use an existing one
(stored in :ssl-context) if already present, and will fall back to a set of (stored in :ssl-context) if already present, and will fall back to a set of
PEM files (stored in :ssl-cert, :ssl-key, and :ssl-ca-cert) if those are present. PEM files (stored in :ssl-cert, :ssl-key, and :ssl-ca-cert) if those are present.
@ -263,6 +265,7 @@
(schema/defn extract-client-opts :- common/ClientOptions (schema/defn extract-client-opts :- common/ClientOptions
[opts :- common/UserRequestOptions] [opts :- common/UserRequestOptions]
(select-keys opts [:ssl-context :ssl-ca-cert :ssl-cert :ssl-key (select-keys opts [:ssl-context :ssl-ca-cert :ssl-cert :ssl-key
:ssl-protocols :cipher-suites
:force-redirects :follow-redirects])) :force-redirects :follow-redirects]))
(schema/defn extract-ssl-opts :- common/SslOptions (schema/defn extract-ssl-opts :- common/SslOptions
@ -273,6 +276,18 @@
[opts :- common/UserRequestOptions] [opts :- common/UserRequestOptions]
(select-keys opts [:url :method :headers :body :decompress-body :as :persistent :query-params])) (select-keys opts [:url :method :headers :body :decompress-body :as :persistent :query-params]))
(schema/defn ^:always-validate ssl-strategy :- SSLIOSessionStrategy
[ssl-ctxt-opts :- common/SslContextOptions
ssl-prot-opts :- common/SslProtocolOptions]
(SSLIOSessionStrategy.
(:ssl-context ssl-ctxt-opts)
(if (contains? ssl-prot-opts :ssl-protocols)
(into-array String (:ssl-protocols ssl-prot-opts))
RequestOptions/DEFAULT_SSL_PROTOCOLS)
(if (contains? ssl-prot-opts :cipher-suites)
(into-array String (:cipher-suites ssl-prot-opts)))
SSLIOSessionStrategy/BROWSER_COMPATIBLE_HOSTNAME_VERIFIER))
(schema/defn ^:always-validate redirect-strategy :- RedirectStrategy (schema/defn ^:always-validate redirect-strategy :- RedirectStrategy
[opts :- common/ClientOptions] [opts :- common/ClientOptions]
(let [follow-redirects (:follow-redirects opts) (let [follow-redirects (:follow-redirects opts)
@ -291,17 +306,12 @@
(schema/defn ^:always-validate create-default-client :- common/Client (schema/defn ^:always-validate create-default-client :- common/Client
[opts :- common/ClientOptions] [opts :- common/ClientOptions]
(let [configured-opts (configure-ssl (extract-ssl-opts opts)) (let [ssl-ctxt-opts (configure-ssl-ctxt (extract-ssl-opts opts))
ssl-prot-opts (select-keys opts [:ssl-protocols :cipher-suites])
client-builder (HttpAsyncClients/custom) client-builder (HttpAsyncClients/custom)
client (do (when (:ssl-context configured-opts) client (do (when (:ssl-context ssl-ctxt-opts)
(.setSSLStrategy (.setSSLStrategy client-builder (ssl-strategy ssl-ctxt-opts ssl-prot-opts)))
client-builder (.setRedirectStrategy client-builder (redirect-strategy opts))
(SSLIOSessionStrategy.
(:ssl-context configured-opts)
SSLIOSessionStrategy/BROWSER_COMPATIBLE_HOSTNAME_VERIFIER)))
(.setRedirectStrategy
client-builder
(redirect-strategy opts))
(.build client-builder))] (.build client-builder))]
(.start client) (.start client)
client)) client))

View file

@ -29,8 +29,8 @@
(def UrlOrString (schema/either schema/Str URL)) (def UrlOrString (schema/either schema/Str URL))
-;; TODO: replace this with a protocol ;; TODO: replace this with a protocol
-(def Client CloseableHttpAsyncClient) (def Client CloseableHttpAsyncClient)
(def Headers (def Headers
{schema/Str schema/Str}) {schema/Str schema/Str})
@ -57,6 +57,8 @@
(ok :ssl-cert) UrlOrString (ok :ssl-cert) UrlOrString
(ok :ssl-key) UrlOrString (ok :ssl-key) UrlOrString
(ok :ssl-ca-cert) UrlOrString (ok :ssl-ca-cert) UrlOrString
(ok :ssl-protocols) [schema/Str]
(ok :cipher-suites) [schema/Str]
(ok :force-redirects) schema/Bool (ok :force-redirects) schema/Bool
(ok :follow-redirects) schema/Bool}) (ok :follow-redirects) schema/Bool})
@ -99,6 +101,10 @@
(def SslOptions (def SslOptions
(schema/either {} SslContextOptions SslCertOptions SslCaCertOptions)) (schema/either {} SslContextOptions SslCertOptions SslCaCertOptions))
(def SslProtocolOptions
{(schema/optional-key :ssl-protocols) [schema/Str]
(schema/optional-key :cipher-suites) [schema/Str]})
(def RedirectOptions (def RedirectOptions
{(schema/optional-key :force-redirects) schema/Bool {(schema/optional-key :force-redirects) schema/Bool
(schema/optional-key :follow-redirects) schema/Bool}) (schema/optional-key :follow-redirects) schema/Bool})
@ -108,9 +114,9 @@
validating the RawUserRequestClientOptions and merging it with the defaults." validating the RawUserRequestClientOptions and merging it with the defaults."
(schema/either (schema/either
(merge RequestOptions RedirectOptions) (merge RequestOptions RedirectOptions)
(merge RequestOptions SslContextOptions RedirectOptions) (merge RequestOptions SslContextOptions SslProtocolOptions RedirectOptions)
(merge RequestOptions SslCaCertOptions RedirectOptions) (merge RequestOptions SslCaCertOptions SslProtocolOptions RedirectOptions)
(merge RequestOptions SslCertOptions RedirectOptions))) (merge RequestOptions SslCertOptions SslProtocolOptions RedirectOptions)))
(def ClientOptions (def ClientOptions
"The options from UserRequestOptions that are related to the "The options from UserRequestOptions that are related to the
@ -118,9 +124,9 @@
from UserRequestOptions not included in RequestOptions." from UserRequestOptions not included in RequestOptions."
(schema/either (schema/either
RedirectOptions RedirectOptions
(merge SslContextOptions RedirectOptions) (merge SslContextOptions SslProtocolOptions RedirectOptions)
(merge SslCertOptions RedirectOptions) (merge SslCertOptions SslProtocolOptions RedirectOptions)
(merge SslCaCertOptions RedirectOptions))) (merge SslCaCertOptions SslProtocolOptions RedirectOptions)))
(def ResponseCallbackFn (def ResponseCallbackFn
(schema/maybe (schema/pred ifn?))) (schema/maybe (schema/pred ifn?)))

View file

@ -12,6 +12,9 @@ import java.net.URISyntaxException;
import java.util.Map; import java.util.Map;
public class RequestOptions { public class RequestOptions {
public static final String[] DEFAULT_SSL_PROTOCOLS =
new String[] {"TLSv1", "TLSv1.1", "TLSv1.2"};
private HttpAsyncClient client = null; private HttpAsyncClient client = null;
private URI uri; private URI uri;
@ -21,6 +24,8 @@ public class RequestOptions {
private String sslCert; private String sslCert;
private String sslKey; private String sslKey;
private String sslCaCert; private String sslCaCert;
private String[] sslProtocols;
private String[] sslCipherSuites;
private boolean insecure = false; private boolean insecure = false;
private Object body; private Object body;
private boolean decompressBody = true; private boolean decompressBody = true;
@ -97,6 +102,22 @@ public class RequestOptions {
return this; return this;
} }
public String[] getSslProtocols() {
return sslProtocols;
}
public RequestOptions setSslProtocols(String[] sslProtocols) {
this.sslProtocols = sslProtocols;
return this;
}
public String[] getSslCipherSuites() {
return sslCipherSuites;
}
public RequestOptions setSslCipherSuites(String[] sslCipherSuites) {
this.sslCipherSuites = sslCipherSuites;
return this;
}
public boolean getInsecure() { public boolean getInsecure() {
return insecure; return insecure;
} }

View file

@ -13,6 +13,8 @@ public class CoercedRequestOptions {
private final Header[] headers; private final Header[] headers;
private final HttpEntity body; private final HttpEntity body;
private final SSLContext sslContext; private final SSLContext sslContext;
private final String[] sslProtocols;
private final String[] sslCipherSuites;
private final boolean forceRedirects; private final boolean forceRedirects;
private final boolean followRedirects; private final boolean followRedirects;
@ -22,6 +24,8 @@ public class CoercedRequestOptions {
Header[] headers, Header[] headers,
HttpEntity body, HttpEntity body,
SSLContext sslContext, SSLContext sslContext,
String[] sslProtocols,
String[] sslCipherSuites,
boolean forceRedirects, boolean forceRedirects,
boolean followRedirects) { boolean followRedirects) {
this.uri = uri; this.uri = uri;
@ -29,6 +33,8 @@ public class CoercedRequestOptions {
this.headers = headers; this.headers = headers;
this.body = body; this.body = body;
this.sslContext = sslContext; this.sslContext = sslContext;
this.sslProtocols = sslProtocols;
this.sslCipherSuites = sslCipherSuites;
this.forceRedirects = forceRedirects; this.forceRedirects = forceRedirects;
this.followRedirects = followRedirects; this.followRedirects = followRedirects;
} }
@ -53,6 +59,14 @@ public class CoercedRequestOptions {
return sslContext; return sslContext;
} }
public String[] getSslProtocols() {
return sslProtocols;
}
public String[] getSslCipherSuites() {
return sslCipherSuites;
}
public boolean getForceRedirects() { return forceRedirects; } public boolean getForceRedirects() { return forceRedirects; }
public boolean getFollowRedirects() { return followRedirects; } public boolean getFollowRedirects() { return followRedirects; }

View file

@ -103,6 +103,18 @@ public class JavaClient {
sslContext = getInsecureSslContext(); sslContext = getInsecureSslContext();
} }
String[] sslProtocols = null;
if (options.getSslProtocols() != null) {
sslProtocols = options.getSslProtocols();
} else {
sslProtocols = RequestOptions.DEFAULT_SSL_PROTOCOLS;
}
String[] sslCipherSuites = null;
if (options.getSslCipherSuites() != null) {
sslCipherSuites = options.getSslCipherSuites();
}
HttpMethod method = options.getMethod(); HttpMethod method = options.getMethod();
if (method == null) { if (method == null) {
method = HttpMethod.GET; method = HttpMethod.GET;
@ -136,7 +148,7 @@ public class JavaClient {
boolean forceRedirects = options.getForceRedirects(); boolean forceRedirects = options.getForceRedirects();
boolean followRedirects = options.getFollowRedirects(); boolean followRedirects = options.getFollowRedirects();
return new CoercedRequestOptions(uri, method, headers, body, sslContext, forceRedirects, followRedirects); return new CoercedRequestOptions(uri, method, headers, body, sslContext, sslProtocols, sslCipherSuites, forceRedirects, followRedirects);
} }
private static SSLContext getInsecureSslContext() { private static SSLContext getInsecureSslContext() {
@ -232,8 +244,11 @@ public class JavaClient {
private static CloseableHttpAsyncClient createClient(CoercedRequestOptions coercedOptions) { private static CloseableHttpAsyncClient createClient(CoercedRequestOptions coercedOptions) {
HttpAsyncClientBuilder clientBuilder = HttpAsyncClients.custom(); HttpAsyncClientBuilder clientBuilder = HttpAsyncClients.custom();
if (coercedOptions.getSslContext() != null) { if (coercedOptions.getSslContext() != null) {
clientBuilder.setSSLStrategy(new SSLIOSessionStrategy(coercedOptions.getSslContext(), clientBuilder.setSSLStrategy(
SSLIOSessionStrategy.BROWSER_COMPATIBLE_HOSTNAME_VERIFIER)); new SSLIOSessionStrategy(coercedOptions.getSslContext(),
coercedOptions.getSslProtocols(),
coercedOptions.getSslCipherSuites(),
SSLIOSessionStrategy.BROWSER_COMPATIBLE_HOSTNAME_VERIFIER));
} }
RedirectStrategy redirectStrategy; RedirectStrategy redirectStrategy;
if (!coercedOptions.getFollowRedirects()) { if (!coercedOptions.getFollowRedirects()) {

View file

@ -12,9 +12,9 @@
(let [opts {:ssl-cert (resource "ssl/cert.pem") (let [opts {:ssl-cert (resource "ssl/cert.pem")
:ssl-key (resource "ssl/key.pem") :ssl-key (resource "ssl/key.pem")
:ssl-ca-cert (resource "ssl/ca.pem")} :ssl-ca-cert (resource "ssl/ca.pem")}
configured-opts (http/configure-ssl opts)] configured-opts (http/configure-ssl-ctxt opts)]
(testing "configure-ssl sets up an SSLContext when given cert, key, ca-cert" (testing "configure-ssl-ctxt sets up an SSLContext when given cert, key, ca-cert"
(is (instance? SSLContext (:ssl-context configured-opts)))) (is (instance? SSLContext (:ssl-context configured-opts))))
(testing "removes ssl-cert, ssl-key, ssl-ca-cert" (testing "removes ssl-cert, ssl-key, ssl-ca-cert"
@ -24,18 +24,18 @@
(deftest ssl-config-with-ca-file (deftest ssl-config-with-ca-file
(let [opts {:ssl-ca-cert (resource "ssl/ca.pem")} (let [opts {:ssl-ca-cert (resource "ssl/ca.pem")}
configured-opts (http/configure-ssl opts)] configured-opts (http/configure-ssl-ctxt opts)]
(testing "configure-ssl sets up an SSLContext when given ca-cert" (testing "configure-ssl-ctxt sets up an SSLContext when given ca-cert"
(is (instance? SSLContext (:ssl-context configured-opts)))) (is (instance? SSLContext (:ssl-context configured-opts))))
(testing "removes ssl-ca-cert" (testing "removes ssl-ca-cert"
(is (not (:ssl-ca-cert configured-opts)))))) (is (not (:ssl-ca-cert configured-opts))))))
(deftest ssl-config-without-ssl-params (deftest ssl-config-without-ssl-params
(let [configured-opts (http/configure-ssl {})] (let [configured-opts (http/configure-ssl-ctxt {})]
(testing "configure-ssl does nothing when given no ssl parameters" (testing "configure-ssl-ctxt does nothing when given no ssl parameters"
(is (= {} configured-opts))))) (is (= {} configured-opts)))))
(deftest ssl-config-with-context (deftest ssl-config-with-context
@ -43,7 +43,7 @@
(resource "ssl/cert.pem") (resource "ssl/cert.pem")
(resource "ssl/key.pem") (resource "ssl/key.pem")
(resource "ssl/ca.pem"))} (resource "ssl/ca.pem"))}
configured-opts (http/configure-ssl opts)] configured-opts (http/configure-ssl-ctxt opts)]
(testing "configure-ssl uses an existing ssl context" (testing "configure-ssl-ctxt uses an existing ssl context"
(is (instance? SSLContext (:ssl-context configured-opts)))))) (is (instance? SSLContext (:ssl-context configured-opts))))))

View file

@ -2,7 +2,8 @@
(:import (com.puppetlabs.http.client SyncHttpClient RequestOptions (:import (com.puppetlabs.http.client SyncHttpClient RequestOptions
HttpClientException) HttpClientException)
(javax.net.ssl SSLHandshakeException) (javax.net.ssl SSLHandshakeException)
(java.net URI)) (java.net URI)
(org.apache.http ConnectionClosedException))
(:require [clojure.test :refer :all] (:require [clojure.test :refer :all]
[puppetlabs.trapperkeeper.core :as tk] [puppetlabs.trapperkeeper.core :as tk]
[puppetlabs.trapperkeeper.testutils.bootstrap :as testutils] [puppetlabs.trapperkeeper.testutils.bootstrap :as testutils]
@ -94,3 +95,116 @@
(is (thrown? SSLHandshakeException (is (thrown? SSLHandshakeException
(sync/get "https://localhost:10081/hello/" (sync/get "https://localhost:10081/hello/"
{:ssl-ca-cert "./dev-resources/ssl/alternate-ca.pem"}))))))) {:ssl-ca-cert "./dev-resources/ssl/alternate-ca.pem"})))))))
(defmacro with-server-with-protocols
[server-protocols server-cipher-suites & body]
`(testlogging/with-test-logging
(testutils/with-app-with-config app#
[jetty9/jetty9-service test-web-service]
{:webserver (merge
{:ssl-host "0.0.0.0"
:ssl-port 10080
:ssl-ca-cert "./dev-resources/ssl/ca.pem"
:ssl-cert "./dev-resources/ssl/cert.pem"
:ssl-key "./dev-resources/ssl/key.pem"
:ssl-protocols ~server-protocols}
(if ~server-cipher-suites
{:cipher-suites ~server-cipher-suites}))}
~@body)))
(defmacro java-unsupported-protocol-exception?
[& body]
`(try
~@body
(catch HttpClientException e#
(let [cause# (.getCause e#)]
(or
(and (instance? SSLHandshakeException cause#)
(re-find #"not supported by the client" (.getMessage cause#)))
(instance? ConnectionClosedException cause#))))))
(defn java-https-get-with-protocols
[client-protocols client-cipher-suites]
(let [options (.. (RequestOptions. (URI. "https://localhost:10080/hello/"))
(setSslCert "./dev-resources/ssl/cert.pem")
(setSslKey "./dev-resources/ssl/key.pem")
(setSslCaCert "./dev-resources/ssl/ca.pem"))]
(if client-protocols
(.setSslProtocols options (into-array String client-protocols)))
(if client-cipher-suites
(.setSslCipherSuites options (into-array String client-cipher-suites)))
(SyncHttpClient/get options)))
(defn clj-https-get-with-protocols
[client-protocols client-cipher-suites]
(let [ssl-opts (merge {:ssl-cert "./dev-resources/ssl/cert.pem"
:ssl-key "./dev-resources/ssl/key.pem"
:ssl-ca-cert "./dev-resources/ssl/ca.pem"}
(if client-protocols
{:ssl-protocols client-protocols})
(if client-cipher-suites
{:cipher-suites client-cipher-suites}))]
(sync/get "https://localhost:10080/hello/" ssl-opts)))
(deftest sync-client-test-ssl-protocols
(testing "should be able to connect to a TLSv1.2 server by default"
(with-server-with-protocols ["TLSv1.2"] nil
(testing "java sync client"
(let [response (java-https-get-with-protocols nil nil)]
(is (= 200 (.getStatus response)))
(is (= "Hello, World!" (slurp (.getBody response))))))
(testing "clojure sync client"
(let [response (clj-https-get-with-protocols nil nil)]
(is (= 200 (:status response)))
(is (= "Hello, World!" (slurp (:body response))))))))
(testing "should be able to connect to a server with non-default protocol if configured"
(with-server-with-protocols ["SSLv3"] nil
(testing "java sync client"
(let [response (java-https-get-with-protocols ["SSLv3"] nil)]
(is (= 200 (.getStatus response)))
(is (= "Hello, World!" (slurp (.getBody response))))))
(testing "clojure sync client"
(let [response (clj-https-get-with-protocols ["SSLv3"] nil)]
(is (= 200 (:status response)))
(is (= "Hello, World!" (slurp (:body response))))))))
(testing "should not connect to an SSLv3 server by default"
(with-server-with-protocols ["SSLv3"] nil
(testing "java sync client"
(is (java-unsupported-protocol-exception?
(java-https-get-with-protocols nil nil))))
(testing "clojure sync client"
(is (thrown-with-msg?
SSLHandshakeException #"not supported by the client"
(clj-https-get-with-protocols nil nil))))))
(testing "should not connect to a server when protocols don't overlap"
(with-server-with-protocols ["TLSv1.1"] nil
(testing "java sync client"
(is (java-unsupported-protocol-exception?
(java-https-get-with-protocols ["TLSv1.2"] nil))))
(testing "clojure sync client"
(is (thrown-with-msg?
SSLHandshakeException #"not supported by the client"
(clj-https-get-with-protocols ["TLSv1.2"] nil)))))))
(deftest sync-client-test-cipher-suites
(testing "should not connect to a server with no overlapping cipher suites"
(with-server-with-protocols ["TLSv1.2"] ["TLS_RSA_WITH_AES_256_CBC_SHA256"]
(testing "java sync client"
(is (java-unsupported-protocol-exception?
(java-https-get-with-protocols ["TLSv1.2"] ["TLS_RSA_WITH_AES_128_CBC_SHA256"]))))
(testing "clojure sync client"
(is (thrown? ConnectionClosedException
(clj-https-get-with-protocols ["TLSv1.2"] ["TLS_RSA_WITH_AES_128_CBC_SHA256"]))))))
(testing "should connect to a server with overlapping cipher suites"
(with-server-with-protocols ["TLSv1.2"] ["TLS_RSA_WITH_AES_256_CBC_SHA256"]
(testing "java sync client"
(let [response (java-https-get-with-protocols ["TLSv1.2"] ["TLS_RSA_WITH_AES_256_CBC_SHA256"])]
(is (= 200 (.getStatus response)))
(is (= "Hello, World!" (slurp (.getBody response))))))
(testing "clojure sync client"
(let [response (clj-https-get-with-protocols ["TLSv1.2"] ["TLS_RSA_WITH_AES_256_CBC_SHA256"])]
(is (= 200 (:status response)))
(is (= "Hello, World!" (slurp (:body response)))))))))