diff --git a/.gitignore b/.gitignore
index 355cea069..d8e5ed553 100644
--- a/.gitignore
+++ b/.gitignore
@@ -56,6 +56,9 @@ pleroma.iml
# asdf
.tool-versions
+# mise
+mise.toml
+
# Editor temp files
*~
*#
diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml
index 7b2a1683c..0ab7441a8 100644
--- a/.woodpecker/lint.yaml
+++ b/.woodpecker/lint.yaml
@@ -1,9 +1,9 @@
when:
- event: pull_request
- path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ]
+ path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ]
+ path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
labels:
platform: linux/amd64
diff --git a/.woodpecker/unit-testing-elixir-1.15.yaml b/.woodpecker/unit-testing-elixir-1.15.yaml
index 5c6be764d..a4a8fc266 100644
--- a/.woodpecker/unit-testing-elixir-1.15.yaml
+++ b/.woodpecker/unit-testing-elixir-1.15.yaml
@@ -1,9 +1,9 @@
when:
- event: pull_request
- path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ]
+ path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ]
+ path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
depends_on:
- lint
diff --git a/.woodpecker/unit-testing-elixir-1.18.yaml b/.woodpecker/unit-testing-elixir-1.18.yaml
index 2a40f41f7..9ad9eebc9 100644
--- a/.woodpecker/unit-testing-elixir-1.18.yaml
+++ b/.woodpecker/unit-testing-elixir-1.18.yaml
@@ -1,9 +1,9 @@
when:
- event: pull_request
- path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ]
+ path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ]
+ path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
depends_on:
- lint
diff --git a/changelog.d/activitypub-spoofing.security b/changelog.d/activitypub-spoofing.security
new file mode 100644
index 000000000..3e6baffb6
--- /dev/null
+++ b/changelog.d/activitypub-spoofing.security
@@ -0,0 +1 @@
+ActivityPub: Fixed failed-signature inbox retry handling and signer identity checks to prevent spoofed remote activities from being processed
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
index 071d634db..0b513ee16 100644
--- a/lib/pleroma/web/activity_pub/activity_pub.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -1677,44 +1677,80 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
show_birthday = !!birthday
- # if WebFinger request was already done, we probably have acct, otherwise
- # we request WebFinger here
- nickname = additional[:nickname_from_acct] || generate_nickname(data)
+ with {:ok, nickname} <- nickname_from_actor(data, additional) do
+ {:ok,
+ %{
+ ap_id: data["id"],
+ uri: get_actor_url(data["url"]),
+ banner: normalize_image(data["image"]),
+ fields: fields,
+ emoji: emojis,
+ is_locked: is_locked,
+ is_discoverable: is_discoverable,
+ invisible: invisible,
+ avatar: normalize_image(data["icon"]),
+ name: data["name"],
+ follower_address: data["followers"],
+ following_address: data["following"],
+ featured_address: featured_address,
+ bio: data["summary"] || "",
+ actor_type: actor_type,
+ also_known_as: normalize_also_known_as(data["alsoKnownAs"]),
+ public_key: public_key,
+ inbox: data["inbox"],
+ shared_inbox: shared_inbox,
+ accepts_chat_messages: accepts_chat_messages,
+ birthday: birthday,
+ show_birthday: show_birthday,
+ pinned_objects: pinned_objects,
+ nickname: nickname
+ }}
+ end
+ end
- %{
- ap_id: data["id"],
- uri: get_actor_url(data["url"]),
- banner: normalize_image(data["image"]),
- fields: fields,
- emoji: emojis,
- is_locked: is_locked,
- is_discoverable: is_discoverable,
- invisible: invisible,
- avatar: normalize_image(data["icon"]),
- name: data["name"],
- follower_address: data["followers"],
- following_address: data["following"],
- featured_address: featured_address,
- bio: data["summary"] || "",
- actor_type: actor_type,
- also_known_as: normalize_also_known_as(data["alsoKnownAs"]),
- public_key: public_key,
- inbox: data["inbox"],
- shared_inbox: shared_inbox,
- accepts_chat_messages: accepts_chat_messages,
- birthday: birthday,
- show_birthday: show_birthday,
- pinned_objects: pinned_objects,
- nickname: nickname
- }
+ defp nickname_from_actor(data, additional) do
+ generated = generated_nickname(data)
+
+ case additional[:nickname_from_acct] do
+ ^generated when is_binary(generated) ->
+ {:ok, generated}
+
+ acct when is_binary(acct) ->
+ with ^acct <- webfinger_nickname(data) do
+ {:ok, acct}
+ else
+ _ -> {:error, {:webfinger_actor_mismatch, acct, data["id"]}}
+ end
+
+ _ ->
+ {:ok, generate_nickname(data)}
+ end
+ end
+
+ defp generated_nickname(%{"preferredUsername" => username, "id" => ap_id})
+ when is_binary(username) and is_binary(ap_id) do
+ case URI.parse(ap_id) do
+ %URI{host: host} when is_binary(host) -> "#{username}@#{host}"
+ _ -> nil
+ end
+ end
+
+ defp generated_nickname(_), do: nil
+
+ defp webfinger_nickname(data) do
+ with generated when is_binary(generated) <- generated_nickname(data),
+ {:ok, %{"subject" => "acct:" <> acct, "ap_id" => ap_id}} <- WebFinger.finger(generated),
+ true <- ap_id == data["id"] do
+ acct
+ end
end
defp generate_nickname(%{"preferredUsername" => username} = data) when is_binary(username) do
- generated = "#{username}@#{URI.parse(data["id"]).host}"
+ generated = generated_nickname(data)
if Config.get([WebFinger, :update_nickname_on_user_fetch]) do
- case WebFinger.finger(generated) do
- {:ok, %{"subject" => "acct:" <> acct}} -> acct
+ case webfinger_nickname(data) do
+ acct when is_binary(acct) -> acct
_ -> generated
end
else
@@ -1794,9 +1830,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp collection_private(_data), do: {:ok, true}
def user_data_from_user_object(data, additional \\ []) do
- with {:ok, data} <- MRF.filter(data) do
- {:ok, object_to_user_data(data, additional)}
+ with {:ok, data} <- MRF.filter(data),
+ {:ok, data} <- object_to_user_data(data, additional) do
+ {:ok, data}
else
+ {:error, _} = e -> e
e -> {:error, e}
end
end
diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
index 4f1613a07..2bfff6968 100644
--- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
@@ -348,7 +348,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
def inbox(%{assigns: %{valid_signature: false}} = conn, params) do
- Federator.incoming_ap_doc(%{
+ Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,
diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex
index aab90235f..4c0d9dff7 100644
--- a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex
@@ -75,15 +75,40 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do
end
end
- # For remote Updates, verify the host is the same.
+ # For remote Updates, verify the Actor is the same
def validate_updating_rights_remote(cng) do
with actor = get_field(cng, :actor),
object = get_field(cng, :object),
{:ok, object_id} <- ObjectValidators.ObjectID.cast(object),
- actor_uri <- URI.parse(actor),
- object_uri <- URI.parse(object_id),
- true <- actor_uri.host == object_uri.host do
- cng
+ entity <-
+ Object.normalize(object_id, fetch: false) || User.get_cached_by_ap_id(object_id) do
+ case entity do
+ # Actor must own Object to update it
+ %Object{} ->
+ if actor == entity.data["actor"] do
+ cng
+ else
+ cng
+ |> add_error(:object, "Can't be updated by this actor")
+ end
+
+ # Actor must only be allowed to update itself
+ %User{} ->
+ if actor == entity.ap_id do
+ cng
+ else
+ cng
+ |> add_error(:object, "Can't be updated by this actor")
+ end
+
+ nil ->
+ cng
+ |> add_error(:object, "Can't be updated by this actor")
+
+ _ ->
+ cng
+ |> add_error(:object, "Update is neither for Object or Actor")
+ end
else
_e ->
cng
diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex
index 676fc5137..90cd2e54a 100644
--- a/lib/pleroma/web/federator.ex
+++ b/lib/pleroma/web/federator.ex
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.Federator do
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Workers.PublisherWorker
alias Pleroma.Workers.ReceiverWorker
+ alias Pleroma.Workers.SignatureRetryWorker
require Logger
@@ -35,12 +36,21 @@ defmodule Pleroma.Web.Federator do
end
# Client API
- def incoming_ap_doc(%{params: params, req_headers: req_headers}) do
- ReceiverWorker.new(
+ def incoming_failed_signature_ap_doc(%{
+ method: method,
+ params: params,
+ req_headers: req_headers,
+ request_path: request_path,
+ query_string: query_string
+ }) do
+ SignatureRetryWorker.new(
%{
- "op" => "incoming_ap_doc",
+ "op" => "incoming_failed_signature_ap_doc",
+ "method" => method,
"req_headers" => req_headers,
"params" => params,
+ "request_path" => request_path,
+ "query_string" => query_string,
"timeout" => :timer.seconds(20)
},
priority: 2
diff --git a/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex b/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex
index c6d531086..a688b1780 100644
--- a/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex
+++ b/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex
@@ -32,8 +32,8 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do
# remove me once testsuite uses mapped capabilities instead of what we do now
{:user, nil} ->
Logger.debug("Failed to map identity from signature (lookup failure)")
- Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{actor}")
- conn
+ Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{inspect(actor)}")
+ assign(conn, :valid_signature, false)
end
end
diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex
index e2c950967..3afbe138d 100644
--- a/lib/pleroma/workers/receiver_worker.ex
+++ b/lib/pleroma/workers/receiver_worker.ex
@@ -4,40 +4,37 @@
defmodule Pleroma.Workers.ReceiverWorker do
alias Pleroma.Instances
- alias Pleroma.Signature
- alias Pleroma.User
alias Pleroma.Web.Federator
+ alias Pleroma.Workers.SignatureRetryWorker
use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity]
@impl true
+ def perform(%Job{args: %{"op" => "incoming_ap_doc", "params" => params} = args} = job) do
+ if signature_retry_job?(args) do
+ perform_signature_retry(job)
+ else
+ perform_incoming(params)
+ end
+ end
- def perform(%Job{
- args: %{
- "op" => "incoming_ap_doc",
- "method" => method,
- "params" => params,
- "req_headers" => req_headers,
- "request_path" => request_path,
- "query_string" => query_string
- }
- }) do
- # Oban's serialization converts our tuple headers to lists.
- # Revert it for the signature validation.
- req_headers = Enum.into(req_headers, [], &List.to_tuple(&1))
+ def perform(%Job{args: %{"op" => "incoming_ap_doc"} = args} = job) do
+ if signature_retry_job?(args) do
+ perform_signature_retry(job)
+ else
+ process_errors(:missing_incoming_ap_doc_params)
+ end
+ end
- conn_data = %Plug.Conn{
- method: method,
- params: params,
- req_headers: req_headers,
- request_path: request_path,
- query_string: query_string
- }
+ defp perform_signature_retry(%Job{args: args} = job) do
+ SignatureRetryWorker.perform(%Job{
+ job
+ | args: Map.put(args, "op", "incoming_failed_signature_ap_doc")
+ })
+ end
- with {:ok, %User{}} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]),
- {:ok, _public_key} <- Signature.refetch_public_key(conn_data),
- {:signature, true} <- {:signature, Signature.validate_signature(conn_data)},
- {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
+ defp perform_incoming(params) do
+ with {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
unless Instances.reachable?(params["actor"]) do
domain = URI.parse(params["actor"]).host
Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain}))
@@ -49,17 +46,8 @@ defmodule Pleroma.Workers.ReceiverWorker do
end
end
- def perform(%Job{args: %{"op" => "incoming_ap_doc", "params" => params}}) do
- with {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
- unless Instances.reachable?(params["actor"]) do
- domain = URI.parse(params["actor"]).host
- Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain}))
- end
-
- {:ok, res}
- else
- e -> process_errors(e)
- end
+ defp signature_retry_job?(args) do
+ Enum.any?(~w(method req_headers request_path query_string), &Map.has_key?(args, &1))
end
@impl true
@@ -85,10 +73,12 @@ defmodule Pleroma.Workers.ReceiverWorker do
{:error, {:reject, _} = reason} -> {:cancel, reason}
# HTTP Sigs
{:signature, false} -> {:cancel, :invalid_signature}
+ {:same_actor, false} -> {:cancel, :actor_signature_mismatch}
# Origin / URL validation failed somewhere possibly due to spoofing
{:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed}
# Unclear if this can be reached
{:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason}
+ :missing_incoming_ap_doc_params -> {:cancel, :missing_incoming_ap_doc_params}
# Catchall
{:error, _} = e -> e
e -> {:error, e}
diff --git a/lib/pleroma/workers/signature_retry_worker.ex b/lib/pleroma/workers/signature_retry_worker.ex
new file mode 100644
index 000000000..2c4c097dd
--- /dev/null
+++ b/lib/pleroma/workers/signature_retry_worker.ex
@@ -0,0 +1,254 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.SignatureRetryWorker do
+ alias Pleroma.Instances
+ alias Pleroma.Signature
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.Utils
+ alias Pleroma.Web.Federator
+ alias Pleroma.Web.Plugs.MappedSignatureToIdentityPlug
+
+ require Logger
+
+ use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity]
+
+ @impl true
+ def perform(%Job{
+ args: %{
+ "op" => "incoming_failed_signature_ap_doc",
+ "method" => method,
+ "params" => params,
+ "req_headers" => req_headers,
+ "request_path" => request_path,
+ "query_string" => query_string
+ }
+ })
+ when is_binary(method) and is_map(params) and is_list(req_headers) and
+ is_binary(request_path) and is_binary(query_string) do
+ case normalize_req_headers(req_headers) do
+ {:ok, req_headers} ->
+ conn_data = %Plug.Conn{
+ assigns: %{valid_signature: true},
+ method: method,
+ params: params,
+ req_headers: req_headers,
+ request_path: request_path,
+ query_string: query_string
+ }
+
+ signature_actor_result = signature_actor_id(conn_data)
+
+ with actor_id = Utils.get_ap_id(params["actor"]),
+ {:signature_actor, {:ok, signature_actor_id}} <-
+ {:signature_actor, signature_actor_result},
+ {:same_actor, true} <- {:same_actor, signature_actor_id == actor_id},
+ {:ok, %User{}} <- User.get_or_fetch_by_ap_id(actor_id),
+ {:ok, _public_key} <- Signature.refetch_public_key(conn_data),
+ {:signature, true} <- {:signature, validate_signature(conn_data)},
+ {:same_actor, true} <- {:same_actor, validate_same_actor(conn_data)},
+ {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
+ unless Instances.reachable?(params["actor"]) do
+ domain = URI.parse(params["actor"]).host
+ Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain}))
+ end
+
+ {:ok, res}
+ else
+ e -> process_errors(e, retry_log_context(params, request_path, signature_actor_result))
+ end
+
+ e ->
+ process_errors(e, retry_log_context(params, request_path, nil))
+ end
+ end
+
+ def perform(%Job{args: %{"op" => "incoming_failed_signature_ap_doc"} = args}) do
+ process_errors(
+ :missing_signature_retry_metadata,
+ retry_log_context(Map.get(args, "params"), Map.get(args, "request_path"), nil)
+ )
+ end
+
+ def perform(%Job{args: args}) when is_map(args) do
+ process_errors(
+ :missing_signature_retry_metadata,
+ retry_log_context(Map.get(args, "params"), Map.get(args, "request_path"), nil)
+ )
+ end
+
+ def perform(%Job{}), do: process_errors(:missing_signature_retry_metadata)
+
+ @impl true
+ def timeout(%_{args: %{"timeout" => timeout}}), do: timeout
+
+ def timeout(_job), do: :timer.seconds(5)
+
+ defp normalize_req_headers(req_headers) do
+ req_headers
+ |> Enum.reduce_while({:ok, []}, fn
+ {key, value}, {:ok, acc} when is_binary(key) and is_binary(value) ->
+ {:cont, {:ok, [{key, value} | acc]}}
+
+ [key, value], {:ok, acc} when is_binary(key) and is_binary(value) ->
+ {:cont, {:ok, [{key, value} | acc]}}
+
+ _, _ ->
+ {:halt, {:error, :invalid_signature_retry_metadata}}
+ end)
+ |> case do
+ {:ok, headers} -> {:ok, Enum.reverse(headers)}
+ error -> error
+ end
+ end
+
+ defp validate_same_actor(conn_data) do
+ case MappedSignatureToIdentityPlug.call(conn_data, []) do
+ %Plug.Conn{assigns: %{valid_signature: true}} ->
+ true
+
+ _ ->
+ false
+ end
+ end
+
+ defp validate_signature(conn_data) do
+ Signature.validate_signature(conn_data)
+ rescue
+ _ -> false
+ catch
+ _, _ -> false
+ end
+
+ defp signature_actor_id(conn_data) do
+ Signature.get_actor_id(conn_data)
+ rescue
+ _ -> {:error, :invalid_signature}
+ catch
+ _, _ -> {:error, :invalid_signature}
+ end
+
+ defp process_errors(errors, context \\ %{})
+
+ defp process_errors({:error, {:error, _} = error}, context), do: process_errors(error, context)
+
+ defp process_errors(errors, context) do
+ result =
+ case errors do
+ # User fetch failures
+ {:error, :not_found} = reason ->
+ {:cancel, reason}
+
+ {:error, :forbidden} = reason ->
+ {:cancel, reason}
+
+ # Inactive user
+ {:error, {:user_active, false} = reason} ->
+ {:cancel, reason}
+
+ # Validator will error and return a changeset error
+ # e.g., duplicate activities or if the object was deleted
+ {:error, {:validate, {:error, _changeset} = reason}} ->
+ {:cancel, reason}
+
+ # Duplicate detection during Normalization
+ {:error, :already_present} ->
+ {:cancel, :already_present}
+
+ # MRFs will return a reject
+ {:error, {:reject, _} = reason} ->
+ {:cancel, reason}
+
+ # HTTP Sigs
+ {:signature_actor, {:error, _}} ->
+ {:cancel, :invalid_signature}
+
+ {:signature, false} ->
+ {:cancel, :invalid_signature}
+
+ {:same_actor, false} ->
+ {:cancel, :actor_signature_mismatch}
+
+ # Origin / URL validation failed somewhere possibly due to spoofing
+ {:error, :origin_containment_failed} ->
+ {:cancel, :origin_containment_failed}
+
+ # Unclear if this can be reached
+ {:error, {:side_effects, {:error, :no_object_actor}} = reason} ->
+ {:cancel, reason}
+
+ # Fail closed if the retry cannot reconstruct the original request.
+ :missing_signature_retry_metadata ->
+ {:cancel, :missing_signature_retry_metadata}
+
+ {:error, :invalid_signature_retry_metadata} ->
+ {:cancel, :invalid_signature_retry_metadata}
+
+ # Catchall
+ {:error, _} = e ->
+ e
+
+ e ->
+ {:error, e}
+ end
+
+ log_signature_retry_rejection(result, context)
+ result
+ end
+
+ defp retry_log_context(params, request_path, signature_actor_result) when is_map(params) do
+ signature_actor =
+ case signature_actor_result do
+ {:ok, actor} when is_binary(actor) -> actor
+ actor when is_binary(actor) -> actor
+ _ -> nil
+ end
+
+ %{
+ activity_id: params["id"],
+ payload_actor: Utils.get_ap_id(params["actor"]),
+ request_path: request_path,
+ signature_actor: signature_actor,
+ type: params["type"]
+ }
+ end
+
+ defp retry_log_context(_params, request_path, signature_actor_result) do
+ signature_actor =
+ case signature_actor_result do
+ {:ok, actor} when is_binary(actor) -> actor
+ actor when is_binary(actor) -> actor
+ _ -> nil
+ end
+
+ %{
+ activity_id: nil,
+ payload_actor: nil,
+ request_path: request_path,
+ signature_actor: signature_actor,
+ type: nil
+ }
+ end
+
+ defp log_signature_retry_rejection({:cancel, reason}, context)
+ when reason in [
+ :actor_signature_mismatch,
+ :invalid_signature,
+ :invalid_signature_retry_metadata,
+ :missing_signature_retry_metadata,
+ :origin_containment_failed
+ ] do
+ Logger.warning(
+ "Failed-signature inbox retry rejected " <>
+ "reason=#{inspect(reason)} " <>
+ "payload_actor=#{inspect(context[:payload_actor])} " <>
+ "signature_actor=#{inspect(context[:signature_actor])} " <>
+ "activity_id=#{inspect(context[:activity_id])} " <>
+ "type=#{inspect(context[:type])} " <>
+ "request_path=#{inspect(context[:request_path])}"
+ )
+ end
+
+ defp log_signature_retry_rejection(_result, _context), do: :ok
+end
diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs
index b2533e9f1..de735cdb7 100644
--- a/test/pleroma/user_test.exs
+++ b/test/pleroma/user_test.exs
@@ -876,17 +876,17 @@ defmodule Pleroma.UserTest do
describe "get_or_fetch/1 remote users with tld, while BE is running on a subdomain" do
setup do: clear_config([Pleroma.Web.WebFinger, :update_nickname_on_user_fetch], true)
- test "for mastodon" do
- ap_id = "a@mastodon.example"
- {:ok, fetched_user} = User.get_or_fetch(ap_id)
+ test "fetches a mastodon split-domain nickname" do
+ nickname = "a@mastodon.example"
+ {:ok, fetched_user} = User.get_or_fetch(nickname)
assert fetched_user.ap_id == "https://sub.mastodon.example/users/a"
assert fetched_user.nickname == "a@mastodon.example"
end
- test "for pleroma" do
- ap_id = "a@pleroma.example"
- {:ok, fetched_user} = User.get_or_fetch(ap_id)
+ test "fetches a pleroma split-domain nickname" do
+ nickname = "a@pleroma.example"
+ {:ok, fetched_user} = User.get_or_fetch(nickname)
assert fetched_user.ap_id == "https://sub.pleroma.example/users/a"
assert fetched_user.nickname == "a@pleroma.example"
@@ -936,6 +936,89 @@ defmodule Pleroma.UserTest do
assert fetched_user == "not found nonexistent"
end
+ test "does not rename an existing remote actor from rogue WebFinger data" do
+ clear_config([Pleroma.Web.WebFinger, :update_nickname_on_user_fetch], true)
+
+ actor_id = "https://legit-actor.example/users/alice"
+
+ Tesla.Mock.mock(fn
+ %{url: "https://evil-webfinger.example/.well-known/host-meta"} ->
+ {:ok, %Tesla.Env{status: 404}}
+
+ %{
+ url:
+ "https://evil-webfinger.example/.well-known/webfinger?resource=acct:claimed@evil-webfinger.example"
+ } ->
+ Tesla.Mock.json(%{
+ "subject" => "acct:claimed@evil-webfinger.example",
+ "links" => [
+ %{
+ "rel" => "self",
+ "type" => "application/activity+json",
+ "href" => actor_id
+ }
+ ]
+ })
+
+ %{url: ^actor_id} ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body:
+ Jason.encode!(%{
+ "id" => actor_id,
+ "type" => "Person",
+ "preferredUsername" => "alice",
+ "name" => "Alice",
+ "summary" => "",
+ "inbox" => "https://legit-actor.example/users/alice/inbox",
+ "outbox" => "https://legit-actor.example/users/alice/outbox",
+ "followers" => "https://legit-actor.example/users/alice/followers",
+ "following" => "https://legit-actor.example/users/alice/following"
+ })
+ }}
+
+ %{url: "https://legit-actor.example/.well-known/host-meta"} ->
+ {:ok, %Tesla.Env{status: 404}}
+
+ %{
+ url:
+ "https://legit-actor.example/.well-known/webfinger?resource=acct:alice@legit-actor.example"
+ } ->
+ Tesla.Mock.json(%{
+ "subject" => "acct:alice@legit-actor.example",
+ "links" => [
+ %{
+ "rel" => "self",
+ "type" => "application/activity+json",
+ "href" => actor_id
+ }
+ ]
+ })
+ end)
+
+ assert {:error, {:webfinger_actor_mismatch, "claimed@evil-webfinger.example", ^actor_id}} =
+ ActivityPub.make_user_from_nickname("claimed@evil-webfinger.example")
+
+ refute User.get_by_ap_id(actor_id)
+ refute User.get_by_nickname("claimed@evil-webfinger.example")
+
+ orig_user =
+ insert(:user,
+ local: false,
+ nickname: "alice@legit-actor.example",
+ ap_id: actor_id
+ )
+
+ assert {:error, {:webfinger_actor_mismatch, "claimed@evil-webfinger.example", ^actor_id}} =
+ ActivityPub.make_user_from_nickname("claimed@evil-webfinger.example")
+
+ assert {:error, _} = User.get_or_fetch_by_nickname("claimed@evil-webfinger.example")
+ assert User.get_by_id(orig_user.id).nickname == "alice@legit-actor.example"
+ refute User.get_by_nickname("claimed@evil-webfinger.example")
+ end
+
test "updates an existing user, if stale" do
a_week_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -604_800)
diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
index d5947186f..3988c3912 100644
--- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
+++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
@@ -19,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Endpoint
alias Pleroma.Workers.ReceiverWorker
+ alias Pleroma.Workers.SignatureRetryWorker
import Pleroma.Factory
@@ -36,6 +37,36 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
setup do: clear_config([:instance, :federating], true)
+ defp assign_valid_signature_for_actor(conn, %User{ap_id: actor_id}) do
+ assign_valid_signature_for_actor(conn, actor_id)
+ end
+
+ defp assign_valid_signature_for_actor(conn, actor) do
+ actor_id = Utils.get_ap_id(actor)
+
+ conn
+ |> assign(:valid_signature, true)
+ |> put_req_header("signature", "keyId=\"#{actor_id}#main-key\"")
+ end
+
+ defp expect_signature_retry_from(%User{} = signer) do
+ signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured")
+
+ Tesla.Mock.mock(fn
+ %{url: url} when url == signer.ap_id ->
+ %Tesla.Env{
+ status: 200,
+ body: Jason.encode!(signer_json),
+ headers: HttpRequestMock.activitypub_object_headers()
+ }
+
+ env ->
+ apply(HttpRequestMock, :request, [env])
+ end)
+
+ Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> true end)
+ end
+
describe "/relay" do
setup do: clear_config([:instance, :allow_relay])
@@ -688,7 +719,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
@@ -716,7 +747,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
@@ -726,6 +757,199 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert Activity.get_by_ap_id(data["id"])
end
+ test "does not create a forged post after failed signature retry", %{conn: conn} do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ object_id = "https://two.com/objects/inbox-forged-note"
+
+ data = %{
+ "type" => "Create",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/inbox-forged-create",
+ "context" => "https://two.com/contexts/inbox-forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => object_id,
+ "actor" => bob.ap_id,
+ "attributedTo" => bob.ap_id,
+ "context" => "https://two.com/contexts/inbox-forged-create",
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ expect_signature_retry_from(alice)
+
+ conn =
+ conn
+ |> assign(:valid_signature, false)
+ |> put_req_header("content-type", "application/activity+json")
+ |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
+ |> post("/inbox", data)
+
+ assert "ok" == json_response(conn, 200)
+
+ assert [{:cancel, :actor_signature_mismatch}] =
+ ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
+
+ refute Activity.get_by_ap_id(data["id"])
+ refute Object.get_by_ap_id(object_id)
+ end
+
+ test "does not create a forged like after failed signature retry", %{conn: conn} do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ note = insert(:note)
+
+ data = %{
+ "type" => "Like",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/inbox-forged-like",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => note.data["id"]
+ }
+
+ expect_signature_retry_from(alice)
+
+ conn =
+ conn
+ |> assign(:valid_signature, false)
+ |> put_req_header("content-type", "application/activity+json")
+ |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
+ |> post("/inbox", data)
+
+ assert "ok" == json_response(conn, 200)
+
+ assert [{:cancel, :actor_signature_mismatch}] =
+ ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
+
+ refute Activity.get_by_ap_id(data["id"])
+ end
+
+ test "does not delete an object after failed signature retry", %{conn: conn} do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ note = insert(:note)
+ object_id = note.data["id"]
+
+ data = %{
+ "type" => "Delete",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/inbox-forged-delete",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => object_id
+ }
+
+ expect_signature_retry_from(alice)
+
+ conn =
+ conn
+ |> assign(:valid_signature, false)
+ |> put_req_header("content-type", "application/activity+json")
+ |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
+ |> post("/inbox", data)
+
+ assert "ok" == json_response(conn, 200)
+
+ assert [{:cancel, :actor_signature_mismatch}] =
+ ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
+
+ refute Activity.get_by_ap_id(data["id"])
+ assert %Object{data: %{"type" => "Note"}} = Object.get_by_ap_id(object_id)
+ end
+
+ test "does not create a forged post signed by a different actor", %{conn: conn} do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ object_id = "https://two.com/objects/inbox-signed-forged-note"
+
+ data = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "type" => "Create",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/inbox-signed-forged-create",
+ "context" => "https://two.com/contexts/inbox-signed-forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => object_id,
+ "actor" => bob.ap_id,
+ "attributedTo" => bob.ap_id,
+ "context" => "https://two.com/contexts/inbox-signed-forged-create",
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ expect_signature_retry_from(alice)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/activity+json")
+ |> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT")
+ |> put_req_header("digest", "SHA-256=fake-digest")
+ |> put_req_header(
+ "signature",
+ "keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
+ )
+ |> post("/inbox", data)
+
+ assert conn.assigns.valid_signature == false
+ assert "ok" == json_response(conn, 200)
+
+ assert [{:cancel, :actor_signature_mismatch}] =
+ ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
+
+ refute Activity.get_by_ap_id(data["id"])
+ refute Object.get_by_ap_id(object_id)
+ end
+
+ test "does not create a forged like signed by a different actor", %{conn: conn} do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ note = insert(:note)
+
+ data = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "type" => "Like",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/inbox-signed-forged-like",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => note.data["id"]
+ }
+
+ expect_signature_retry_from(alice)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/activity+json")
+ |> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT")
+ |> put_req_header("digest", "SHA-256=fake-digest")
+ |> put_req_header(
+ "signature",
+ "keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
+ )
+ |> post("/inbox", data)
+
+ assert conn.assigns.valid_signature == false
+ assert "ok" == json_response(conn, 200)
+
+ assert [{:cancel, :actor_signature_mismatch}] =
+ ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
+
+ refute Activity.get_by_ap_id(data["id"])
+ end
+
test "accept follow activity", %{conn: conn} do
clear_config([:instance, :federating], true)
relay = Relay.get_actor()
@@ -742,7 +966,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert "ok" ==
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(followed_relay)
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", accept)
|> json_response(200)
@@ -822,16 +1046,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
test "Unknown activity types are discarded", %{conn: conn} do
unknown_types = ["Poke", "Read", "Dazzle"]
+ actor =
+ insert(:user, local: false, ap_id: "https://unknown.mastodon.instance/users/somebody")
+
Enum.each(unknown_types, fn bad_type ->
params =
%{
"type" => bad_type,
- "actor" => "https://unknown.mastodon.instance/users/somebody"
+ "actor" => actor.ap_id
}
|> Jason.encode!()
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(actor)
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", params)
|> json_response(400)
@@ -900,7 +1127,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert "ok" ==
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
|> json_response(200)
@@ -921,7 +1148,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert "ok" ==
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
|> json_response(200)
@@ -990,7 +1217,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert "ok" ==
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
|> json_response(200)
@@ -1009,7 +1236,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert "ok" ==
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
|> json_response(200)
@@ -1040,7 +1267,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
@@ -1061,7 +1288,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
@@ -1082,7 +1309,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
@@ -1106,7 +1333,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
@@ -1133,7 +1360,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
@@ -1163,7 +1390,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{recipient.nickname}/inbox", data)
@@ -1228,7 +1455,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
}
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{recipient.nickname}/inbox", data)
|> json_response(200)
@@ -1318,7 +1545,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
}
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{reported_user.nickname}/inbox", data)
|> json_response(200)
@@ -1372,7 +1599,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
}
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{reported_user.nickname}/inbox", data)
|> json_response(200)
@@ -1405,7 +1632,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
@@ -1428,7 +1655,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
@@ -1451,7 +1678,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
- |> assign(:valid_signature, true)
+ |> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
@@ -2571,6 +2798,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
setup do: clear_config([:media_proxy])
setup do: clear_config([Pleroma.Upload])
+ # majic's libmagic port is unavailable on local Darwin runs; Linux CI still runs this test.
+ @tag :skip_darwin
test "POST /api/ap/upload_media", %{conn: conn} do
user = insert(:user)
diff --git a/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs
index 347c5b578..9dec315b3 100644
--- a/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs
+++ b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs
@@ -29,7 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do
assert {:ok, _update, []} = ObjectValidator.validate(valid_update, [])
end
- test "returns an error if the object can't be updated by the actor", %{
+ test "returns an error if the object can't be updated by the actor (different domain)", %{
valid_update: valid_update
} do
other_user = insert(:user, local: false)
@@ -41,27 +41,72 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do
assert {:error, _cng} = ObjectValidator.validate(update, [])
end
- test "validates as long as the object is same-origin with the actor", %{
+ test "returns an error if the object can't be updated by the actor (same domain)", %{
+ user: user,
valid_update: valid_update
} do
- other_user = insert(:user)
+ user_ap_id = user.ap_id
+ user_domain = URI.parse(user_ap_id).host
+ other_user = insert(:user, local: false, domain: user_domain)
update =
valid_update
|> Map.put("actor", other_user.ap_id)
- assert {:ok, _update, []} = ObjectValidator.validate(update, [])
+ assert {:error, _cng} = ObjectValidator.validate(update, [])
end
- test "validates if the object is not of an Actor type" do
- note = insert(:note)
+ test "validates if the object is not of an Actor type", %{user: user} do
+ note = insert(:note, user: user)
updated_note = note.data |> Map.put("content", "edited content")
- other_user = insert(:user)
- {:ok, update, _} = Builder.update(other_user, updated_note)
+ {:ok, update, _} = Builder.update(user, updated_note)
assert {:ok, _update, _} = ObjectValidator.validate(update, [])
end
+
+ test "returns an error if the remote update target is unknown" do
+ remote_user = insert(:user, local: false, ap_id: "https://example.com/users/alice")
+
+ update = %{
+ "type" => "Update",
+ "actor" => remote_user.ap_id,
+ "id" => "https://example.com/activities/update-unknown-object",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => "https://example.com/objects/unknown",
+ "actor" => remote_user.ap_id,
+ "content" => "edited content",
+ "published" => "2024-07-25T13:33:31Z",
+ "updated" => "2024-07-25T13:34:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ assert {:error, %Ecto.Changeset{} = cng} = ObjectValidator.validate(update, local: false)
+ refute cng.valid?
+ assert Keyword.has_key?(cng.errors, :object)
+ end
+
+ test "returns an error if the remote update target IRI is unknown" do
+ remote_user = insert(:user, local: false, ap_id: "https://example.com/users/alice")
+
+ update = %{
+ "type" => "Update",
+ "actor" => remote_user.ap_id,
+ "id" => "https://example.com/activities/update-unknown-object-iri",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => "https://example.com/objects/unknown-iri"
+ }
+
+ assert {:error, %Ecto.Changeset{} = cng} = ObjectValidator.validate(update, local: false)
+ refute cng.valid?
+ assert Keyword.has_key?(cng.errors, :object)
+ end
end
describe "update note" do
diff --git a/test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs b/test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs
index 33eff1bc5..df713762c 100644
--- a/test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs
+++ b/test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs
@@ -47,13 +47,27 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlugTest do
assert %{valid_signature: false} == conn.assigns
end
- @tag skip: "known breakage; the testsuite presently depends on it"
test "it considers a mapped identity to be invalid when the identity cannot be found" do
+ actor = "http://niu.moe/users/rye"
+
conn =
- build_conn(:post, "/doesntmattter", %{"actor" => "http://mastodon.example.org/users/admin"})
- |> set_signature("http://niu.moe/users/rye")
+ build_conn(:post, "/doesntmattter", %{"actor" => actor})
+ |> set_signature(actor)
|> MappedSignatureToIdentityPlug.call(%{})
- assert %{valid_signature: false} == conn.assigns
+ assert conn.assigns.valid_signature == false
+ refute Map.has_key?(conn.assigns, :user)
+ end
+
+ test "it considers a mapped identity to be invalid when embedded actor identity cannot be found" do
+ actor = "http://niu.moe/users/rye"
+
+ conn =
+ build_conn(:post, "/doesntmattter", %{"actor" => %{"id" => actor}})
+ |> set_signature(actor)
+ |> MappedSignatureToIdentityPlug.call(%{})
+
+ assert conn.assigns.valid_signature == false
+ refute Map.has_key?(conn.assigns, :user)
end
end
diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs
index 12abc1a27..ea05f38f1 100644
--- a/test/pleroma/workers/receiver_worker_test.exs
+++ b/test/pleroma/workers/receiver_worker_test.exs
@@ -11,9 +11,27 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
alias Pleroma.User
alias Pleroma.Web.CommonAPI
- alias Pleroma.Web.Federator
alias Pleroma.Workers.ReceiverWorker
+ defp signature_headers_for(%User{} = signer) do
+ [
+ {"host", "local.test"},
+ {"date", "Thu, 25 Jul 2024 13:33:31 GMT"},
+ {"digest", "SHA-256=fake-digest"},
+ {"content-type", "application/activity+json"},
+ {
+ "signature",
+ "keyId=\"#{signer.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
+ }
+ ]
+ end
+
+ defp perform_incoming(params) do
+ ReceiverWorker.perform(%Oban.Job{
+ args: %{"op" => "incoming_ap_doc", "params" => params}
+ })
+ end
+
test "it does not retry MRF reject" do
params = insert(:note).data
@@ -81,16 +99,7 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
insert(:note_activity).data
|> Map.put("actor", "https://springfield.social/users/bart")
- {:ok, oban_job} =
- Federator.incoming_ap_doc(%{
- method: "POST",
- req_headers: [],
- request_path: "/inbox",
- params: params,
- query_string: ""
- })
-
- assert {:cancel, {:error, :forbidden}} = ReceiverWorker.perform(oban_job)
+ assert {:cancel, {:error, :forbidden}} = perform_incoming(params)
end
test "when request returns a 404" do
@@ -98,16 +107,7 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
insert(:note_activity).data
|> Map.put("actor", "https://springfield.social/users/troymcclure")
- {:ok, oban_job} =
- Federator.incoming_ap_doc(%{
- method: "POST",
- req_headers: [],
- request_path: "/inbox",
- params: params,
- query_string: ""
- })
-
- assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job)
+ assert {:cancel, {:error, :not_found}} = perform_incoming(params)
end
test "when request returns a 410" do
@@ -115,16 +115,7 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
insert(:note_activity).data
|> Map.put("actor", "https://springfield.social/users/hankscorpio")
- {:ok, oban_job} =
- Federator.incoming_ap_doc(%{
- method: "POST",
- req_headers: [],
- request_path: "/inbox",
- params: params,
- query_string: ""
- })
-
- assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job)
+ assert {:cancel, {:error, :not_found}} = perform_incoming(params)
end
test "when user account is disabled" do
@@ -138,86 +129,16 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
{:ok, %User{}} = User.set_activation(user, false)
- {:ok, oban_job} =
- Federator.incoming_ap_doc(%{
- method: "POST",
- req_headers: [],
- request_path: "/inbox",
- params: params,
- query_string: ""
- })
-
- assert {:cancel, {:user_active, false}} = ReceiverWorker.perform(oban_job)
+ assert {:cancel, {:user_active, false}} = perform_incoming(params)
end
end
- test "it can validate the signature" do
- Tesla.Mock.mock(fn
- %{url: "https://phpc.social/users/denniskoch"} ->
- %Tesla.Env{
- status: 200,
- body: File.read!("test/fixtures/denniskoch.json"),
- headers: [{"content-type", "application/activity+json"}]
- }
-
- %{url: "https://phpc.social/users/denniskoch/collections/featured"} ->
- %Tesla.Env{
- status: 200,
- headers: [{"content-type", "application/activity+json"}],
- body:
- File.read!("test/fixtures/users_mock/masto_featured.json")
- |> String.replace("{{domain}}", "phpc.social")
- |> String.replace("{{nickname}}", "denniskoch")
- }
- end)
-
- params =
- File.read!("test/fixtures/receiver_worker_signature_activity.json") |> Jason.decode!()
-
- req_headers = [
- ["accept-encoding", "gzip"],
- ["content-length", "5184"],
- ["content-type", "application/activity+json"],
- ["date", "Thu, 25 Jul 2024 13:33:31 GMT"],
- ["digest", "SHA-256=ouge/6HP2/QryG6F3JNtZ6vzs/hSwMk67xdxe87eH7A="],
- ["host", "bikeshed.party"],
- [
- "signature",
- "keyId=\"https://mastodon.social/users/bastianallgeier#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"ymE3vn5Iw50N6ukSp8oIuXJB5SBjGAGjBasdTDvn+ahZIzq2SIJfmVCsIIzyqIROnhWyQoTbavTclVojEqdaeOx+Ejz2wBnRBmhz5oemJLk4RnnCH0lwMWyzeY98YAvxi9Rq57Gojuv/1lBqyGa+rDzynyJpAMyFk17XIZpjMKuTNMCbjMDy76ILHqArykAIL/v1zxkgwxY/+ELzxqMpNqtZ+kQ29znNMUBB3eVZ/mNAHAz6o33Y9VKxM2jw+08vtuIZOusXyiHbRiaj2g5HtN2WBUw1MzzfRfHF2/yy7rcipobeoyk5RvP5SyHV3WrIeZ3iyoNfmv33y8fxllF0EA==\""
- ],
- [
- "user-agent",
- "http.rb/5.2.0 (Mastodon/4.3.0-nightly.2024-07-25; +https://mastodon.social/)"
- ]
- ]
-
- {:ok, oban_job} =
- Federator.incoming_ap_doc(%{
- method: "POST",
- req_headers: req_headers,
- request_path: "/inbox",
- params: params,
- query_string: ""
- })
-
- assert {:ok, %Pleroma.Activity{}} = ReceiverWorker.perform(oban_job)
- end
-
test "cancels due to origin containment" do
params =
insert(:note_activity).data
|> Map.put("id", "https://notorigindomain.com/activity")
- {:ok, oban_job} =
- Federator.incoming_ap_doc(%{
- method: "POST",
- req_headers: [],
- request_path: "/inbox",
- params: params,
- query_string: ""
- })
-
- assert {:cancel, :origin_containment_failed} = ReceiverWorker.perform(oban_job)
+ assert {:cancel, :origin_containment_failed} = perform_incoming(params)
end
test "canceled due to deleted object" do
@@ -233,16 +154,114 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
}
end)
- {:ok, oban_job} =
- Federator.incoming_ap_doc(%{
- method: "POST",
- req_headers: [],
- request_path: "/inbox",
- params: params,
- query_string: ""
- })
+ assert {:cancel, _} = perform_incoming(params)
+ end
- assert {:cancel, _} = ReceiverWorker.perform(oban_job)
+ test "delegates legacy failed-signature metadata jobs instead of processing them as trusted" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ object_id = "https://two.com/objects/legacy-forged-note"
+
+ create = %{
+ "type" => "Create",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/legacy-forged-create",
+ "context" => "https://two.com/contexts/legacy-forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => object_id,
+ "actor" => bob.ap_id,
+ "attributedTo" => bob.ap_id,
+ "context" => "https://two.com/contexts/legacy-forged-create",
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ assert {:cancel, :actor_signature_mismatch} =
+ ReceiverWorker.perform(%Oban.Job{
+ args: %{
+ "op" => "incoming_ap_doc",
+ "method" => "POST",
+ "params" => create,
+ "req_headers" => signature_headers_for(alice),
+ "request_path" => "/inbox",
+ "query_string" => ""
+ }
+ })
+
+ refute Pleroma.Activity.get_by_ap_id(create["id"])
+ refute Pleroma.Object.get_by_ap_id(object_id)
+ end
+
+ test "fails closed for the old persisted failed-signature job shape" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ object_id = "https://two.com/objects/old-shape-forged-note"
+
+ create = %{
+ "type" => "Create",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/old-shape-forged-create",
+ "context" => "https://two.com/contexts/old-shape-forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => object_id,
+ "actor" => bob.ap_id,
+ "attributedTo" => bob.ap_id,
+ "context" => "https://two.com/contexts/old-shape-forged-create",
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ assert {:cancel, :missing_signature_retry_metadata} =
+ ReceiverWorker.perform(%Oban.Job{
+ args: %{
+ "op" => "incoming_ap_doc",
+ "params" => create,
+ "req_headers" => signature_headers_for(alice),
+ "timeout" => 20_000
+ }
+ })
+
+ refute Pleroma.Activity.get_by_ap_id(create["id"])
+ refute Pleroma.Object.get_by_ap_id(object_id)
+ end
+
+ test "fails closed for legacy retry jobs missing one metadata field" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ params = insert(:note_activity).data
+
+ assert {:cancel, :missing_signature_retry_metadata} =
+ ReceiverWorker.perform(%Oban.Job{
+ args: %{
+ "op" => "incoming_ap_doc",
+ "method" => "POST",
+ "params" => params,
+ "req_headers" => signature_headers_for(alice),
+ "request_path" => "/inbox"
+ }
+ })
+ end
+
+ test "fails closed for malformed legacy metadata jobs without params" do
+ assert {:cancel, :missing_signature_retry_metadata} =
+ ReceiverWorker.perform(%Oban.Job{
+ args: %{
+ "op" => "incoming_ap_doc",
+ "req_headers" => [],
+ "timeout" => 20_000
+ }
+ })
end
describe "Server reachability:" do
diff --git a/test/pleroma/workers/signature_retry_worker_test.exs b/test/pleroma/workers/signature_retry_worker_test.exs
new file mode 100644
index 000000000..94dd5f6c1
--- /dev/null
+++ b/test/pleroma/workers/signature_retry_worker_test.exs
@@ -0,0 +1,574 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.SignatureRetryWorkerTest do
+ use Pleroma.DataCase, async: false
+ use Oban.Testing, repo: Pleroma.Repo
+
+ import ExUnit.CaptureLog
+ import Pleroma.Factory
+
+ @moduletag capture_log: true
+
+ alias Pleroma.Activity
+ alias Pleroma.Object
+ alias Pleroma.Signature
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.UserView
+ alias Pleroma.Web.Federator
+ alias Pleroma.Workers.SignatureRetryWorker
+
+ defp signature_headers_for(%User{} = signer) do
+ [
+ {"host", "local.test"},
+ {"date", "Thu, 25 Jul 2024 13:33:31 GMT"},
+ {"digest", "SHA-256=fake-digest"},
+ {"content-type", "application/activity+json"},
+ {
+ "signature",
+ "keyId=\"#{signer.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
+ }
+ ]
+ end
+
+ defp stub_actor_fetch(%User{} = signer) do
+ signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured")
+
+ Tesla.Mock.mock(fn
+ %{url: url} when url == signer.ap_id ->
+ %Tesla.Env{
+ status: 200,
+ body: Jason.encode!(signer_json),
+ headers: HttpRequestMock.activitypub_object_headers()
+ }
+ end)
+ end
+
+ defp expect_signature_from(%User{} = signer) do
+ stub_actor_fetch(signer)
+ Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> true end)
+ end
+
+ defp enqueue_failed_signature(params, signer) do
+ Federator.incoming_failed_signature_ap_doc(%{
+ method: "POST",
+ req_headers: signature_headers_for(signer),
+ request_path: "/inbox",
+ params: params,
+ query_string: ""
+ })
+ end
+
+ defp failed_signature_job(params, req_headers, opts \\ []) do
+ %Oban.Job{
+ args: %{
+ "op" => "incoming_failed_signature_ap_doc",
+ "method" => Keyword.get(opts, :method, "POST"),
+ "req_headers" => req_headers,
+ "request_path" => Keyword.get(opts, :request_path, "/inbox"),
+ "params" => params,
+ "query_string" => Keyword.get(opts, :query_string, "")
+ }
+ }
+ end
+
+ defp assert_mismatched_signature_cancelled(params, signer) do
+ assert {:ok, oban_job} = enqueue_failed_signature(params, signer)
+
+ capture_log([level: :warning], fn ->
+ assert {:cancel, :actor_signature_mismatch} = SignatureRetryWorker.perform(oban_job)
+ end)
+ end
+
+ test "Federator preserves request metadata for failed-signature retry jobs" do
+ params = insert(:note_activity).data
+
+ req_headers = [
+ {"host", "local.test"},
+ {"signature", "keyId=\"https://one.com/users/alice#main-key\""}
+ ]
+
+ assert {:ok, oban_job} =
+ Federator.incoming_failed_signature_ap_doc(%{
+ method: "POST",
+ req_headers: req_headers,
+ request_path: "/inbox",
+ params: params,
+ query_string: "foo=bar"
+ })
+
+ assert oban_job.worker == "Pleroma.Workers.SignatureRetryWorker"
+
+ assert %{
+ "op" => "incoming_failed_signature_ap_doc",
+ "method" => "POST",
+ "req_headers" => ^req_headers,
+ "request_path" => "/inbox",
+ "params" => ^params,
+ "query_string" => "foo=bar"
+ } = oban_job.args
+ end
+
+ test "cancels retry jobs without request metadata" do
+ params = insert(:note_activity).data
+
+ log =
+ capture_log([level: :warning], fn ->
+ assert {:cancel, :missing_signature_retry_metadata} =
+ SignatureRetryWorker.perform(%Oban.Job{
+ args: %{"op" => "incoming_failed_signature_ap_doc", "params" => params}
+ })
+ end)
+
+ assert log =~ "Failed-signature inbox retry rejected"
+ assert log =~ "reason=:missing_signature_retry_metadata"
+ assert log =~ "payload_actor=#{inspect(params["actor"])}"
+ assert log =~ "activity_id=#{inspect(params["id"])}"
+ assert log =~ "type=#{inspect(params["type"])}"
+ assert log =~ "request_path=nil"
+ end
+
+ test "cancels retry jobs with malformed serialized request headers" do
+ params = insert(:note_activity).data
+
+ log =
+ capture_log([level: :warning], fn ->
+ assert {:cancel, :invalid_signature_retry_metadata} =
+ SignatureRetryWorker.perform(failed_signature_job(params, [["signature"]]))
+ end)
+
+ assert log =~ "Failed-signature inbox retry rejected"
+ assert log =~ "reason=:invalid_signature_retry_metadata"
+ assert log =~ "signature_actor=nil"
+ assert log =~ "request_path=\"/inbox\""
+ end
+
+ test "cancels retry jobs without a signature header" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ params = insert(:note_activity, user: alice).data
+
+ log =
+ capture_log([level: :warning], fn ->
+ assert {:cancel, :invalid_signature} =
+ SignatureRetryWorker.perform(
+ failed_signature_job(params, [{"host", "local.test"}])
+ )
+ end)
+
+ assert log =~ "Failed-signature inbox retry rejected"
+ assert log =~ "reason=:invalid_signature"
+ assert log =~ "payload_actor=#{inspect(params["actor"])}"
+ assert log =~ "signature_actor=nil"
+ assert log =~ "request_path=\"/inbox\""
+ end
+
+ test "cancels missing signature before fetching an unavailable payload actor" do
+ params =
+ insert(:note_activity).data
+ |> Map.put("actor", "https://unavailable.example/users/bob")
+
+ assert {:cancel, :invalid_signature} =
+ SignatureRetryWorker.perform(failed_signature_job(params, [{"host", "local.test"}]))
+ end
+
+ test "cancels signer mismatch before fetching an unavailable payload actor" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+
+ params =
+ insert(:note_activity).data
+ |> Map.put("actor", "https://unavailable.example/users/bob")
+
+ assert {:cancel, :actor_signature_mismatch} =
+ SignatureRetryWorker.perform(
+ failed_signature_job(params, signature_headers_for(alice))
+ )
+ end
+
+ test "cancels retry jobs with a signature header without keyId" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ params = insert(:note_activity, user: alice).data
+
+ req_headers = [{"signature", "algorithm=\"rsa-sha256\",signature=\"fake-signature\""}]
+
+ assert {:cancel, :invalid_signature} =
+ SignatureRetryWorker.perform(failed_signature_job(params, req_headers))
+ end
+
+ test "cancels retry jobs with an unparsable signature keyId" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ params = insert(:note_activity, user: alice).data
+ req_headers = [{"signature", "keyId=\"not an activitypub id\",signature=\"fake-signature\""}]
+
+ assert {:cancel, :invalid_signature} =
+ SignatureRetryWorker.perform(failed_signature_job(params, req_headers))
+ end
+
+ test "cancels when the refetched key still cannot validate the signature" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+
+ create = %{
+ "type" => "Create",
+ "actor" => alice.ap_id,
+ "id" => "https://one.com/activities/invalid-signature-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => "https://one.com/objects/invalid-signature-note",
+ "actor" => alice.ap_id,
+ "attributedTo" => alice.ap_id,
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ stub_actor_fetch(alice)
+
+ assert {:ok, oban_job} = enqueue_failed_signature(create, alice)
+
+ log =
+ capture_log([level: :warning], fn ->
+ assert {:cancel, :invalid_signature} = SignatureRetryWorker.perform(oban_job)
+ end)
+
+ assert log =~ "Failed-signature inbox retry rejected"
+ assert log =~ "reason=:invalid_signature"
+ assert log =~ "payload_actor=\"https://one.com/users/alice\""
+ assert log =~ "signature_actor=\"https://one.com/users/alice\""
+ assert log =~ "activity_id=\"https://one.com/activities/invalid-signature-create\""
+ assert log =~ "type=\"Create\""
+ assert log =~ "request_path=\"/inbox\""
+
+ refute Activity.get_by_ap_id(create["id"])
+ end
+
+ test "processes the activity after refetching a valid matching signature" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+
+ create = %{
+ "type" => "Create",
+ "actor" => alice.ap_id,
+ "id" => "https://one.com/activities/valid-signature-create",
+ "context" => "https://one.com/contexts/valid-signature-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => "https://one.com/objects/valid-signature-note",
+ "actor" => alice.ap_id,
+ "attributedTo" => alice.ap_id,
+ "context" => "https://one.com/contexts/valid-signature-create",
+ "content" => "valid post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ expect_signature_from(alice)
+
+ assert {:ok, oban_job} = enqueue_failed_signature(create, alice)
+ assert {:ok, %Activity{}} = SignatureRetryWorker.perform(oban_job)
+ assert Activity.get_by_ap_id(create["id"])
+ end
+
+ test "processes the activity when a real signature validates with a query string" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+
+ create = %{
+ "type" => "Create",
+ "actor" => alice.ap_id,
+ "id" => "https://one.com/activities/valid-query-signature-create",
+ "context" => "https://one.com/contexts/valid-query-signature-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => "https://one.com/objects/valid-query-signature-note",
+ "actor" => alice.ap_id,
+ "attributedTo" => alice.ap_id,
+ "context" => "https://one.com/contexts/valid-query-signature-create",
+ "content" => "valid signed post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ stub_actor_fetch(alice)
+
+ date = "Thu, 25 Jul 2024 13:33:31 GMT"
+ digest = "SHA-256=fake-digest"
+
+ signature =
+ Signature.sign(alice, %{
+ "(request-target)" => "post /inbox?foo=bar",
+ "content-type" => "application/activity+json",
+ date: date,
+ digest: digest,
+ host: "local.test"
+ })
+
+ req_headers = [
+ ["host", "local.test"],
+ ["date", date],
+ ["digest", digest],
+ ["content-type", "application/activity+json"],
+ ["signature", signature]
+ ]
+
+ assert {:ok, %Activity{}} =
+ SignatureRetryWorker.perform(
+ failed_signature_job(create, req_headers, query_string: "foo=bar")
+ )
+
+ assert Activity.get_by_ap_id(create["id"])
+ end
+
+ test "cancels when signature actor does not match payload actor" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+
+ note =
+ insert(:note,
+ user: bob,
+ object_local: false,
+ data: %{"id" => "https://two.com/objects/malicious-update-note"}
+ )
+
+ update = %{
+ "type" => "Update",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/malicious-update",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => note.data
+ }
+
+ assert_mismatched_signature_cancelled(update, alice)
+ end
+
+ test "cancels signature actor mismatch through Federator-created jobs" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+
+ note =
+ insert(:note,
+ user: bob,
+ object_local: false,
+ data: %{"id" => "https://two.com/objects/federator-malicious-note"}
+ )
+
+ update = %{
+ "type" => "Update",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/federator-malicious-update",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => note.data
+ }
+
+ assert_mismatched_signature_cancelled(update, alice)
+ end
+
+ test "cancels signature actor mismatch before processing a forged Create" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+
+ create = %{
+ "type" => "Create",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => "https://two.com/objects/forged-note",
+ "actor" => bob.ap_id,
+ "attributedTo" => bob.ap_id,
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ assert_mismatched_signature_cancelled(create, alice)
+ end
+
+ test "cancels signature actor mismatch when payload actor is embedded" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+
+ create = %{
+ "type" => "Create",
+ "actor" => %{"id" => bob.ap_id},
+ "id" => "https://two.com/activities/embedded-actor-forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => "https://two.com/objects/embedded-actor-forged-note",
+ "actor" => bob.ap_id,
+ "attributedTo" => bob.ap_id,
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ assert_mismatched_signature_cancelled(create, alice)
+ end
+
+ test "logs signature actor mismatch retry rejections" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+
+ create = %{
+ "type" => "Create",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/logged-forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => "https://two.com/objects/logged-forged-note",
+ "actor" => bob.ap_id,
+ "attributedTo" => bob.ap_id,
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ log = assert_mismatched_signature_cancelled(create, alice)
+
+ assert log =~ "Failed-signature inbox retry rejected"
+ assert log =~ "reason=:actor_signature_mismatch"
+ assert log =~ "payload_actor=\"https://two.com/users/bob\""
+ assert log =~ "signature_actor=\"https://one.com/users/alice\""
+ assert log =~ "activity_id=\"https://two.com/activities/logged-forged-create\""
+ assert log =~ "type=\"Create\""
+ assert log =~ "request_path=\"/inbox\""
+ end
+
+ test "cancels signature actor mismatch before actually creating a forged post" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+
+ object_id = "https://two.com/objects/actually-forged-note"
+
+ create = %{
+ "type" => "Create",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/actually-forged-create",
+ "context" => "https://two.com/contexts/actually-forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => object_id,
+ "actor" => bob.ap_id,
+ "attributedTo" => bob.ap_id,
+ "context" => "https://two.com/contexts/actually-forged-create",
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ assert_mismatched_signature_cancelled(create, alice)
+ refute Object.get_by_ap_id(object_id)
+ end
+
+ test "cancels signature actor mismatch before processing a forged Like" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ note = insert(:note)
+
+ like = %{
+ "type" => "Like",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/forged-like",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => note.data["id"]
+ }
+
+ assert_mismatched_signature_cancelled(like, alice)
+ end
+
+ test "cancels signature actor mismatch before actually creating a forged Like" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ note = insert(:note)
+
+ like = %{
+ "type" => "Like",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/actually-forged-like",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => note.data["id"]
+ }
+
+ assert_mismatched_signature_cancelled(like, alice)
+ refute Activity.get_by_ap_id(like["id"])
+ end
+
+ test "cancels signature actor mismatch before processing a forged Announce" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ note = insert(:note)
+
+ announce = %{
+ "type" => "Announce",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/forged-announce",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => note.data["id"]
+ }
+
+ assert_mismatched_signature_cancelled(announce, alice)
+ end
+
+ test "cancels signature actor mismatch before processing a forged Follow" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+ followed = insert(:user)
+
+ follow = %{
+ "type" => "Follow",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/forged-follow",
+ "to" => [followed.ap_id],
+ "cc" => [],
+ "object" => followed.ap_id
+ }
+
+ assert_mismatched_signature_cancelled(follow, alice)
+ end
+
+ test "cancels signature actor mismatch before processing a forged Undo" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
+
+ undo = %{
+ "type" => "Undo",
+ "actor" => bob.ap_id,
+ "id" => "https://two.com/activities/forged-undo",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => "https://two.com/activities/existing-bob-activity"
+ }
+
+ assert_mismatched_signature_cancelled(undo, alice)
+ end
+end