diff --git a/src/gleam/httpc.gleam b/src/gleam/httpc.gleam index cfaeb69..0e35a2f 100644 --- a/src/gleam/httpc.gleam +++ b/src/gleam/httpc.gleam @@ -22,6 +22,13 @@ pub type ConnectError { TlsAlert(code: String, detail: String) } +/// Use with `tls_versions` to restrict which TLS versions the client will use. +/// This can be needed when interfacing with old or buggy TLS implementations. +pub type TlsVersion { + Tls12 + Tls13 +} + @external(erlang, "gleam_httpc_ffi", "default_user_agent") fn default_user_agent() -> #(Charlist, Charlist) @@ -53,12 +60,21 @@ type Inet6fb4 { type ErlSslOption { Verify(ErlVerifyOption) + Versions(List(ErlTlsVersion)) } type ErlVerifyOption { VerifyNone } +type ErlTlsVersion + +@external(erlang, "gleam_httpc_ffi", "tlsv12") +fn erl_tlsv12() -> ErlTlsVersion + +@external(erlang, "gleam_httpc_ffi", "tlsv13") +fn erl_tlsv13() -> ErlTlsVersion + @external(erlang, "httpc", "request") fn erl_request( a: Method, @@ -86,6 +102,13 @@ fn string_header(header: #(Charlist, Charlist)) -> #(String, String) { #(charlist.to_string(k), charlist.to_string(v)) } +fn tls_version(version: TlsVersion) -> ErlTlsVersion { + case version { + Tls12 -> erl_tlsv12() + Tls13 -> erl_tlsv13() + } +} + // TODO: refine error type /// Send a HTTP request of binary data using the default configuration. /// @@ -115,10 +138,21 @@ pub fn dispatch_bits( Autoredirect(config.follow_redirects), Timeout(config.timeout), ] - let erl_http_options = case config.verify_tls { - True -> erl_http_options - False -> [Ssl([Verify(VerifyNone)]), ..erl_http_options] + + // Build SSL options based on verify_tls and tls_versions settings + let ssl_opts = case config.verify_tls { + True -> [] + False -> [Verify(VerifyNone)] + } + let ssl_opts = case config.tls_versions { + [] -> ssl_opts + versions -> [Versions(list.map(versions, tls_version)), ..ssl_opts] } + let erl_http_options = case ssl_opts { + [] -> erl_http_options + _ -> [Ssl(ssl_opts), ..erl_http_options] + } + let erl_options = [BodyFormat(Binary), SocketOpts([Ipfamily(Inet6fb4)])] use response <- result.try( @@ -166,6 +200,9 @@ pub opaque type Configuration { /// Timeout for the request in milliseconds. /// timeout: Int, + /// Which TLS versions to allow. If empty, uses system defaults. + /// + tls_versions: List(TlsVersion), ) } @@ -177,9 +214,15 @@ pub opaque type Configuration { /// - Redirects are not followed. /// - The timeout for the response to be received is 30 seconds from when the /// request is sent. +/// - All TLS versions supported by the system are allowed. /// pub fn configure() -> Configuration { - Builder(verify_tls: True, follow_redirects: False, timeout: 30_000) + Builder( + verify_tls: True, + follow_redirects: False, + timeout: 30_000, + tls_versions: [], + ) } /// Set whether to verify the TLS certificate of the server. @@ -209,6 +252,15 @@ pub fn timeout(config: Configuration, timeout: Int) -> Configuration { Builder(..config, timeout:) } +/// Set which TLS versions to allow for HTTPS connections. +/// +pub fn tls_versions( + config: Configuration, + versions: List(TlsVersion), +) -> Configuration { + Builder(..config, tls_versions: versions) +} + /// Send a HTTP request of unicode data. /// pub fn dispatch( diff --git a/src/gleam_httpc_ffi.erl b/src/gleam_httpc_ffi.erl index 561cdf0..910264c 100644 --- a/src/gleam_httpc_ffi.erl +++ b/src/gleam_httpc_ffi.erl @@ -1,5 +1,9 @@ -module(gleam_httpc_ffi). --export([default_user_agent/0, normalise_error/1]). +-export([default_user_agent/0, normalise_error/1, tlsv12/0, tlsv13/0]). + +%% TLS version atoms for SSL options +tlsv12() -> 'tlsv1.2'. +tlsv13() -> 'tlsv1.3'. normalise_error(Error = {failed_connect, Opts}) -> Ipv6 = case lists:keyfind(inet6, 1, Opts) of diff --git a/test/gleam_httpc_test.gleam b/test/gleam_httpc_test.gleam index a34ece0..2f17bc0 100644 --- a/test/gleam_httpc_test.gleam +++ b/test/gleam_httpc_test.gleam @@ -161,3 +161,30 @@ pub fn timeout_error_test() { |> httpc.dispatch(req) == Error(httpc.ResponseTimeout) } + +pub fn tls_versions_tls12_enforced_test() { + // Verify TLS 1.2 is actually used when configured + // check.ja3.zone returns JSON with "protocol" field showing TLS version + let assert Ok(req) = request.to("https://check.ja3.zone/") + + let assert Ok(resp) = + httpc.configure() + |> httpc.verify_tls(False) + |> httpc.tls_versions([httpc.Tls12]) + |> httpc.dispatch(req) + + assert string.contains(resp.body, "\"protocol\":\"TLSv1.2\"") +} + +pub fn tls_versions_tls13_enforced_test() { + // Verify TLS 1.3 is actually used when configured + let assert Ok(req) = request.to("https://check.ja3.zone/") + + let assert Ok(resp) = + httpc.configure() + |> httpc.verify_tls(False) + |> httpc.tls_versions([httpc.Tls13]) + |> httpc.dispatch(req) + + assert string.contains(resp.body, "\"protocol\":\"TLSv1.3\"") +}