From 6823f5c7da46af99f37c7cb08babff8df90be320 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sun, 25 Oct 2026 21:09:22 +0100 Subject: [PATCH] Verify HTTPS CONNECT proxies like the target The proxy leg of a CONNECT tunnel was opened with SNI only. On OTP 27+ that fails to connect without cacerts, and hackney's CA bundle and hostname check never applied. Build the options with hackney_ssl, as the target leg does; an explicit verify_none is kept as given. --- NEWS.md | 5 +++++ src/hackney_http_connect.erl | 23 ++++++++++++++++------- test/hackney_proxy_tests.erl | 23 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/NEWS.md b/NEWS.md index 4e3d5cfb..fbff3006 100644 --- a/NEWS.md +++ b/NEWS.md @@ -31,6 +31,11 @@ Unreleased bracketed IPv6 address, or behind a trailing dot, are now refused like other IP literals. The earlier fix for CVE-2026-47076 missed these two spellings, so such a URL still reached the address it denotes (#987). +- HTTPS proxies reached through CONNECT now get the same certificate checks + as the target: hackney's CA bundle and a hostname check against the + proxy. The connection to the proxy only set the server name, so on + OTP 27 and later it failed unless `proxy_ssl_options` set `cacerts` or + `verify_none`. An explicit `{verify, verify_none}` is still honoured (#988). ### Changed diff --git a/src/hackney_http_connect.erl b/src/hackney_http_connect.erl index e7694937..0504554c 100644 --- a/src/hackney_http_connect.erl +++ b/src/hackney_http_connect.erl @@ -29,17 +29,26 @@ ssl_opts(Host, Opts) -> hackney_ssl:ssl_opts(Host, Opts). -%% @doc TLS options for the proxy leg. Add SNI for the proxy host unless the -%% user already set one or the proxy is addressed by IP literal (RFC 6066 -%% forbids SNI for IP literals). +%% @doc TLS options for the proxy leg: the same verification as the target +%% leg (verify_peer, the bundled CA store, a hostname check against the proxy +%% host) with `proxy_ssl_options' on top. A caller who sets +%% `{verify, verify_none}' gets their options as given. SNI is set to the +%% proxy host unless the user set one or the proxy is addressed by IP literal +%% (RFC 6066 forbids SNI for IP literals). -spec proxy_ssl_opts(string() | binary(), list()) -> list(). +proxy_ssl_opts(ProxyHost, ProxySslOpts) when is_binary(ProxyHost) -> + proxy_ssl_opts(binary_to_list(ProxyHost), ProxySslOpts); proxy_ssl_opts(ProxyHost, ProxySslOpts) -> - case lists:keymember(server_name_indication, 1, ProxySslOpts) of - true -> ProxySslOpts; + Opts = case proplists:get_value(verify, ProxySslOpts) of + verify_none -> ProxySslOpts; + _ -> hackney_ssl:ssl_opts(ProxyHost, [{ssl_options, ProxySslOpts}]) + end, + case lists:keymember(server_name_indication, 1, Opts) of + true -> Opts; false -> case hackney_url:is_ip_literal(ProxyHost) of - true -> ProxySslOpts; - false -> [{server_name_indication, ProxyHost} | ProxySslOpts] + true -> Opts; + false -> [{server_name_indication, ProxyHost} | Opts] end end. diff --git a/test/hackney_proxy_tests.erl b/test/hackney_proxy_tests.erl index 06641ac8..769028f2 100644 --- a/test/hackney_proxy_tests.erl +++ b/test/hackney_proxy_tests.erl @@ -1068,6 +1068,29 @@ proxy_ssl_opts_ipv6_test() -> Opts = hackney_http_connect:proxy_ssl_opts("::1", []), ?assertNot(proplists:is_defined(server_name_indication, Opts)). +%% The proxy leg verifies the proxy's certificate like the target leg does; +%% it used to carry SNI only, which OTP 27+ refuses to connect with. +proxy_ssl_opts_verifies_by_default_test() -> + Opts = hackney_http_connect:proxy_ssl_opts("proxy.example", []), + ?assertEqual(verify_peer, proplists:get_value(verify, Opts)), + ?assertMatch([_ | _], proplists:get_value(cacerts, Opts)), + ?assert(proplists:is_defined(verify_fun, Opts)). + +%% An explicit opt-out is used as given, with SNI still set. +proxy_ssl_opts_verify_none_as_given_test() -> + Opts = hackney_http_connect:proxy_ssl_opts("proxy.example", + [{verify, verify_none}]), + ?assertEqual(verify_none, proplists:get_value(verify, Opts)), + ?assertNot(proplists:is_defined(verify_fun, Opts)), + ?assertEqual("proxy.example", proplists:get_value(server_name_indication, Opts)). + +%% Caller trust options are merged over the defaults, not replacing them. +proxy_ssl_opts_custom_ca_test() -> + Opts = hackney_http_connect:proxy_ssl_opts("proxy.example", + [{cacerts, [<<"der">>]}]), + ?assertEqual(verify_peer, proplists:get_value(verify, Opts)), + ?assertEqual([<<"der">>], proplists:get_value(cacerts, Opts)). + proxy_ssl_opts_user_sni_test() -> Opts = hackney_http_connect:proxy_ssl_opts( "proxy.example", [{server_name_indication, "set.by.user"}]),