commit: d1d7dd18277985fbfb3df25c9f49630bb5537321 parent 8de659d3fd50cede351189755f6eb78515c2db81 Author: lain <lain@soykaf.club> Date: Fri, 5 Sep 2025 12:15:19 +0000 Merge branch 'from/upstream-develop/tusooa/report-anon' into 'develop' Anonymize reports Closes #2661 and #1024 See merge request pleroma/pleroma!3806Diffstat:
19 files changed, 377 insertions(+), 73 deletions(-)diff --git a/changelog.d/fix-report-empty-fields.fix b/changelog.d/fix-report-empty-fields.fix@@ -0,0 +1 @@ +Fix reports being rejected when the activity had an empty CC or TO field (instead of not having them at all) +\ No newline at end of filediff --git a/changelog.d/report-anon.add b/changelog.d/report-anon.add@@ -0,0 +1 @@ +Allow anonymizing reports sent to remote serversdiff --git a/config/config.exs b/config/config.exs@@ -364,7 +364,9 @@ config :pleroma, :activitypub, note_replies_output_limit: 5, sign_object_fetches: true, authorized_fetch_mode: false, - client_api_enabled: false + client_api_enabled: false, + anonymize_reporter: false, + anonymize_reporter_local_nickname: "" config :pleroma, :streamer, workers: 3,diff --git a/config/description.exs b/config/description.exs@@ -1797,6 +1797,23 @@ config :pleroma, :config_description, [ key: :client_api_enabled, type: :boolean, description: "Allow client to server ActivityPub interactions" + }, + %{ + key: :anonymize_reporter, + type: :boolean, + label: "Anonymize local reports", + description: + "If true, replace local reporters with the designated local user for the copy to be sent to remote servers" + }, + %{ + key: :anonymize_reporter_local_nickname, + type: :string, + label: "Anonymized reporter", + description: + "The nickname of the designated local user that replaces the actual reporter in the copy to be sent to remote servers", + suggestions: [ + "lain" + ] } ] },diff --git a/config/test.exs b/config/test.exs@@ -170,6 +170,10 @@ config :pleroma, Pleroma.Upload.Filter.Mogrify, config_impl: Pleroma.StaticStubb config :pleroma, Pleroma.Upload.Filter.Mogrify, mogrify_impl: Pleroma.MogrifyMock config :pleroma, Pleroma.Signature, http_signatures_impl: Pleroma.StubbedHTTPSignaturesMock +config :pleroma, Pleroma.Web.ActivityPub.Publisher, signature_impl: Pleroma.SignatureMock + +config :pleroma, Pleroma.Web.ActivityPub.Publisher, + transmogrifier_impl: Pleroma.Web.ActivityPub.TransmogrifierMock peer_module = if String.to_integer(System.otp_release()) >= 25 dodiff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex@@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Signature do + @behaviour Pleroma.Signature.API @behaviour HTTPSignatures.Adapter alias Pleroma.EctoType.ActivityPub.ObjectValidatorsdiff --git a/lib/pleroma/signature/api.ex b/lib/pleroma/signature/api.ex@@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Signature.API do + @moduledoc """ + Behaviour for signing requests and producing HTTP Date headers. + + This is used to allow tests to replace the signing implementation with Mox. + """ + + @callback sign(user :: Pleroma.User.t(), headers :: map()) :: String.t() + @callback signed_date() :: String.t() +enddiff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex@@ -414,10 +414,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do with flag_data <- make_flag_data(params, additional), {:ok, activity} <- insert(flag_data, local), - {:ok, stripped_activity} <- strip_report_status_data(activity), _ <- notify_and_stream(activity), - :ok <- - maybe_federate(stripped_activity) do + :ok <- maybe_federate(activity) do User.all_users_with_privilege(:reports_manage_reports) |> Enum.filter(fn user -> user.ap_id != actor end) |> Enum.filter(fn user -> not is_nil(user.email) end)diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex@@ -13,7 +13,6 @@ defmodule Pleroma.Web.ActivityPub.Publisher do alias Pleroma.User alias Pleroma.Web.ActivityPub.Publisher.Prepared alias Pleroma.Web.ActivityPub.Relay - alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Workers.PublisherWorker require Pleroma.Constants @@ -26,6 +25,18 @@ defmodule Pleroma.Web.ActivityPub.Publisher do ActivityPub outgoing federation module. """ + @signature_impl Application.compile_env( + :pleroma, + [__MODULE__, :signature_impl], + Pleroma.Signature + ) + + @transmogrifier_impl Application.compile_env( + :pleroma, + [__MODULE__, :transmogrifier_impl], + Pleroma.Web.ActivityPub.Transmogrifier + ) + @doc """ Enqueue publishing a single activity. """ @@ -68,7 +79,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do Determine if an activity can be represented by running it through Transmogrifier. """ def representable?(%Activity{} = activity) do - with {:ok, _data} <- Transmogrifier.prepare_outgoing(activity.data) do + with {:ok, _data} <- @transmogrifier_impl.prepare_outgoing(activity.data) do true else _e -> @@ -91,7 +102,17 @@ defmodule Pleroma.Web.ActivityPub.Publisher do Logger.debug("Federating #{ap_id} to #{inbox}") uri = %{path: path} = URI.parse(inbox) - {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + {:ok, data} = @transmogrifier_impl.prepare_outgoing(activity.data) + + {actor, data} = + with {_, false} <- {:actor_changed?, data["actor"] != activity.data["actor"]} do + {actor, data} + else + {:actor_changed?, true} -> + # If prepare_outgoing changes the actor, re-get it from the db + new_actor = User.get_cached_by_ap_id(data["actor"]) + {new_actor, data} + end param_cc = Map.get(params, :cc, []) @@ -115,10 +136,10 @@ defmodule Pleroma.Web.ActivityPub.Publisher do digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64()) - date = Pleroma.Signature.signed_date() + date = @signature_impl.signed_date() signature = - Pleroma.Signature.sign(actor, %{ + @signature_impl.sign(actor, %{ "(request-target)": "post #{path}", host: signature_host(uri), "content-length": byte_size(json),diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex@@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do @moduledoc """ A module to handle coding from internal to wire ActivityPub and back. """ + @behaviour Pleroma.Web.ActivityPub.Transmogrifier.API alias Pleroma.Activity alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Maps @@ -909,6 +910,14 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end + def prepare_outgoing(%{"type" => "Flag"} = data) do + with {:ok, stripped_activity} <- Utils.strip_report_status_data(data), + stripped_activity <- Utils.maybe_anonymize_reporter(stripped_activity), + stripped_activity <- Map.merge(stripped_activity, Utils.make_json_ld_header()) do + {:ok, stripped_activity} + end + end + def prepare_outgoing(%{"type" => _type} = data) do data = datadiff --git a/lib/pleroma/web/activity_pub/transmogrifier/api.ex b/lib/pleroma/web/activity_pub/transmogrifier/api.ex@@ -0,0 +1,11 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.API do + @moduledoc """ + Behaviour for the subset of Transmogrifier used by Publisher. + """ + + @callback prepare_outgoing(map()) :: {:ok, map()} | {:error, term()} +enddiff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex@@ -82,7 +82,11 @@ defmodule Pleroma.Web.ActivityPub.Utils do def unaddressed_message?(params), do: [params["to"], params["cc"], params["bto"], params["bcc"]] - |> Enum.all?(&is_nil(&1)) + |> Enum.all?(fn + nil -> true + [] -> true + _ -> false + end) @spec recipient_in_message(User.t(), User.t(), map()) :: boolean() def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params), @@ -859,8 +863,14 @@ defmodule Pleroma.Web.ActivityPub.Utils do def update_report_state(_, _), do: {:error, "Unsupported state"} - def strip_report_status_data(activity) do - [actor | reported_activities] = activity.data["object"] + def strip_report_status_data(%Activity{} = activity) do + with {:ok, new_data} <- strip_report_status_data(activity.data) do + {:ok, %{activity | data: new_data}} + end + end + + def strip_report_status_data(data) do + [actor | reported_activities] = data["object"] stripped_activities = Enum.reduce(reported_activities, [], fn act, acc -> @@ -870,9 +880,36 @@ defmodule Pleroma.Web.ActivityPub.Utils do end end) - new_data = put_in(activity.data, ["object"], [actor | stripped_activities]) + new_data = put_in(data, ["object"], [actor | stripped_activities]) - {:ok, %{activity | data: new_data}} + {:ok, new_data} + end + + def get_anonymized_reporter do + with true <- Pleroma.Config.get([:activitypub, :anonymize_reporter]), + nickname when is_binary(nickname) <- + Pleroma.Config.get([:activitypub, :anonymize_reporter_local_nickname]), + %User{ap_id: ap_id, local: true} <- User.get_cached_by_nickname(nickname) do + ap_id + else + _ -> nil + end + end + + def maybe_anonymize_reporter(%Activity{data: data} = activity) do + new_data = maybe_anonymize_reporter(data) + %Activity{activity | actor: new_data["actor"], data: new_data} + end + + def maybe_anonymize_reporter(activity) do + ap_id = get_anonymized_reporter() + + if is_binary(ap_id) do + activity + |> Map.put("actor", ap_id) + else + activity + end end def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities dodiff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs@@ -1152,9 +1152,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do } ], "actor" => actor.ap_id, - "cc" => [ - reported_user.ap_id - ], + # CC and TO might either not exist at all, or be empty. We should be able to handle either. + # "cc" => [], "content" => "test", "context" => "context", "id" => "http://#{remote_domain}/activities/02be56cf-35e3-46b4-b2c6-47ae08dfee9e",diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs@@ -1691,32 +1691,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do } = activity end - test_with_mock "strips status data from Flag, before federating it", - %{ - reporter: reporter, - context: context, - target_account: target_account, - reported_activity: reported_activity, - object_ap_id: object_ap_id, - content: content - }, - Utils, - [:passthrough], - [] do - {:ok, activity} = - ActivityPub.flag(%{ - actor: reporter, - context: context, - account: target_account, - statuses: [reported_activity], - content: content - }) - - new_data = put_in(activity.data, ["object"], [target_account.ap_id, object_ap_id]) - - assert_called(Utils.maybe_federate(%{activity | data: new_data})) - end - test_with_mock "reverts on error", %{ reporter: reporter,diff --git a/test/pleroma/web/activity_pub/publisher_test.exs b/test/pleroma/web/activity_pub/publisher_test.exs@@ -8,12 +8,13 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do import Pleroma.Factory import Tesla.Mock - import Mock alias Pleroma.Activity alias Pleroma.Object alias Pleroma.Tests.ObanHelpers + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Publisher + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI @as_public "https://www.w3.org/ns/activitystreams#Public" @@ -168,10 +169,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do end describe "publish/2" do - test_with_mock "doesn't publish a non-public activity to quarantined instances.", - Pleroma.Web.ActivityPub.Publisher, - [:passthrough], - [] do + test "doesn't publish a non-public activity to quarantined instances." do Config.put([:instance, :quarantined_instances], [{"domain.com", "some reason"}]) follower = @@ -206,10 +204,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do ) end - test_with_mock "Publishes a non-public activity to non-quarantined instances.", - Pleroma.Web.ActivityPub.Publisher, - [:passthrough], - [] do + test "Publishes a non-public activity to non-quarantined instances." do Config.put([:instance, :quarantined_instances], [{"somedomain.com", "some reason"}]) follower = @@ -245,10 +240,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do ) end - test_with_mock "Publishes to directly addressed actors with higher priority.", - Pleroma.Web.ActivityPub.Publisher, - [:passthrough], - [] do + test "Publishes to directly addressed actors with higher priority." do note_activity = insert(:direct_note_activity) actor = Pleroma.User.get_by_ap_id(note_activity.data["actor"]) @@ -257,21 +249,58 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do assert res == :ok - assert called( - Publisher.enqueue_one( - %{ - inbox: :_, - activity_id: note_activity.id - }, - priority: 0 - ) - ) + assert_enqueued( + worker: "Pleroma.Workers.PublisherWorker", + args: %{ + "op" => "publish_one", + "params" => %{"activity_id" => note_activity.id} + }, + priority: 0 + ) end - test_with_mock "publishes an activity with BCC to all relevant peers.", - Pleroma.Web.ActivityPub.Publisher, - [:passthrough], - [] do + test "Publishes with the new actor if prepare_outgoing changes the actor." do + mock(fn + %{method: :post, url: "https://domain.com/users/nick1/inbox", body: body} -> + {:ok, %Tesla.Env{status: 200, body: body}} + end) + + other_user = + insert(:user, %{ + local: false, + inbox: "https://domain.com/users/nick1/inbox" + }) + + actor = insert(:user) + replaced_actor = insert(:user) + + note_activity = + insert(:note_activity, + user: actor, + data_attrs: %{"to" => [other_user.ap_id]} + ) + + Pleroma.Web.ActivityPub.TransmogrifierMock + |> Mox.expect(:prepare_outgoing, fn data -> + {:ok, Map.put(data, "actor", replaced_actor.ap_id)} + end) + + prepared = + Publisher.prepare_one(%{ + inbox: "https://domain.com/users/nick1/inbox", + activity_id: note_activity.id, + cc: ["https://domain.com/users/nick2/inbox"] + }) + + {:ok, decoded} = Jason.decode(prepared.json) + assert decoded["actor"] == replaced_actor.ap_id + + {:ok, published} = Publisher.publish_one(prepared) + sent_activity = Jason.decode!(published.body) + assert sent_activity["actor"] == replaced_actor.ap_id + end + + test "publishes an activity with BCC to all relevant peers." do follower = insert(:user, %{ local: false, @@ -303,10 +332,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do ) end - test_with_mock "publishes a delete activity to peers who signed fetch requests to the create acitvity/object.", - Pleroma.Web.ActivityPub.Publisher, - [:passthrough], - [] do + test "publishes a delete activity to peers who signed fetch requests to the create acitvity/object." do fetcher = insert(:user, local: false, @@ -510,4 +536,43 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do assert decoded["cc"] == ["https://example.com/specific/user"] end + + describe "prepare_one/1 with reporter anonymization" do + test "signs with the anonymized actor keys when Transmogrifier changes actor" do + Pleroma.SignatureMock + |> Mox.stub(:signed_date, fn -> Pleroma.Signature.signed_date() end) + |> Mox.expect(:sign, fn %Pleroma.User{} = user, _headers -> + send(self(), {:signed_as, user.ap_id}) + "TESTSIG" + end) + + placeholder = insert(:user) + reporter = insert(:user) + target_account = insert(:user) + + clear_config([:activitypub, :anonymize_reporter], true) + clear_config([:activitypub, :anonymize_reporter_local_nickname], placeholder.nickname) + + {:ok, reported} = CommonAPI.post(target_account, %{status: "content"}) + context = Utils.generate_context_id() + + {:ok, activity} = + ActivityPub.flag(%{ + actor: reporter, + context: context, + account: target_account, + statuses: [reported], + content: "reason" + }) + + _prepared = + Publisher.prepare_one(%{ + inbox: "http://remote.example/users/alice/inbox", + activity_id: activity.id + }) + + assert_received {:signed_as, ap_id} + assert ap_id == placeholder.ap_id + end + end enddiff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs@@ -642,6 +642,69 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do assert [_, _, %{"@language" => "pl"}] = modified["@context"] end + + test "it strips report data" do + reporter = insert(:user) + target_account = insert(:user) + content = "foobar" + {:ok, reported_activity} = CommonAPI.post(target_account, %{status: content}) + context = Utils.generate_context_id() + + object_ap_id = reported_activity.object.data["id"] + + assert {:ok, activity} = + Pleroma.Web.ActivityPub.ActivityPub.flag(%{ + actor: reporter, + context: context, + account: target_account, + statuses: [reported_activity], + content: content + }) + + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + + expected_data = + activity.data + |> put_in(["object"], [target_account.ap_id, object_ap_id]) + |> Map.put("actor", reporter.ap_id) + |> Map.merge(Utils.make_json_ld_header()) + + assert data == expected_data + end + + test "it strips report data and anonymize" do + placeholder = insert(:user) + + reporter = insert(:user) + target_account = insert(:user) + content = "foobar" + {:ok, reported_activity} = CommonAPI.post(target_account, %{status: content}) + context = Utils.generate_context_id() + + object_ap_id = reported_activity.object.data["id"] + + assert {:ok, activity} = + Pleroma.Web.ActivityPub.ActivityPub.flag(%{ + actor: reporter, + context: context, + account: target_account, + statuses: [reported_activity], + content: content + }) + + clear_config([:activitypub, :anonymize_reporter], true) + clear_config([:activitypub, :anonymize_reporter_local_nickname], placeholder.nickname) + + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + + expected_data = + activity.data + |> put_in(["object"], [target_account.ap_id, object_ap_id]) + |> Map.put("actor", placeholder.ap_id) + |> Map.merge(Utils.make_json_ld_header()) + + assert data == expected_data + end end describe "actor rewriting" dodiff --git a/test/pleroma/web/activity_pub/utils_test.exs b/test/pleroma/web/activity_pub/utils_test.exs@@ -670,4 +670,78 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do ) end end + + describe "maybe_anonymize_reporter/1" do + setup do + reporter = insert(:user) + report = %{"actor" => reporter.ap_id} + + %{ + placeholder: insert(:user), + reporter: reporter, + report: report + } + end + + test "anonymize when configured correctly", %{ + placeholder: placeholder, + report: report + } do + clear_config([:activitypub, :anonymize_reporter], true) + clear_config([:activitypub, :anonymize_reporter_local_nickname], placeholder.nickname) + + assert %{"actor" => placeholder.ap_id} == Utils.maybe_anonymize_reporter(report) + end + + test "anonymize Activity", %{ + placeholder: placeholder, + reporter: reporter, + report: report + } do + clear_config([:activitypub, :anonymize_reporter], true) + clear_config([:activitypub, :anonymize_reporter_local_nickname], placeholder.nickname) + report_activity = %Activity{actor: reporter, data: report} + anon_id = placeholder.ap_id + + assert %Activity{actor: ^anon_id, data: %{"actor" => ^anon_id}} = + Utils.maybe_anonymize_reporter(report_activity) + end + + test "do not anonymize when disabled", %{ + placeholder: placeholder, + reporter: reporter, + report: report + } do + clear_config([:activitypub, :anonymize_reporter], false) + clear_config([:activitypub, :anonymize_reporter_local_nickname], placeholder.nickname) + + assert %{"actor" => reporter.ap_id} == Utils.maybe_anonymize_reporter(report) + end + + test "do not anonymize when user does not exist", %{ + placeholder: placeholder, + reporter: reporter, + report: report + } do + clear_config([:activitypub, :anonymize_reporter], true) + + clear_config( + [:activitypub, :anonymize_reporter_local_nickname], + placeholder.nickname <> "MewMew" + ) + + assert %{"actor" => reporter.ap_id} == Utils.maybe_anonymize_reporter(report) + end + + test "do not anonymize when user is not local", %{ + reporter: reporter, + report: report + } do + placeholder = insert(:user, local: false) + clear_config([:activitypub, :anonymize_reporter], true) + clear_config([:activitypub, :anonymize_reporter_local_nickname], placeholder.nickname) + + assert %{"actor" => reporter.ap_id} == Utils.maybe_anonymize_reporter(report) + end + end enddiff --git a/test/support/data_case.ex b/test/support/data_case.ex@@ -119,6 +119,12 @@ defmodule Pleroma.DataCase do Mox.stub_with(Pleroma.StubbedHTTPSignaturesMock, Pleroma.Test.HTTPSignaturesProxy) Mox.stub_with(Pleroma.DateTimeMock, Pleroma.DateTime.Impl) + Mox.stub_with(Pleroma.SignatureMock, Pleroma.Signature) + + Mox.stub_with( + Pleroma.Web.ActivityPub.TransmogrifierMock, + Pleroma.Web.ActivityPub.Transmogrifier + ) end def ensure_local_uploader(context) dodiff --git a/test/support/mocks.ex b/test/support/mocks.ex@@ -40,3 +40,9 @@ Mox.defmock(Pleroma.Language.LanguageDetectorMock, Mox.defmock(Pleroma.DateTimeMock, for: Pleroma.DateTime) Mox.defmock(Pleroma.MogrifyMock, for: Pleroma.MogrifyBehaviour) + +Mox.defmock(Pleroma.SignatureMock, for: Pleroma.Signature.API) + +Mox.defmock(Pleroma.Web.ActivityPub.TransmogrifierMock, + for: Pleroma.Web.ActivityPub.Transmogrifier.API +)