logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma git clone https://anongit.hacktivis.me/git/pleroma.git/
commit: 426535bc38330cff207cea4a0ba113b68ecbaee3
parent f91474851050e5d9b67e301d3558f236d253f002
Author: Phantasm <phantasm@centrum.cz>
Date:   Sat,  6 Dec 2025 23:59:44 +0100

CommonAPI: Forbid disallowed status (un)muting and unpinning

When a user tried to unpin a status not belonging to them, a full
MastoAPI response was sent back even if status was not visible to them.

Ditto with (un)mutting except ownership.

Diffstat:

Mlib/pleroma/web/api_spec/operations/status_operation.ex38+++++++++++++++++++++++++++++++++++---
Mlib/pleroma/web/common_api.ex13++++++++++---
Mlib/pleroma/web/mastodon_api/controllers/status_controller.ex21+++++++++++++++++++++
Mtest/pleroma/web/mastodon_api/controllers/status_controller_test.exs35+++++++++++++++++++++++++++++++++++
4 files changed, 101 insertions(+), 6 deletions(-)

diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -250,7 +250,19 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do example: %{ "error" => "Record not found" } - }) + }), + 422 => + Operation.response( + "Unprocessable Entity", + "application/json", + %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "Someone else's status cannot be unpinned" + } + } + ) } } end @@ -325,7 +337,17 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do ], responses: %{ 200 => status_response(), - 400 => Operation.response("Error", "application/json", ApiError) + 400 => Operation.response("Error", "application/json", ApiError), + 404 => + Operation.response( + "Unprocessable Entity", + "application/json", + %Schema{ + allOf: [ApiError], + title: "Error", + example: %{"error" => "Record not found"} + } + ) } } end @@ -341,7 +363,17 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do parameters: [id_param()], responses: %{ 200 => status_response(), - 400 => Operation.response("Error", "application/json", ApiError) + 400 => Operation.response("Error", "application/json", ApiError), + 404 => + Operation.response( + "Error", + "application/json", + %Schema{ + allOf: [ApiError], + title: "Error", + example: %{"error" => "Record not found"} + } + ) } } end diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex @@ -554,6 +554,7 @@ defmodule Pleroma.Web.CommonAPI do @spec unpin(String.t(), User.t()) :: {:ok, Activity.t()} | Pipeline.errors() def unpin(id, user) do with %Activity{} = activity <- create_activity_by_id(id), + true <- activity_belongs_to_actor(activity, user.ap_id), {:ok, unpin_data, _} <- Builder.unpin(user, activity.object), {:ok, _unpin, _} <- Pipeline.common_pipeline(unpin_data, @@ -570,7 +571,8 @@ defmodule Pleroma.Web.CommonAPI do def add_mute(activity, user, params \\ %{}) do expires_in = Map.get(params, :expires_in, 0) - with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]), + with true <- Visibility.visible_for_user?(activity, user), + {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]), _ <- Pleroma.Notification.mark_context_as_read(user, activity.data["context"]) do if expires_in > 0 do Pleroma.Workers.MuteExpireWorker.new( @@ -583,13 +585,18 @@ defmodule Pleroma.Web.CommonAPI do {:ok, activity} else {:error, _} -> {:error, dgettext("errors", "conversation is already muted")} + false -> {:error, :visibility_error} end end @spec remove_mute(Activity.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()} def remove_mute(%Activity{} = activity, %User{} = user) do - ThreadMute.remove_mute(user.id, activity.data["context"]) - {:ok, activity} + if Visibility.visible_for_user?(activity, user) do + ThreadMute.remove_mute(user.id, activity.data["context"]) + {:ok, activity} + else + {:error, :visibility_error} + end end @spec remove_mute(String.t(), String.t()) :: {:ok, Activity.t()} | {:error, any()} diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -413,8 +413,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do conn, _ ) do + # CommonAPI already checks whether user can unpin with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + {:error, :ownership_error} -> + {:error, :unprocessable_entity, "Someone else's status cannot be unpinned"} + + error -> + error end end @@ -462,8 +469,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do _ ) do with %Activity{} = activity <- Activity.get_by_id(id), + # CommonAPI already checks whether user is allowed to mute {:ok, activity} <- CommonAPI.add_mute(activity, user, params) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + {:error, :visibility_error} -> + {:error, :not_found, "Record not found"} + + error -> + error end end @@ -476,8 +490,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do _ ) do with %Activity{} = activity <- Activity.get_by_id(id), + # CommonAPI already checks whether user is allowed to unmute {:ok, activity} <- CommonAPI.remove_mute(activity, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + {:error, :visibility_error} -> + {:error, :not_found, "Record not found"} + + error -> + error end end diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1769,6 +1769,17 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} end + test "/unpin: returns 422 error when activity not owned by user", %{activity: activity} do + %{conn: conn} = oauth_access(["write:accounts"]) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unpin") + |> json_response_and_validate_schema(422) == %{ + "error" => "Someone else's status cannot be unpinned" + } + end + test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"}) @@ -1977,6 +1988,30 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> post("/api/v1/statuses/#{activity.id}/unmute") |> json_response_and_validate_schema(200) end + + test "cannot mute not visible conversation", %{user: user} do + {:ok, activity} = CommonAPI.post(user, %{status: "Invisible!", visibility: "private"}) + %{conn: conn} = oauth_access(["write:mutes"]) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/mute") + |> json_response_and_validate_schema(404) == %{ + "error" => "Record not found" + } + end + + test "cannot unmute not visible conversation", %{user: user} do + {:ok, activity} = CommonAPI.post(user, %{status: "Invisible!", visibility: "private"}) + %{conn: conn} = oauth_access(["write:mutes"]) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unmute") + |> json_response_and_validate_schema(404) == %{ + "error" => "Record not found" + } + end end test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{conn: conn} do