diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..06abcb4 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 28.5 +elixir 1.19.4 \ No newline at end of file diff --git a/lib/httpoison.ex b/lib/httpoison.ex index 8964404..ec3662f 100644 --- a/lib/httpoison.ex +++ b/lib/httpoison.ex @@ -29,11 +29,12 @@ defmodule HTTPoison.Request do * `:socks5_user`- socks5 username * `:socks5_pass`- socks5 password * `:ssl` - SSL options supported by the `ssl` erlang module. SSL defaults will be used where options - are not specified. Note: under hackney 4.0 the bare `verify: :verify_none` idiom no longer disables - certificate verification on its own, because hackney injects its own hostname-checking `verify_fun` - that OTP still invokes. HTTPoison detects `verify: :verify_none` given without a custom `:verify_fun` - and injects a permissive one so verification is actually skipped. Supply your own `:verify_fun` for - finer-grained control. + are not specified. When available, trusted CA certificates default to the OS trust store loaded by + `:public_key.cacerts_get/0`. Note: under hackney 4.0 the bare `verify: :verify_none` idiom no longer + disables certificate verification on its own, because hackney injects its own hostname-checking + `verify_fun` that OTP still invokes. HTTPoison detects `verify: :verify_none` given without a custom + `:verify_fun` and injects a permissive one so verification is actually skipped. Supply your own + `:verify_fun` for finer-grained control. * `:ssl_override` - if `:ssl` is specified, this option is ignored, otherwise it can be used to completely override SSL settings. * `:follow_redirect` - a boolean that causes redirects to be followed (resolved internally by diff --git a/lib/httpoison/base.ex b/lib/httpoison/base.ex index 04bbda8..20e5212 100644 --- a/lib/httpoison/base.ex +++ b/lib/httpoison/base.ex @@ -766,15 +766,7 @@ defmodule HTTPoison.Base do stream_to = Keyword.get(options, :stream_to) async = Keyword.get(options, :async) - ssl = - if ssl_opts = Keyword.get(options, :ssl) do - # Extract the host from the URL just like hackney does - host = hackney_url_record(:hackney_url.parse_url(url), :host) - - :hackney_ssl.ssl_opts(host, [{:ssl_options, allow_insecure_verify(ssl_opts)}]) - else - Keyword.get(options, :ssl_override) - end + ssl = build_ssl_options(url, options) follow_redirect = Keyword.get(options, :follow_redirect) max_redirect = Keyword.get(options, :max_redirect) @@ -826,6 +818,59 @@ defmodule HTTPoison.Base do defp allow_insecure_verify(ssl_opts), do: ssl_opts + defp build_ssl_options(url, options) do + cond do + Keyword.has_key?(options, :ssl) -> + # Extract the host from the URL just like hackney does. + host = hackney_url_record(:hackney_url.parse_url(url), :host) + + ssl_opts = + options + |> Keyword.get(:ssl, []) + |> put_default_cacerts() + |> allow_insecure_verify() + + :hackney_ssl.ssl_opts(host, [{:ssl_options, ssl_opts}]) + + Keyword.has_key?(options, :ssl_override) -> + Keyword.get(options, :ssl_override) + + hackney_insecure?(options) -> + nil + + https_url?(url) -> + host = hackney_url_record(:hackney_url.parse_url(url), :host) + :hackney_ssl.ssl_opts(host, [{:ssl_options, default_cacerts()}]) + + true -> + nil + end + end + + defp put_default_cacerts(ssl_opts) do + case Keyword.has_key?(ssl_opts, :cacerts) || Keyword.has_key?(ssl_opts, :cacertfile) do + true -> ssl_opts + _ -> Keyword.merge(default_cacerts(), ssl_opts) + end + end + + if Code.ensure_loaded?(:public_key) and function_exported?(:public_key, :cacerts_get, 0) do + defp default_cacerts do + [cacerts: :public_key.cacerts_get()] + end + else + defp default_cacerts(), do: [] + end + + defp https_url?(url) when is_binary(url), do: URI.parse(url).scheme == "https" + defp https_url?(_url), do: false + + defp hackney_insecure?(options) do + hackney_options = Keyword.get(options, :hackney, []) + + Keyword.get(hackney_options, :insecure, false) || :insecure in hackney_options + end + defp build_hackney_proxy_options(%Request{options: options, url: request_url}) do proxy = if Keyword.has_key?(options, :proxy) do diff --git a/test/httpoison_base_test.exs b/test/httpoison_base_test.exs index b1e80c5..20ef16c 100644 --- a/test/httpoison_base_test.exs +++ b/test/httpoison_base_test.exs @@ -503,7 +503,17 @@ defmodule HTTPoisonBaseTest do end defp expect_hackney_post_with_proxy(url, proxy) do - expect_hackney_post(url, proxy: proxy) + if String.starts_with?(url, "https://") do + expect(:hackney, :request, fn :post, ^url, [], "body", options -> + assert options[:proxy] == proxy + assert options[:ssl_options][:verify] == :verify_peer + assert is_list(options[:ssl_options][:cacerts]) + + {:ok, 200, "headers", "response"} + end) + else + expect_hackney_post(url, proxy: proxy) + end end defp expect_hackney_post_with_no_proxy(url) do @@ -598,6 +608,32 @@ defmodule HTTPoisonBaseTest do ) end + test "https requests use public key CA certs by default" do + expect(:hackney, :request, fn :get, "https://localhost", [], "", [ssl_options: opts] -> + assert opts[:verify] == :verify_peer + assert opts[:customize_hostname_check][:match_fun] + assert is_list(opts[:cacerts]) + assert opts[:cacerts] == :public_key.cacerts_get() + + {:ok, 200, "headers", "response"} + end) + + assert %HTTPoison.Response{status_code: 200} = HTTPoison.get!("https://localhost") + end + + test "explicit cacertfile is not replaced by public key CA certs" do + expect(:hackney, :request, fn :get, "https://localhost", [], "", [ssl_options: opts] -> + assert opts[:verify] == :verify_peer + refute Keyword.has_key?(opts, :cacerts) + assert opts[:cacertfile] == "certs/ca.crt" + + {:ok, 200, "headers", "response"} + end) + + assert %HTTPoison.Response{status_code: 200} = + HTTPoison.get!("https://localhost", [], ssl: [cacertfile: "certs/ca.crt"]) + end + test "passing follow_redirect option" do expect(:hackney, :request, fn :post, "http://localhost",