diff --git a/changelog.d/hackney-castore.security b/changelog.d/hackney-castore.security new file mode 100644 index 000000000..44cb90ce6 --- /dev/null +++ b/changelog.d/hackney-castore.security @@ -0,0 +1 @@ +Hackney and Finch now trust only the CAStore certificate bundle, which rejects the compromised eMudhra intermediate CAs diff --git a/changelog.d/mailer-tls-options.fix b/changelog.d/mailer-tls-options.fix new file mode 100644 index 000000000..fe17eea6d --- /dev/null +++ b/changelog.d/mailer-tls-options.fix @@ -0,0 +1 @@ +Fix Swoosh HTTP mail adapters failing with `cacerts: :undefined` on OTP 26+ diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 353e91f72..adc054f3b 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -66,6 +66,7 @@ defmodule Pleroma.Application do if match?({Tesla.Adapter.Finch, _}, adapter) do Logger.info("Starting Finch") + use_castore_for_otp_cacerts() Finch.start_link(name: MyFinch) end @@ -304,6 +305,15 @@ defmodule Pleroma.Application do end end + # Mint trusts the OTP CA store, which comes from the OS. Make it the CAStore + # bundle that the other adapters trust. OTP 24 has no such store and Mint + # uses CAStore there anyway. + defp use_castore_for_otp_cacerts do + if Code.ensure_loaded?(:public_key) and function_exported?(:public_key, :cacerts_load, 1) do + :ok = :public_key.cacerts_load(CAStore.file_path()) + end + end + defp http_children_hackney do pools = [:federation, :media] diff --git a/lib/pleroma/emails/mailer.ex b/lib/pleroma/emails/mailer.ex index 2a80f8547..fa6fd8020 100644 --- a/lib/pleroma/emails/mailer.ex +++ b/lib/pleroma/emails/mailer.ex @@ -36,10 +36,11 @@ defmodule Pleroma.Emails.Mailer do def deliver(email, config \\ []) def deliver(email, config) do - # temporary hackney fix until hackney max_connections bug is fixed - # https://git.pleroma.social/pleroma/pleroma/-/issues/2101 + # Swoosh HTTP adapters use Hackney, which otherwise trusts its certifi bundle email = - Swoosh.Email.put_private(email, :hackney_options, ssl_options: [versions: [:"tlsv1.2"]]) + Swoosh.Email.put_private(email, :hackney_options, + ssl_options: Pleroma.HTTP.TLS.ssl_options() + ) case enabled?() do true -> Swoosh.Mailer.deliver(email, parse_config(config)) diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index 314f9e8b1..32a186e8f 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -30,6 +30,7 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do |> Pleroma.HTTP.AdapterHelper.maybe_add_proxy(proxy) |> Keyword.put(:follow_redirect, false) |> Keyword.put(:force_redirect, false) + |> Pleroma.HTTP.TLS.put_hackney_ssl_options() end defp add_scheme_opts(opts, _), do: opts diff --git a/lib/pleroma/http/tls.ex b/lib/pleroma/http/tls.ex new file mode 100644 index 000000000..e1fbc7caa --- /dev/null +++ b/lib/pleroma/http/tls.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.TLS do + @moduledoc """ + TLS client options that trust the CAStore bundle. + + Hackney otherwise trusts its own certifi bundle. Hackney 1 drops all of its + default TLS options, including certificate checks, as soon as any + `ssl_options` are given. Hackney 4 merges them, so `partial_chain` must be + set too: Hackney's own one trusts any certificate in the peer chain that is + in certifi. + """ + + @spec ssl_options(keyword()) :: keyword() + def ssl_options(overrides \\ []) do + Keyword.merge( + [ + verify: :verify_peer, + cacertfile: CAStore.file_path(), + depth: 20, + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)], + partial_chain: &__MODULE__.reject_partial_chain/1 + ], + overrides + ) + end + + @doc false + def reject_partial_chain(_certs), do: :unknown_ca + + @spec put_hackney_ssl_options(keyword()) :: keyword() + def put_hackney_ssl_options(opts) do + if opts[:insecure] do + opts + else + Keyword.put(opts, :ssl_options, ssl_options(Keyword.get(opts, :ssl_options, []))) + end + end +end diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex index 948787497..b4d2b5e01 100644 --- a/lib/pleroma/reverse_proxy/client/hackney.ex +++ b/lib/pleroma/reverse_proxy/client/hackney.ex @@ -28,9 +28,9 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do @impl true def request(method, url, headers, body, opts \\ []) do opts = - Keyword.put_new(opts, :path_encode_fun, fn path -> - path - end) + opts + |> Keyword.put_new(:path_encode_fun, fn path -> path end) + |> Pleroma.HTTP.TLS.put_hackney_ssl_options() if opts[:follow_redirect] != false do {_state, req_opts} = Access.get_and_update(opts, :follow_redirect, fn a -> {a, false} end) diff --git a/test/pleroma/emails/mailer_test.exs b/test/pleroma/emails/mailer_test.exs index 7a718c653..2c2b5a4af 100644 --- a/test/pleroma/emails/mailer_test.exs +++ b/test/pleroma/emails/mailer_test.exs @@ -52,4 +52,13 @@ defmodule Pleroma.Emails.MailerTest do subject: "Pleroma test email" ) end + + test "keeps certificate checks for HTTP API adapters" do + Mailer.deliver(@email) + + assert_received {:email, %Swoosh.Email{private: %{hackney_options: hackney_options}}} + + assert hackney_options[:ssl_options] == + Pleroma.HTTP.TLS.ssl_options() + end end diff --git a/test/pleroma/http/adapter_helper/hackney_test.exs b/test/pleroma/http/adapter_helper/hackney_test.exs index cf60d994b..42d0aaf6b 100644 --- a/test/pleroma/http/adapter_helper/hackney_test.exs +++ b/test/pleroma/http/adapter_helper/hackney_test.exs @@ -46,5 +46,20 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do assert opts[:b] == 1 refute Keyword.has_key?(opts, :proxy) end + + test "trusts the CAStore bundle", %{uri: uri} do + opts = Hackney.options([], uri) + + assert opts[:ssl_options] == Pleroma.HTTP.TLS.ssl_options() + end + + test "keeps the CAStore bundle with configured ssl_options", %{uri: uri} do + clear_config([:http, :adapter], ssl_options: [versions: [:"tlsv1.2"]]) + + opts = Hackney.options([], uri) + + assert opts[:ssl_options][:versions] == [:"tlsv1.2"] + assert opts[:ssl_options][:cacertfile] == CAStore.file_path() + end end end diff --git a/test/pleroma/http/tls_test.exs b/test/pleroma/http/tls_test.exs new file mode 100644 index 000000000..490950b50 --- /dev/null +++ b/test/pleroma/http/tls_test.exs @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.TLSTest do + use ExUnit.Case, async: true + + alias Pleroma.HTTP.TLS + + describe "ssl_options/1" do + test "verifies peers against the CAStore bundle" do + opts = TLS.ssl_options() + + assert opts[:verify] == :verify_peer + assert opts[:cacertfile] == CAStore.file_path() + assert [match_fun: match_fun] = opts[:customize_hostname_check] + assert is_function(match_fun, 2) + end + + test "does not accept certificates from the peer chain as trust anchors" do + partial_chain = TLS.ssl_options()[:partial_chain] + + assert partial_chain.([:certificate]) == :unknown_ca + end + + test "keeps the CA bundle when other options are given" do + opts = TLS.ssl_options(versions: [:"tlsv1.2"]) + + assert opts[:versions] == [:"tlsv1.2"] + assert opts[:cacertfile] == CAStore.file_path() + assert opts[:verify] == :verify_peer + end + end + + describe "put_hackney_ssl_options/1" do + test "adds the TLS options" do + opts = TLS.put_hackney_ssl_options(pool: :media) + + assert opts[:pool] == :media + assert opts[:ssl_options] == TLS.ssl_options() + end + + test "merges given ssl_options into the TLS options" do + opts = TLS.put_hackney_ssl_options(ssl_options: [versions: [:"tlsv1.2"]]) + + assert opts[:ssl_options] == TLS.ssl_options(versions: [:"tlsv1.2"]) + end + + test "leaves insecure requests alone" do + assert TLS.put_hackney_ssl_options(insecure: true) == [insecure: true] + end + end +end diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 63c0c2bb8..ec5a2a12a 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -531,9 +531,9 @@ defmodule Pleroma.UserTest do ObanHelpers.perform_all() Pleroma.Emails.UserEmail.account_confirmation_email(registered_user) - # temporary hackney fix until hackney max_connections bug is fixed - # https://git.pleroma.social/pleroma/pleroma/-/issues/2101 - |> Swoosh.Email.put_private(:hackney_options, ssl_options: [versions: [:"tlsv1.2"]]) + |> Swoosh.Email.put_private(:hackney_options, + ssl_options: Pleroma.HTTP.TLS.ssl_options() + ) |> assert_email_sent() end diff --git a/test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs b/test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs index fc578d828..aa38a7454 100644 --- a/test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs @@ -1042,9 +1042,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do ObanHelpers.perform_all() Pleroma.Emails.UserEmail.account_confirmation_email(first_user) - # temporary hackney fix until hackney max_connections bug is fixed - # https://git.pleroma.social/pleroma/pleroma/-/issues/2101 - |> Swoosh.Email.put_private(:hackney_options, ssl_options: [versions: [:"tlsv1.2"]]) + |> Swoosh.Email.put_private(:hackney_options, + ssl_options: Pleroma.HTTP.TLS.ssl_options() + ) |> assert_email_sent() end