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 93f647e2a..1279e0cb4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -713,6 +713,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 = @@ -1360,6 +1361,50 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_unauthenticated(query, _), do: query + 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 + actor_restricted -> + from(activity in query, where: false) + + local_restricted -> + restrict_activity_and_object_origin(query, false) + + remote_restricted -> + restrict_activity_and_object_origin(query, true) + + true -> + query + end + 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) @@ -1517,6 +1562,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..e82d795e1 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,170 @@ 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 "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) + + local_user = insert(:user) + 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) + + {: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 mixed_create.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 mixed_create.id in authenticated_status_ids + assert announce.id in authenticated_status_ids + end + + 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) + + 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) + + 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(%{ + 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 mixed_create.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 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)