Add Signed Fetch Statistics (#312)

Close #304.

Notes:
 - This patch was made on top of Pleroma develop, so I created a separate cachex worker for request signature actions, instead of Akkoma's instance cache. If that is a merge blocker, I can attempt to move logic around for that.
 - Regarding the `has_request_signatures: true -> false` state transition: I think that is a higher level thing (resetting instance state on new instance actor key) which is separate from the changes relevant to this one.

Co-authored-by: Luna <git@l4.pm>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/312
Co-authored-by: @luna@f.l4.pm <akkoma@l4.pm>
Co-committed-by: @luna@f.l4.pm <akkoma@l4.pm>
This commit is contained in:
@luna@f.l4.pm 2022-11-26 19:22:56 +00:00 committed by floatingghost
parent ec1d903f2e
commit a90c45b7e9
7 changed files with 216 additions and 58 deletions

View file

@ -157,7 +157,8 @@ defp cachex_children do
build_cachex("failed_proxy_url", limit: 2500), build_cachex("failed_proxy_url", limit: 2500),
build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000), build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000),
build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500), build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500),
build_cachex("instances", default_ttl: :timer.hours(24), ttl_interval: 1000, limit: 2500) build_cachex("instances", default_ttl: :timer.hours(24), ttl_interval: 1000, limit: 2500),
build_cachex("request_signatures", default_ttl: :timer.hours(24 * 30), limit: 3000)
] ]
end end

View file

@ -43,4 +43,6 @@ def host(url_or_host) when is_binary(url_or_host) do
url_or_host url_or_host
end end
end end
defdelegate set_request_signatures(url_or_host), to: Instance
end end

View file

@ -26,6 +26,7 @@ defmodule Pleroma.Instances.Instance do
field(:favicon, :string) field(:favicon, :string)
field(:metadata_updated_at, :naive_datetime) field(:metadata_updated_at, :naive_datetime)
field(:nodeinfo, :map, default: %{}) field(:nodeinfo, :map, default: %{})
field(:has_request_signatures, :boolean)
timestamps() timestamps()
end end
@ -34,7 +35,14 @@ defmodule Pleroma.Instances.Instance do
def changeset(struct, params \\ %{}) do def changeset(struct, params \\ %{}) do
struct struct
|> cast(params, [:host, :unreachable_since, :favicon, :nodeinfo, :metadata_updated_at]) |> cast(params, [
:host,
:unreachable_since,
:favicon,
:nodeinfo,
:metadata_updated_at,
:has_request_signatures
])
|> validate_required([:host]) |> validate_required([:host])
|> unique_constraint(:host) |> unique_constraint(:host)
end end
@ -316,4 +324,24 @@ def get_cached_by_url(url_or_host) do
end) end)
end end
end end
def set_request_signatures(url_or_host) when is_binary(url_or_host) do
host = host(url_or_host)
existing_record = Repo.get_by(Instance, %{host: host})
changes = %{has_request_signatures: true}
cond do
is_nil(existing_record) ->
%Instance{}
|> changeset(Map.put(changes, :host, host))
|> Repo.insert()
true ->
existing_record
|> changeset(changes)
|> Repo.update()
end
end
def set_request_signatures(_), do: {:error, :invalid_input}
end end

View file

