From 4da250b86b21f4d5262a51c5a05751ca5d905e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariano=20Vall=C3=A9s?= Date: Sat, 30 May 2026 17:39:28 +0200 Subject: [PATCH 1/4] fix: rewrite cert_expired to root_cert_expired for cross-sign recovery OTP's ssl_certificate:find_cross_sign_root_paths/4 recovers from an expired cross-signed root by locating an alternative valid root with the same public key in the trust store. It only triggers when path validation reports root_cert_expired. ssl_verify_hostname:verify_fun/3 returns {fail, {bad_cert, cert_expired}} verbatim, which terminates the handshake before OTP's recovery can run. Wrap the verify_fun in check_hostname_opts/1 to intercept cert_expired and rewrite it to root_cert_expired. All other events are delegated to ssl_verify_hostname unchanged, so hostname checking is unaffected. Confirmed against rest.fra-01.braze.eu (Let's Encrypt chain containing the ISRG Root X2 cross-signed by ISRG Root X1, expired 2025-09-15) using hackney 1.25.0, certifi 2.15.0, OTP 27. --- src/hackney_ssl.erl | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/hackney_ssl.erl b/src/hackney_ssl.erl index af628be5..70d0c9f1 100644 --- a/src/hackney_ssl.erl +++ b/src/hackney_ssl.erl @@ -82,8 +82,20 @@ merge_ssl_opts(Host, OverrideOpts, Options) -> check_hostname_opts(Host0) -> Host1 = string:trim(Host0, trailing, "."), + %% Wrap ssl_verify_hostname to rewrite {bad_cert, cert_expired} to + %% {bad_cert, root_cert_expired} before delegating. OTP's cross-sign + %% recovery (ssl_certificate:find_cross_sign_root_paths/4) only runs + %% when path validation reports root_cert_expired; ssl_verify_hostname + %% returns cert_expired verbatim, which causes the handshake to fail + %% before recovery can trigger. Affected chains include Let's Encrypt + %% endpoints that present the ISRG Root X2 cross-signed by ISRG Root X1 + %% (validity 2020-09-04 to 2025-09-15, now expired). VerifyFun = { - fun ssl_verify_hostname:verify_fun/3, + fun(_Cert, {bad_cert, cert_expired}, _State) -> + {fail, {bad_cert, root_cert_expired}}; + (Cert, Event, State) -> + ssl_verify_hostname:verify_fun(Cert, Event, State) + end, [{check_hostname, Host1}] }, SslOpts = [{verify, verify_peer}, From e7e78f2b9035fce4b4f7d1628926b98482cfb533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariano=20Vall=C3=A9s?= Date: Mon, 1 Jun 2026 09:57:46 +0200 Subject: [PATCH 2/4] shorten comment in check_hostname_opts --- src/hackney_ssl.erl | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/hackney_ssl.erl b/src/hackney_ssl.erl index 70d0c9f1..d8f7ba19 100644 --- a/src/hackney_ssl.erl +++ b/src/hackney_ssl.erl @@ -82,14 +82,9 @@ merge_ssl_opts(Host, OverrideOpts, Options) -> check_hostname_opts(Host0) -> Host1 = string:trim(Host0, trailing, "."), - %% Wrap ssl_verify_hostname to rewrite {bad_cert, cert_expired} to - %% {bad_cert, root_cert_expired} before delegating. OTP's cross-sign - %% recovery (ssl_certificate:find_cross_sign_root_paths/4) only runs - %% when path validation reports root_cert_expired; ssl_verify_hostname - %% returns cert_expired verbatim, which causes the handshake to fail - %% before recovery can trigger. Affected chains include Let's Encrypt - %% endpoints that present the ISRG Root X2 cross-signed by ISRG Root X1 - %% (validity 2020-09-04 to 2025-09-15, now expired). + %% Rewrite cert_expired -> root_cert_expired so OTP's cross-sign recovery + %% (find_cross_sign_root_paths/4) triggers; ssl_verify_hostname returns + %% cert_expired verbatim, which bypasses it entirely. VerifyFun = { fun(_Cert, {bad_cert, cert_expired}, _State) -> {fail, {bad_cert, root_cert_expired}}; From b110719b3394e791088ce34ca3889f297ad55396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariano=20Vall=C3=A9s?= Date: Mon, 1 Jun 2026 10:19:15 +0200 Subject: [PATCH 3/4] test: verify cert_expired is rewritten to root_cert_expired in verify_fun --- test/hackney_ssl_tests.erl | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/hackney_ssl_tests.erl b/test/hackney_ssl_tests.erl index 2d98e339..b28aa64f 100644 --- a/test/hackney_ssl_tests.erl +++ b/test/hackney_ssl_tests.erl @@ -67,3 +67,18 @@ check_hostname_opts_test() -> Opts = hackney_ssl:check_hostname_opts("example.com"), ?assertEqual(verify_peer, proplists:get_value(verify, Opts)), ?assert(lists:keymember(cacerts, 1, Opts) orelse lists:keymember(cacertfile, 1, Opts)). + +verify_fun_rewrites_cert_expired_test() -> + %% cert_expired must be rewritten to root_cert_expired so OTP's + %% ssl_certificate:find_cross_sign_root_paths/4 recovery can trigger + %% (e.g. expired ISRG Root X2 cross-signed anchor in Let's Encrypt chains). + Opts = hackney_ssl:check_hostname_opts("example.com"), + {VerifyFun, InitState} = proplists:get_value(verify_fun, Opts), + ?assertEqual({fail, {bad_cert, root_cert_expired}}, + VerifyFun(fake_cert, {bad_cert, cert_expired}, InitState)). + +verify_fun_passes_through_other_bad_cert_test() -> + %% Other bad_cert reasons must not be silently rewritten. + Opts = hackney_ssl:check_hostname_opts("example.com"), + {VerifyFun, InitState} = proplists:get_value(verify_fun, Opts), + {fail, _} = VerifyFun(fake_cert, {bad_cert, unknown_ca}, InitState). From 2e386f49b8a5bf17627025b93f43f8ef6e69caeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariano=20Vall=C3=A9s?= Date: Mon, 1 Jun 2026 10:47:03 +0200 Subject: [PATCH 4/4] test: strengthen assertion on pass-through bad_cert test --- test/hackney_ssl_tests.erl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/hackney_ssl_tests.erl b/test/hackney_ssl_tests.erl index b28aa64f..67473a95 100644 --- a/test/hackney_ssl_tests.erl +++ b/test/hackney_ssl_tests.erl @@ -81,4 +81,5 @@ verify_fun_passes_through_other_bad_cert_test() -> %% Other bad_cert reasons must not be silently rewritten. Opts = hackney_ssl:check_hostname_opts("example.com"), {VerifyFun, InitState} = proplists:get_value(verify_fun, Opts), - {fail, _} = VerifyFun(fake_cert, {bad_cert, unknown_ca}, InitState). + ?assertEqual({fail, {bad_cert, unknown_ca}}, + VerifyFun(fake_cert, {bad_cert, unknown_ca}, InitState)).