Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
17 KB
Referenced Files
None
Subscribers
None
diff --git a/changelog.d/hackney-castore.security b/changelog.d/hackney-castore.security
index 44cb90ce6..8d1a0cf4a 100644
--- a/changelog.d/hackney-castore.security
+++ b/changelog.d/hackney-castore.security
@@ -1 +1 @@
-Hackney and Finch now trust only the CAStore certificate bundle, which rejects the compromised eMudhra intermediate CAs
+Hackney, Finch, Mua and the Swoosh HTTP mail adapters now trust only the CAStore certificate bundle, which rejects the compromised eMudhra intermediate CAs
diff --git a/lib/pleroma/emails/mailer.ex b/lib/pleroma/emails/mailer.ex
index fa6fd8020..e6abf4456 100644
--- a/lib/pleroma/emails/mailer.ex
+++ b/lib/pleroma/emails/mailer.ex
@@ -1,73 +1,88 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Emails.Mailer do
@moduledoc """
Defines the Pleroma mailer.
The module contains functions to delivery email using Swoosh.Mailer.
"""
alias Pleroma.Workers.MailerWorker
alias Swoosh.DeliveryError
@otp_app :pleroma
@mailer_config [otp: :pleroma]
@spec enabled?() :: boolean()
def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled])
@doc "add email to queue"
def deliver_async(email, config \\ []) do
encoded_email =
email
|> :erlang.term_to_binary()
|> Base.encode64()
MailerWorker.new(%{"op" => "email", "encoded_email" => encoded_email, "config" => config})
|> Oban.insert()
end
@doc "callback to perform send email from queue"
def perform(:deliver_async, email, config), do: deliver(email, config)
@spec deliver(Swoosh.Email.t(), Keyword.t()) :: {:ok, term} | {:error, term}
def deliver(email, config \\ [])
def deliver(email, config) do
# Swoosh HTTP adapters use Hackney, which otherwise trusts its certifi bundle
email =
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))
false -> {:error, :deliveries_disabled}
end
end
@spec deliver!(Swoosh.Email.t(), Keyword.t()) :: term | no_return
def deliver!(email, config \\ [])
def deliver!(email, config) do
case deliver(email, config) do
{:ok, result} -> result
{:error, reason} -> raise DeliveryError, reason: reason
end
end
@on_load :validate_dependency
@doc false
def validate_dependency do
parse_config([])
|> Keyword.get(:adapter)
|> Swoosh.Mailer.validate_dependency()
end
defp parse_config(config) do
- Swoosh.Mailer.parse_config(@otp_app, __MODULE__, @mailer_config, config)
+ @otp_app
+ |> Swoosh.Mailer.parse_config(__MODULE__, @mailer_config, config)
+ |> put_mua_cacertfile()
+ end
+
+ @doc false
+ # Mua otherwise trusts the OS CA store. Keep a CA that the admin set.
+ def put_mua_cacertfile(config) do
+ ssl = Keyword.get(config, :ssl, [])
+
+ if config[:adapter] == Swoosh.Adapters.Mua and
+ not Keyword.has_key?(ssl, :cacertfile) and not Keyword.has_key?(ssl, :cacerts) do
+ Keyword.put(config, :ssl, [{:cacertfile, CAStore.file_path()} | ssl])
+ else
+ config
+ end
end
end
diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex
index 32c1080f7..4f0e23f20 100644
--- a/lib/pleroma/http/adapter_helper.ex
+++ b/lib/pleroma/http/adapter_helper.ex
@@ -1,131 +1,149 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.AdapterHelper do
@moduledoc """
Configure Tesla.Client with default and customized adapter options.
"""
@defaults [pool: :federation, connect_timeout: 5_000, recv_timeout: 5_000]
@type proxy_type() :: :socks4 | :socks5
@type host() :: charlist() | :inet.ip_address()
alias Pleroma.HTTP.AdapterHelper
require Logger
@type proxy ::
{host(), pos_integer()}
| {proxy_type(), host(), pos_integer()}
@callback options(keyword(), URI.t()) :: keyword()
@spec format_proxy(String.t() | tuple() | nil) :: proxy() | nil
def format_proxy(nil), do: nil
def format_proxy(proxy_url) do
case parse_proxy(proxy_url) do
{:ok, host, port} -> {host, port}
{:ok, type, host, port} -> {type, host, port}
_ -> nil
end
end
@spec maybe_add_proxy(keyword(), proxy() | nil) :: keyword()
def maybe_add_proxy(opts, nil), do: opts
def maybe_add_proxy(opts, proxy), do: Keyword.put_new(opts, :proxy, proxy)
@doc """
Merge default connection & adapter options with received ones.
"""
@spec options(URI.t(), keyword()) :: keyword()
def options(%URI{} = uri, opts \\ []) do
@defaults
|> Keyword.merge(opts)
|> adapter_helper().options(uri)
end
+ @doc """
+ The configured Tesla adapter with TLS options, for clients that do not go
+ through `Pleroma.HTTP`.
+ """
+ @spec tesla_adapter() :: module() | {module(), keyword()}
+ def tesla_adapter do
+ case adapter() do
+ Tesla.Adapter.Hackney ->
+ {Tesla.Adapter.Hackney, Pleroma.HTTP.TLS.put_hackney_ssl_options([])}
+
+ Tesla.Adapter.Gun ->
+ {Tesla.Adapter.Gun, certificates_verification: true}
+
+ adapter ->
+ adapter
+ end
+ end
+
defp adapter, do: Application.get_env(:tesla, :adapter)
defp adapter_helper do
case adapter() do
Tesla.Adapter.Gun -> AdapterHelper.Gun
Tesla.Adapter.Hackney -> AdapterHelper.Hackney
{Tesla.Adapter.Finch, _} -> AdapterHelper.Finch
_ -> AdapterHelper.Default
end
end
@spec parse_proxy(String.t() | tuple() | nil) ::
{:ok, host(), pos_integer()}
| {:ok, proxy_type(), host(), pos_integer()}
| {:error, atom()}
| nil
def parse_proxy(nil), do: nil
def parse_proxy(proxy) when is_binary(proxy) do
with [host, port] <- String.split(proxy, ":"),
{port, ""} <- Integer.parse(port) do
{:ok, parse_host(host), port}
else
{_, _} ->
Logger.warning("Parsing port failed #{inspect(proxy)}")
{:error, :invalid_proxy_port}
:error ->
Logger.warning("Parsing port failed #{inspect(proxy)}")
{:error, :invalid_proxy_port}
_ ->
Logger.warning("Parsing proxy failed #{inspect(proxy)}")
{:error, :invalid_proxy}
end
end
def parse_proxy(proxy) when is_tuple(proxy) do
with {type, host, port} <- proxy do
{:ok, type, parse_host(host), port}
else
_ ->
Logger.warning("Parsing proxy failed #{inspect(proxy)}")
{:error, :invalid_proxy}
end
end
@spec parse_host(String.t() | atom() | charlist()) :: charlist() | :inet.ip_address()
def parse_host(host) when is_list(host), do: host
def parse_host(host) when is_atom(host), do: to_charlist(host)
def parse_host(host) when is_binary(host) do
host = to_charlist(host)
case :inet.parse_address(host) do
{:error, :einval} -> host
{:ok, ip} -> ip
end
end
@spec format_host(String.t()) :: charlist()
def format_host(host) do
host_charlist = to_charlist(host)
case :inet.parse_address(host_charlist) do
{:error, :einval} ->
:idna.encode(host_charlist)
{:ok, _ip} ->
host_charlist
end
end
@spec can_stream? :: bool()
def can_stream? do
case Application.get_env(:tesla, :adapter) do
Tesla.Adapter.Gun -> true
{Tesla.Adapter.Finch, _} -> true
_ -> false
end
end
end
diff --git a/lib/pleroma/search/qdrant_search.ex b/lib/pleroma/search/qdrant_search.ex
index 4d57cfa88..a8c5a3eb9 100644
--- a/lib/pleroma/search/qdrant_search.ex
+++ b/lib/pleroma/search/qdrant_search.ex
@@ -1,207 +1,207 @@
defmodule Pleroma.Search.QdrantSearch do
@behaviour Pleroma.Search.SearchBackend
import Ecto.Query
alias Pleroma.Activity
alias Pleroma.Config.Getting, as: Config
alias Pleroma.Object
alias Pleroma.Search
alias __MODULE__.OpenAIClient
alias __MODULE__.QdrantClient
import Pleroma.Search.DatabaseSearch, only: [maybe_fetch: 3]
@impl true
def create_index do
payload = Config.get([Pleroma.Search.QdrantSearch, :qdrant_index_configuration])
with {:ok, %{status: 200}} <- QdrantClient.put("/collections/posts", payload) do
:ok
else
e -> {:error, e}
end
end
@impl true
def drop_index do
with {:ok, %{status: 200}} <- QdrantClient.delete("/collections/posts") do
:ok
else
e -> {:error, e}
end
end
def get_embedding(text) do
with {:ok, %{body: %{"data" => [%{"embedding" => embedding}]}}} <-
OpenAIClient.post("/v1/embeddings", %{
input: text,
model: Config.get([Pleroma.Search.QdrantSearch, :openai_model])
}) do
{:ok, embedding}
else
_ ->
{:error, "Failed to get embedding"}
end
end
defp actor_from_activity(%{data: %{"actor" => actor}}) do
actor
end
defp actor_from_activity(_), do: nil
defp build_index_payload(activity, embedding) do
actor = actor_from_activity(activity)
published_at = activity.data["published"]
%{
points: [
%{
id: activity.id |> FlakeId.from_string() |> Ecto.UUID.cast!(),
vector: embedding,
payload: %{actor: actor, published_at: published_at}
}
]
}
end
defp build_search_payload(embedding, options) do
base = %{
vector: embedding,
limit: options[:limit] || 20,
offset: options[:offset] || 0
}
if author = options[:author] do
Map.put(base, :filter, %{
must: [%{key: "actor", match: %{value: author.ap_id}}]
})
else
base
end
end
@impl true
def add_to_index(%Activity{object: %Object{} = object} = activity) do
search_data = Search.object_to_search_data(object)
with {:ok, embedding} <- get_embedding(search_data.content),
{:ok, %{status: 200}} <-
QdrantClient.put(
"/collections/posts/points",
build_index_payload(activity, embedding)
) do
:ok
else
e -> {:error, e}
end
end
@impl true
def remove_from_index(object) do
activity = Activity.get_by_object_ap_id_with_object(object.data["id"])
id = activity.id |> FlakeId.from_string() |> Ecto.UUID.cast!()
with {:ok, %{status: 200}} <-
QdrantClient.post("/collections/posts/points/delete", %{"points" => [id]}) do
:ok
else
e -> {:error, e}
end
end
@impl true
def search(user, original_query, options) do
query = "Represent this sentence for searching relevant passages: #{original_query}"
with {:ok, embedding} <- get_embedding(query),
{:ok, %{body: %{"result" => result}}} <-
QdrantClient.post(
"/collections/posts/points/search",
build_search_payload(embedding, options)
) do
ids =
Enum.map(result, fn %{"id" => id} ->
Ecto.UUID.dump!(id)
end)
from(a in Activity, where: a.id in ^ids)
|> Activity.with_preloaded_object()
|> Activity.restrict_deactivated_users()
|> Ecto.Query.order_by([a], fragment("array_position(?, ?)", ^ids, a.id))
|> Pleroma.Repo.all()
|> maybe_fetch(user, original_query)
else
_ ->
[]
end
end
@impl true
def healthcheck_endpoints do
qdrant_health =
Config.get([Pleroma.Search.QdrantSearch, :qdrant_url])
|> URI.parse()
|> Map.put(:path, "/healthz")
|> URI.to_string()
openai_health = Config.get([Pleroma.Search.QdrantSearch, :openai_healthcheck_url])
[qdrant_health, openai_health] |> Enum.filter(& &1)
end
end
defmodule Pleroma.Search.QdrantSearch.OpenAIClient do
alias Pleroma.Config.Getting, as: Config
def post(path, body) do
Tesla.post(client(), path, body)
end
defp client do
- Tesla.client(middleware())
+ Tesla.client(middleware(), Pleroma.HTTP.AdapterHelper.tesla_adapter())
end
defp middleware do
[
{Tesla.Middleware.BaseUrl, Config.get([Pleroma.Search.QdrantSearch, :openai_url])},
Tesla.Middleware.JSON,
{Tesla.Middleware.Headers,
[
{"Authorization", "Bearer #{Config.get([Pleroma.Search.QdrantSearch, :openai_api_key])}"}
]}
]
end
end
defmodule Pleroma.Search.QdrantSearch.QdrantClient do
alias Pleroma.Config.Getting, as: Config
def delete(path) do
Tesla.delete(client(), path)
end
def post(path, body) do
Tesla.post(client(), path, body)
end
def put(path, body) do
Tesla.put(client(), path, body)
end
defp client do
- Tesla.client(middleware())
+ Tesla.client(middleware(), Pleroma.HTTP.AdapterHelper.tesla_adapter())
end
defp middleware do
[
{Tesla.Middleware.BaseUrl, Config.get([Pleroma.Search.QdrantSearch, :qdrant_url])},
Tesla.Middleware.JSON,
{Tesla.Middleware.Headers,
[
{"api-key", Pleroma.Config.get([Pleroma.Search.QdrantSearch, :qdrant_api_key])}
]}
]
end
end
diff --git a/test/pleroma/emails/mailer_test.exs b/test/pleroma/emails/mailer_test.exs
index 2c2b5a4af..d42e8b723 100644
--- a/test/pleroma/emails/mailer_test.exs
+++ b/test/pleroma/emails/mailer_test.exs
@@ -1,64 +1,93 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Emails.MailerTest do
use Pleroma.DataCase
alias Pleroma.Emails.Mailer
import Swoosh.TestAssertions
@email %Swoosh.Email{
from: {"Pleroma", "noreply@example.com"},
html_body: "Test email",
subject: "Pleroma test email",
to: [{"Test User", "user1@example.com"}]
}
setup do: clear_config([Pleroma.Emails.Mailer, :enabled], true)
test "not send email when mailer is disabled" do
clear_config([Pleroma.Emails.Mailer, :enabled], false)
Mailer.deliver(@email)
:timer.sleep(100)
refute_email_sent(
from: {"Pleroma", "noreply@example.com"},
to: [{"Test User", "user1@example.com"}],
html_body: "Test email",
subject: "Pleroma test email"
)
end
test "send email" do
Mailer.deliver(@email)
:timer.sleep(100)
assert_email_sent(
from: {"Pleroma", "noreply@example.com"},
to: [{"Test User", "user1@example.com"}],
html_body: "Test email",
subject: "Pleroma test email"
)
end
test "perform" do
Mailer.perform(:deliver_async, @email, [])
:timer.sleep(100)
assert_email_sent(
from: {"Pleroma", "noreply@example.com"},
to: [{"Test User", "user1@example.com"}],
html_body: "Test email",
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
+
+ describe "put_mua_cacertfile/1" do
+ test "makes Mua trust the CAStore bundle" do
+ config = Mailer.put_mua_cacertfile(adapter: Swoosh.Adapters.Mua, relay: "mail.example.com")
+
+ assert config[:ssl] == [cacertfile: CAStore.file_path()]
+ assert config[:relay] == "mail.example.com"
+ end
+
+ test "keeps other ssl options" do
+ config = Mailer.put_mua_cacertfile(adapter: Swoosh.Adapters.Mua, ssl: [depth: 3])
+
+ assert config[:ssl] == [cacertfile: CAStore.file_path(), depth: 3]
+ end
+
+ test "keeps a CA that the admin set" do
+ config = [adapter: Swoosh.Adapters.Mua, ssl: [cacertfile: "/etc/ssl/smtp.pem"]]
+ assert Mailer.put_mua_cacertfile(config) == config
+
+ config = [adapter: Swoosh.Adapters.Mua, ssl: [cacerts: []]]
+ assert Mailer.put_mua_cacertfile(config) == config
+ end
+
+ test "leaves other adapters alone" do
+ config = [adapter: Swoosh.Adapters.SMTP, relay: "mail.example.com"]
+
+ assert Mailer.put_mua_cacertfile(config) == config
+ end
+ end
end
diff --git a/test/pleroma/http/adapter_helper/tesla_adapter_test.exs b/test/pleroma/http/adapter_helper/tesla_adapter_test.exs
new file mode 100644
index 000000000..1ee2f13ba
--- /dev/null
+++ b/test/pleroma/http/adapter_helper/tesla_adapter_test.exs
@@ -0,0 +1,34 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HTTP.AdapterHelper.TeslaAdapterTest do
+ use ExUnit.Case, async: false
+
+ alias Pleroma.HTTP.AdapterHelper
+ alias Pleroma.HTTP.TLS
+
+ setup do
+ adapter = Application.get_env(:tesla, :adapter)
+ on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end)
+ end
+
+ test "adds the TLS options to Hackney" do
+ Application.put_env(:tesla, :adapter, Tesla.Adapter.Hackney)
+
+ assert AdapterHelper.tesla_adapter() ==
+ {Tesla.Adapter.Hackney, ssl_options: TLS.ssl_options()}
+ end
+
+ test "turns on certificate checks for Gun" do
+ Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun)
+
+ assert AdapterHelper.tesla_adapter() == {Tesla.Adapter.Gun, certificates_verification: true}
+ end
+
+ test "keeps other adapters" do
+ Application.put_env(:tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch})
+
+ assert AdapterHelper.tesla_adapter() == {Tesla.Adapter.Finch, name: MyFinch}
+ end
+end

File Metadata

Mime Type
text/x-diff
Expires
Sun, Nov 1, 9:14 AM (10 h, 48 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1802910
Default Alt Text
(17 KB)

Event Timeline