@ -7,8 +7,12 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
import Phoenix.Controller, only: [get_format: 1, text: 2] import Phoenix.Controller, only: [get_format: 1, text: 2]
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Web.Router alias Pleroma.Web.Router
alias Pleroma.Signature
alias Pleroma.Instances
require Logger require Logger
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
def init(options) do def init(options) do
options options
end end
@ -57,6 +61,7 @@ defp assign_valid_signature_on_route_aliases(conn, [path | rest]) do
conn conn
|> assign(:valid_signature, HTTPSignatures.validate_conn(conn)) |> assign(:valid_signature, HTTPSignatures.validate_conn(conn))
|> assign(:signature_actor_id, signature_host(conn))
|> assign_valid_signature_on_route_aliases(rest) |> assign_valid_signature_on_route_aliases(rest)
end end
@ -78,6 +83,36 @@ defp has_signature_header?(conn) do
conn |> get_req_header("signature") |> Enum.at(0, false) conn |> get_req_header("signature") |> Enum.at(0, false)
end end
defp maybe_require_signature(
%{assigns: %{valid_signature: true, signature_actor_id: actor_id}} = conn
) do
# inboxes implicitly need http signatures for authentication
# so we don't really know if the instance will have broken federation after
# we turn on authorized_fetch_mode.
#
# to "check" this is a signed fetch, verify if method is GET
if conn.method == "GET" do
actor_host = URI.parse(actor_id).host
case @cachex.get(:request_signatures_cache, actor_host) do
{:ok, nil} ->
Logger.debug("Successful signature from #{actor_host}")
Instances.set_request_signatures(actor_host)
@cachex.put(:request_signatures_cache, actor_host, true)
{:ok, true} ->
:noop
any ->
Logger.warn(
"expected request signature cache to return a boolean, instead got #{inspect(any)}"
)
end
end
conn
end
defp maybe_require_signature(%{assigns: %{valid_signature: true}} = conn), do: conn defp maybe_require_signature(%{assigns: %{valid_signature: true}} = conn), do: conn
defp maybe_require_signature(conn) do defp maybe_require_signature(conn) do
@ -90,4 +125,14 @@ defp maybe_require_signature(conn) do
conn conn
end end
end end
defp signature_host(conn) do
with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn),
{:ok, actor_id} <- Signature.key_id_to_actor_id(kid) do
actor_id
else
e ->
{:error, e}
end
end
end end

View file

@ -0,0 +1,9 @@
defmodule Pleroma.Repo.Migrations.AddHasRequestSignatures do
use Ecto.Migration
def change do
alter table(:instances) do
add(:has_request_signatures, :boolean, default: false, null: false)
end
end
end

View file

@ -4,6 +4,7 @@
defmodule Pleroma.InstancesTest do defmodule Pleroma.InstancesTest do
alias Pleroma.Instances alias Pleroma.Instances
alias Pleroma.Instances.Instance
use Pleroma.DataCase use Pleroma.DataCase
@ -121,4 +122,21 @@ test "keeps unreachable url or host unreachable" do
refute Instances.reachable?(host) refute Instances.reachable?(host)
end end
end end
describe "set_request_signatures/1" do
test "sets instance has request signatures" do
host = "domain.com"
{:ok, instance} = Instances.set_request_signatures(host)
assert instance.has_request_signatures
{:ok, cached_instance} = Instance.get_cached_by_url(host)
assert cached_instance.has_request_signatures
end
test "returns error status on non-binary input" do
assert {:error, _} = Instances.set_request_signatures(nil)
assert {:error, _} = Instances.set_request_signatures(1)
end
end
end end

View file

