From 0c030721c9bf1c2f793030ccda48f2e789d26bf0 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/5] 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 6b1f1f77e74394e673d345420a404176371b4a68 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/5] 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 3ad7d22fa6bddf95bb37569911dc2131e802425c 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/5] 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 f658d1cf20042e2518a507d79d1a21bddbfa8f54 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/5] 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)). From ea33a1341440535f44975952748b3cbc24392bae Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Wed, 3 Jun 2026 17:45:35 +0200 Subject: [PATCH 5/5] test: guard partial chain and valid pass-through in cross-sign verify_fun Add regression tests that the verify_fun wrapper delegates valid and extension events to ssl_verify_hostname unchanged, and that the partial_chain option is preserved, so valid and partial certificate chains still verify after the cert_expired rewrite. --- test/hackney_ssl_tests.erl | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/hackney_ssl_tests.erl b/test/hackney_ssl_tests.erl index 67473a95..f974fc28 100644 --- a/test/hackney_ssl_tests.erl +++ b/test/hackney_ssl_tests.erl @@ -83,3 +83,20 @@ verify_fun_passes_through_other_bad_cert_test() -> {VerifyFun, InitState} = proplists:get_value(verify_fun, Opts), ?assertEqual({fail, {bad_cert, unknown_ca}}, VerifyFun(fake_cert, {bad_cert, unknown_ca}, InitState)). + +verify_fun_passes_through_valid_test() -> + %% Valid and extension events must delegate to ssl_verify_hostname + %% unchanged, so valid chains (including partial chains whose anchor is + %% accepted) still verify. Only cert_expired is rewritten. + Opts = hackney_ssl:check_hostname_opts("example.com"), + {VerifyFun, InitState} = proplists:get_value(verify_fun, Opts), + ?assertEqual({valid, InitState}, VerifyFun(fake_cert, valid, InitState)), + ?assertEqual({unknown, InitState}, + VerifyFun(fake_cert, {extension, fake_ext}, InitState)). + +partial_chain_preserved_test() -> + %% The cross-sign verify_fun change must not drop the partial_chain + %% option; partial certificate chains rely on it to pick a trusted anchor. + Opts = hackney_ssl:check_hostname_opts("example.com"), + PartialChain = proplists:get_value(partial_chain, Opts), + ?assert(is_function(PartialChain, 1)).