diff --git a/src/hackney_ssl.erl b/src/hackney_ssl.erl index af628be5..d8f7ba19 100644 --- a/src/hackney_ssl.erl +++ b/src/hackney_ssl.erl @@ -82,8 +82,15 @@ merge_ssl_opts(Host, OverrideOpts, Options) -> check_hostname_opts(Host0) -> Host1 = string:trim(Host0, trailing, "."), + %% 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 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}, diff --git a/test/hackney_ssl_tests.erl b/test/hackney_ssl_tests.erl index 2d98e339..67473a95 100644 --- a/test/hackney_ssl_tests.erl +++ b/test/hackney_ssl_tests.erl @@ -67,3 +67,19 @@ 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), + ?assertEqual({fail, {bad_cert, unknown_ca}}, + VerifyFun(fake_cert, {bad_cert, unknown_ca}, InitState)).