From ecec227d9a4f5d6f266e7b9e4c618acae9e08777 Mon Sep 17 00:00:00 2001 From: Zabrane Date: Fri, 25 Sep 2026 16:22:52 +0200 Subject: [PATCH] Add TLS transport option to pin the TLS version Callers had no working way to pin the TLS version: a `versions` entry in transport_opts is silently dropped, since connect/3 only forwards `socket_opts`, `verify`, `cacerts` and the buffer sizes to ssl:connect. Add `{tls, "1.2"}` / `{tls, "1.3"}`, mapped to `{versions, ['tlsv1.2']}` / `{versions, ['tlsv1.3']}` in both connect/3 and upgrade/5. Any other value raises `{invalid_tls_version, Value}` before connecting. Without the option, the OTP default applies, as before. When TLS 1.3 is pinned, drop the hardcoded `{reuse_sessions, true}`: it is a TLS =< 1.2 option, and OTP rejects it alongside a TLS-1.3-only version list with `{options, incompatible, [reuse_sessions, {versions, ['tlsv1.3']}]}`. Verified against a live server: "1.2" negotiates tlsv1.2, "1.3" negotiates tlsv1.3, "1.1" is rejected. --- src/gen_http_ssl.erl | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/gen_http_ssl.erl b/src/gen_http_ssl.erl index b2581ee..d69a2f5 100644 --- a/src/gen_http_ssl.erl +++ b/src/gen_http_ssl.erl @@ -40,6 +40,7 @@ Options: - `socket_opts` - Additional SSL options - `verify` (default `verify_peer`) - Certificate verification mode - `cacerts` - CA certificates for verification +- `tls` - Pin the TLS version: `"1.2"` or `"1.3"`. Absent: OTP default. """). -spec connect(address(), inet:port_number(), proplists:proplist()) -> {ok, socket()} | {error, term()}. @@ -71,9 +72,8 @@ connect(Address, Port, Opts) -> {packet, raw}, {active, false}, {alpn_advertised_protocols, AlpnProtocols}, - {reuseaddr, true}, - {reuse_sessions, true} - | SniOpts ++ [{K, V} || {K, V} <- Opts, K =:= sndbuf orelse K =:= recbuf] + {reuseaddr, true} + | session_opts(Opts) ++ SniOpts ++ [{K, V} || {K, V} <- Opts, K =:= sndbuf orelse K =:= recbuf] ], %% Add verification options (verify_peer by default) @@ -95,7 +95,8 @@ connect(Address, Port, Opts) -> end, %% Combine all options - SocketOpts = BaseOpts ++ VerifyOpts ++ proplists:get_value(socket_opts, Opts, []), + SocketOpts = BaseOpts ++ VerifyOpts ++ tls_version_opts(Opts) + ++ proplists:get_value(socket_opts, Opts, []), case ssl:connect(NormalizedAddress, Port, SocketOpts, Timeout) of {ok, Socket} -> @@ -129,7 +130,7 @@ upgrade(Socket, _OriginalScheme, Hostname, _Port, Opts) -> {active, false}, {alpn_advertised_protocols, AlpnProtocols}, {server_name_indication, binary_to_list(Hostname)} - | proplists:get_value(socket_opts, Opts, []) + | tls_version_opts(Opts) ++ proplists:get_value(socket_opts, Opts, []) ], ssl:connect(Socket, SSLOpts, Timeout). @@ -260,3 +261,23 @@ get_cacerts_opts(Opts) -> CACerts -> [{cacerts, CACerts}] end. + +%% `{tls, "1.2"}' / `{tls, "1.3"}' pins the negotiated TLS version. +%% Anything else is a configuration error, raised before connecting. +-spec tls_version_opts(proplists:proplist()) -> [{versions, [ssl:tls_version()]}]. +tls_version_opts(Opts) -> + case proplists:get_value(tls, Opts) of + undefined -> []; + "1.2" -> [{versions, ['tlsv1.2']}]; + "1.3" -> [{versions, ['tlsv1.3']}]; + Other -> error({invalid_tls_version, Other}) + end. + +%% `reuse_sessions' is a TLS =< 1.2 option: OTP rejects it alongside a +%% TLS-1.3-only `versions' list, so drop it when 1.3 is pinned. +-spec session_opts(proplists:proplist()) -> [{reuse_sessions, true}]. +session_opts(Opts) -> + case proplists:get_value(tls, Opts) of + "1.3" -> []; + _ -> [{reuse_sessions, true}] + end.