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:
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