@ -1,34 +1,90 @@
# Pleroma: A lightweight social networking server # Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
use Pleroma.Web.ConnCase use Pleroma.Web.ConnCase, async: false
import Pleroma.Factory import Pleroma.Factory
alias Pleroma.Web.Plugs.HTTPSignaturePlug alias Pleroma.Web.Plugs.HTTPSignaturePlug
alias Pleroma.Instances.Instance
alias Pleroma.Repo
import Plug.Conn import Plug.Conn
import Phoenix.Controller, only: [put_format: 2] import Phoenix.Controller, only: [put_format: 2]
import Mock import Mock
test "it call HTTPSignatures to check validity if the actor sighed it" do setup_with_mocks([
{HTTPSignatures, [],
[
signature_for_conn: fn _ ->
%{"keyId" => "http://mastodon.example.org/users/admin#main-key"}
end,
validate_conn: fn conn ->
Map.get(conn.assigns, :valid_signature, true)
end
]}
]) do
:ok
end
defp submit_to_plug(host), do: submit_to_plug(host, :get, "/doesntmattter")
defp submit_to_plug(host, method, path) do
params = %{"actor" => "http://#{host}/users/admin"}
build_conn(method, path, params)
|> put_req_header(
"signature",
"keyId=\"http://#{host}/users/admin#main-key"
)
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
end
test "it call HTTPSignatures to check validity if the actor signed it" do
params = %{"actor" => "http://mastodon.example.org/users/admin"} params = %{"actor" => "http://mastodon.example.org/users/admin"}
conn = build_conn(:get, "/doesntmattter", params) conn = build_conn(:get, "/doesntmattter", params)
with_mock HTTPSignatures, validate_conn: fn _ -> true end do conn =
conn = conn
conn |> put_req_header(
|> put_req_header( "signature",
"signature", "keyId=\"http://mastodon.example.org/users/admin#main-key"
"keyId=\"http://mastodon.example.org/users/admin#main-key" )
) |> put_format("activity+json")
|> put_format("activity+json") |> HTTPSignaturePlug.call(%{})
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true assert conn.assigns.valid_signature == true
assert conn.halted == false assert conn.assigns.signature_actor_id == params["actor"]
assert called(HTTPSignatures.validate_conn(:_)) assert conn.halted == false
end assert called(HTTPSignatures.validate_conn(:_))
end
test "it sets request signatures property on the instance" do
host = "mastodon.example.org"
conn = submit_to_plug(host)
assert conn.assigns.valid_signature == true
instance = Repo.get_by(Instance, %{host: host})
assert instance.has_request_signatures
end
test "it does not set request signatures property on the instance when using inbox" do
host = "mastodon.example.org"
conn = submit_to_plug(host, :post, "/inbox")
assert conn.assigns.valid_signature == true
# we don't even create the instance entry if its just POST /inbox
refute Repo.get_by(Instance, %{host: host})
end
test "it does not set request signatures property on the instance when its cached" do
host = "mastodon.example.org"
Cachex.put(:request_signatures_cache, host, true)
conn = submit_to_plug(host)
assert conn.assigns.valid_signature == true
# we don't even create the instance entry if it was already done
refute Repo.get_by(Instance, %{host: host})
end end
describe "requires a signature when `authorized_fetch_mode` is enabled" do describe "requires a signature when `authorized_fetch_mode` is enabled" do
@ -41,40 +97,39 @@ test "it call HTTPSignatures to check validity if the actor sighed it" do
[conn: conn] [conn: conn]
end end
test "when signature header is present", %{conn: conn} do test "and signature is present and incorrect", %{conn: conn} do
with_mock HTTPSignatures, validate_conn: fn _ -> false end do conn =
conn = conn
conn |> assign(:valid_signature, false)
|> put_req_header( |> put_req_header(
"signature", "signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key" "keyId=\"http://mastodon.example.org/users/admin#main-key"
) )
|> HTTPSignaturePlug.call(%{}) |> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false assert conn.assigns.valid_signature == false
assert conn.halted == true assert conn.halted == true
assert conn.status == 401 assert conn.status == 401
assert conn.state == :sent assert conn.state == :sent
assert conn.resp_body == "Request not signed" assert conn.resp_body == "Request not signed"
assert called(HTTPSignatures.validate_conn(:_)) assert called(HTTPSignatures.validate_conn(:_))
end
with_mock HTTPSignatures, validate_conn: fn _ -> true end do
conn =
conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == false
assert called(HTTPSignatures.validate_conn(:_))
end
end end
test "halts the connection when `signature` header is not present", %{conn: conn} do test "and signature is correct", %{conn: conn} do
conn =
conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == false
assert called(HTTPSignatures.validate_conn(:_))
end
test "and halts the connection when `signature` header is not present", %{conn: conn} do
conn = HTTPSignaturePlug.call(conn, %{}) conn = HTTPSignaturePlug.call(conn, %{})
assert conn.assigns[:valid_signature] == nil assert conn.assigns[:valid_signature] == nil
assert conn.halted == true assert conn.halted == true
@ -82,16 +137,16 @@ test "halts the connection when `signature` header is not present", %{conn: conn
assert conn.state == :sent assert conn.state == :sent
assert conn.resp_body == "Request not signed" assert conn.resp_body == "Request not signed"
end end
end
test "aliases redirected /object endpoints", _ do test "aliases redirected /object endpoints", _ do
obj = insert(:note) obj = insert(:note)
act = insert(:note_activity, note: obj) act = insert(:note_activity, note: obj)
params = %{"actor" => "someparam"} params = %{"actor" => "someparam"}
path = URI.parse(obj.data["id"]).path path = URI.parse(obj.data["id"]).path
conn = build_conn(:get, path, params) conn = build_conn(:get, path, params)
assert ["/notice/#{act.id}", "/notice/#{act.id}?actor=someparam"] == assert ["/notice/#{act.id}", "/notice/#{act.id}?actor=someparam"] ==
HTTPSignaturePlug.route_aliases(conn) HTTPSignaturePlug.route_aliases(conn)
end
end end
end end