From 2b32e3cf1fa89eda4bd7843455de5c885b732316 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 26 Jul 2026 18:12:49 +0400 Subject: [PATCH 1/2] fix(mastodon-api): restrict unauthenticated account statuses --- ...trict-unauthenticated-account-statuses.fix | 1 + lib/pleroma/web/activity_pub/activity_pub.ex | 38 +++++++ .../controllers/account_controller.ex | 1 + .../controllers/account_controller_test.exs | 106 ++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 changelog.d/restrict-unauthenticated-account-statuses.fix diff --git a/changelog.d/restrict-unauthenticated-account-statuses.fix b/changelog.d/restrict-unauthenticated-account-statuses.fix new file mode 100644 index 000000000..a6ae013fd --- /dev/null +++ b/changelog.d/restrict-unauthenticated-account-statuses.fix @@ -0,0 +1 @@ +Fixed account status and media views exposing activities restricted from unauthenticated users. diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index cdad6a49b..722bf8b60 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1357,6 +1357,43 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_unauthenticated(query, _), do: query + defp restrict_unauthenticated_object(query, nil) do + local = Config.restrict_unauthenticated_access?(:activities, :local) + remote = Config.restrict_unauthenticated_access?(:activities, :remote) + local_object_pattern = Pleroma.Web.Endpoint.url() <> "/%" + + cond do + local and remote -> + from([object: object] in query, where: false) + + local -> + from([object: object] in query, + where: not fragment("(?->>'id') LIKE ?", object.data, ^local_object_pattern) + ) + + remote -> + from([object: object] in query, + where: fragment("(?->>'id') LIKE ?", object.data, ^local_object_pattern) + ) + + true -> + query + end + end + + defp restrict_unauthenticated_object(query, _), do: query + + defp maybe_restrict_unauthenticated( + query, + %{restrict_unauthenticated: true, user: user} + ) do + query + |> restrict_unauthenticated(user) + |> restrict_unauthenticated_object(user) + end + + defp maybe_restrict_unauthenticated(query, _), do: query + defp restrict_quote_url(query, %{quote_url: quote_url}) do from([_activity, object] in query, where: fragment("(?)->'quoteUrl' = ?", object.data, ^quote_url) @@ -1514,6 +1551,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> maybe_preload_report_notes(opts) |> maybe_set_thread_muted_field(opts) |> maybe_order(opts) + |> maybe_restrict_unauthenticated(opts) |> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags]) |> restrict_replies(opts) |> restrict_since(opts) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 6d5851029..631102843 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -348,6 +348,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do params |> Map.delete(:tagged) |> Map.put(:tag, params[:tagged]) + |> Map.put(:restrict_unauthenticated, true) activities = ActivityPub.fetch_user_activities(user, reading_user, params) diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index 01e195842..a2ac6382a 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -458,6 +458,112 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert [%{"id" => ^image_post_id}] = json_response_and_validate_schema(conn, 200) end + test "media respects activity restrictions when unauthenticated profiles are enabled", %{ + conn: conn + } do + clear_config([:instance, :public], false) + clear_config([:restrict_unauthenticated, :profiles, :local], false) + + user = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, %{id: media_id}} = ActivityPub.upload(file, actor: user.ap_id) + {:ok, activity} = CommonAPI.post(user, %{status: "cofe", media_ids: [media_id]}) + user_id = user.id + + assert %{"id" => ^user_id} = + build_conn() + |> get("/api/v1/accounts/#{user.id}") + |> json_response_and_validate_schema(200) + + assert [] = + build_conn() + |> get("/api/v1/accounts/#{user.id}/statuses?only_media=true") + |> json_response_and_validate_schema(200) + + assert [%{"id" => activity_id}] = + conn + |> get("/api/v1/accounts/#{user.id}/statuses?only_media=true") + |> json_response_and_validate_schema(200) + + assert activity_id == activity.id + end + + test "activity restrictions apply to boosted objects", %{conn: conn} do + clear_config([:restrict_unauthenticated, :activities, :local], false) + clear_config([:restrict_unauthenticated, :activities, :remote], true) + + local_user = insert(:user) + remote_user = insert(:user, local: false, domain: "example.com") + local_activity = insert(:note_activity, user: local_user) + + remote_activity = + insert(:note_activity, local: false, object_local: false, user: remote_user) + + {:ok, announce} = CommonAPI.repeat(remote_activity.id, local_user) + + status_ids = + build_conn() + |> get("/api/v1/accounts/#{local_user.id}/statuses") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["id"]) + + assert local_activity.id in status_ids + refute announce.id in status_ids + + authenticated_status_ids = + conn + |> get("/api/v1/accounts/#{local_user.id}/statuses") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["id"]) + + assert local_activity.id in authenticated_status_ids + assert announce.id in authenticated_status_ids + end + + test "local object restrictions apply to remote boosts", %{conn: conn} do + clear_config([:restrict_unauthenticated, :activities, :local], true) + clear_config([:restrict_unauthenticated, :activities, :remote], false) + + local_user = insert(:user) + remote_user = insert(:user, local: false, domain: "example.com") + local_activity = insert(:note_activity, user: local_user) + + remote_activity = + insert(:note_activity, local: false, object_local: false, user: remote_user) + + remote_announce = + insert(:announce_activity, note_activity: local_activity, user: remote_user) + |> Ecto.Changeset.change(%{ + local: false, + recipients: ["https://www.w3.org/ns/activitystreams#Public"] + }) + |> Repo.update!() + + status_ids = + build_conn() + |> get("/api/v1/accounts/#{remote_user.id}/statuses") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["id"]) + + assert remote_activity.id in status_ids + refute remote_announce.id in status_ids + + authenticated_status_ids = + conn + |> get("/api/v1/accounts/#{remote_user.id}/statuses") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["id"]) + + assert remote_activity.id in authenticated_status_ids + assert remote_announce.id in authenticated_status_ids + end + test "gets a user's statuses without reblogs", %{user: user, conn: conn} do {:ok, %{id: post_id}} = CommonAPI.post(user, %{status: "HI!!!"}) {:ok, _} = CommonAPI.repeat(post_id, user) From 4a47a756bee471791e39b38fc0b24eaf043772f7 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 28 Jul 2026 16:01:48 +0400 Subject: [PATCH 2/2] fix(mastodon-api): optimize account status restrictions --- lib/pleroma/web/activity_pub/activity_pub.ex | 58 +++++++++-------- .../controllers/account_controller_test.exs | 62 ++++++++++++++++++- 2 files changed, 93 insertions(+), 27 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 722bf8b60..363b5f3c3 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -710,6 +710,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> Map.put(:type, ["Create", "Announce"]) |> Map.put(:user, reading_user) |> Map.put(:actor_id, user.ap_id) + |> Map.put(:actor_local, user.local) |> Map.put(:pinned_object_ids, Map.keys(user.pinned_objects)) params = @@ -1357,41 +1358,48 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_unauthenticated(query, _), do: query - defp restrict_unauthenticated_object(query, nil) do - local = Config.restrict_unauthenticated_access?(:activities, :local) - remote = Config.restrict_unauthenticated_access?(:activities, :remote) - local_object_pattern = Pleroma.Web.Endpoint.url() <> "/%" + defp restrict_activity_and_object_origin(query, local?) do + local_object_prefix = Pleroma.Web.Endpoint.url() <> "/" + + # Keeping the correlated origin checks inside CASE prevents PostgreSQL from choosing a + # global bitmap-and-sort plan before applying the account statuses limit. + from([activity, object: object] in query, + where: + fragment( + "CASE WHEN ? = ? AND starts_with((?->>'id'), ?) = ? THEN true ELSE false END", + activity.local, + ^local?, + object.data, + ^local_object_prefix, + ^local? + ) + ) + end + + defp maybe_restrict_unauthenticated( + query, + %{restrict_unauthenticated: true, user: nil, actor_local: actor_local} + ) + when is_boolean(actor_local) do + local_restricted = Config.restrict_unauthenticated_access?(:activities, :local) + remote_restricted = Config.restrict_unauthenticated_access?(:activities, :remote) + actor_restricted = if actor_local, do: local_restricted, else: remote_restricted cond do - local and remote -> - from([object: object] in query, where: false) + actor_restricted -> + from(activity in query, where: false) - local -> - from([object: object] in query, - where: not fragment("(?->>'id') LIKE ?", object.data, ^local_object_pattern) - ) + local_restricted -> + restrict_activity_and_object_origin(query, false) - remote -> - from([object: object] in query, - where: fragment("(?->>'id') LIKE ?", object.data, ^local_object_pattern) - ) + remote_restricted -> + restrict_activity_and_object_origin(query, true) true -> query end end - defp restrict_unauthenticated_object(query, _), do: query - - defp maybe_restrict_unauthenticated( - query, - %{restrict_unauthenticated: true, user: user} - ) do - query - |> restrict_unauthenticated(user) - |> restrict_unauthenticated_object(user) - end - defp maybe_restrict_unauthenticated(query, _), do: query defp restrict_quote_url(query, %{quote_url: quote_url}) do diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index a2ac6382a..e82d795e1 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -494,7 +494,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert activity_id == activity.id end - test "activity restrictions apply to boosted objects", %{conn: conn} do + test "remote activity restrictions apply to created and boosted objects", %{conn: conn} do clear_config([:restrict_unauthenticated, :activities, :local], false) clear_config([:restrict_unauthenticated, :activities, :remote], true) @@ -502,6 +502,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do remote_user = insert(:user, local: false, domain: "example.com") local_activity = insert(:note_activity, user: local_user) + mixed_create = + insert(:note_activity, local: true, object_local: false, user: local_user) + remote_activity = insert(:note_activity, local: false, object_local: false, user: remote_user) @@ -514,6 +517,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do |> Enum.map(& &1["id"]) assert local_activity.id in status_ids + refute mixed_create.id in status_ids refute announce.id in status_ids authenticated_status_ids = @@ -523,10 +527,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do |> Enum.map(& &1["id"]) assert local_activity.id in authenticated_status_ids + assert mixed_create.id in authenticated_status_ids assert announce.id in authenticated_status_ids end - test "local object restrictions apply to remote boosts", %{conn: conn} do + test "local activity restrictions apply to created and boosted objects", %{conn: conn} do clear_config([:restrict_unauthenticated, :activities, :local], true) clear_config([:restrict_unauthenticated, :activities, :remote], false) @@ -537,6 +542,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do remote_activity = insert(:note_activity, local: false, object_local: false, user: remote_user) + mixed_create = + insert(:note_activity, local: false, object_local: true, user: remote_user) + remote_announce = insert(:announce_activity, note_activity: local_activity, user: remote_user) |> Ecto.Changeset.change(%{ @@ -552,6 +560,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do |> Enum.map(& &1["id"]) assert remote_activity.id in status_ids + refute mixed_create.id in status_ids refute remote_announce.id in status_ids authenticated_status_ids = @@ -561,9 +570,58 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do |> Enum.map(& &1["id"]) assert remote_activity.id in authenticated_status_ids + assert mixed_create.id in authenticated_status_ids assert remote_announce.id in authenticated_status_ids end + test "restricted local accounts reject activities with mismatched remote origins", %{ + conn: conn + } do + clear_config([:restrict_unauthenticated, :activities, :local], true) + clear_config([:restrict_unauthenticated, :activities, :remote], false) + + local_user = insert(:user) + + mismatched_activity = + insert(:note_activity, local: false, object_local: false, user: local_user) + + assert [] = + build_conn() + |> get("/api/v1/accounts/#{local_user.id}/statuses") + |> json_response_and_validate_schema(200) + + assert [%{"id" => activity_id}] = + conn + |> get("/api/v1/accounts/#{local_user.id}/statuses") + |> json_response_and_validate_schema(200) + + assert activity_id == mismatched_activity.id + end + + test "restricted remote accounts reject activities with mismatched local origins", %{ + conn: conn + } do + clear_config([:restrict_unauthenticated, :activities, :local], false) + clear_config([:restrict_unauthenticated, :activities, :remote], true) + + remote_user = insert(:user, local: false, domain: "example.com") + + mismatched_activity = + insert(:note_activity, local: true, object_local: true, user: remote_user) + + assert [] = + build_conn() + |> get("/api/v1/accounts/#{remote_user.id}/statuses") + |> json_response_and_validate_schema(200) + + assert [%{"id" => activity_id}] = + conn + |> get("/api/v1/accounts/#{remote_user.id}/statuses") + |> json_response_and_validate_schema(200) + + assert activity_id == mismatched_activity.id + end + test "gets a user's statuses without reblogs", %{user: user, conn: conn} do {:ok, %{id: post_id}} = CommonAPI.post(user, %{status: "HI!!!"}) {:ok, _} = CommonAPI.repeat(post_id, user)