fix(mastodon-api): harden admin moderation actions

This commit is contained in:
Lain Soykaf 2026-07-26 13:24:03 +04:00
commit 458a15bf9f
No known key found for this signature in database
10 changed files with 344 additions and 31 deletions

View file

@ -1016,6 +1016,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp restrict_state(query, _), do: query
defp restrict_report_target(query, %{report_target_id: actor_id}) do
from(activity in query,
where: fragment("?->'object'->>0 = ?", activity.data, ^actor_id)
)
end
defp restrict_report_target(query, _), do: query
defp restrict_assigned_account(query, %{assigned_account: assigned_account}) do
from(activity in query,
where: fragment("?->>'assigned_account' = ?", activity.data, ^assigned_account)
@ -1514,6 +1522,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> restrict_actor(opts)
|> restrict_type(opts)
|> restrict_state(opts)
|> restrict_report_target(opts)
|> restrict_assigned_account(opts)
|> restrict_favorited_by(opts)
|> restrict_blocked(restrict_blocked_opts)

View file

@ -216,20 +216,24 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do
properties: %{
type: %Schema{
type: :string,
enum: ["none", "disable", "sensitive", "silence", "suspend"]
enum: ["none", "disable"]
},
report_id: %Schema{
type: :string,
nullable: true,
description: "ID of an associated report that caused this action to be taken"
}
}
},
required: [:type]
},
required: true
),
responses: %{
204 => no_content_response(),
401 => Operation.response("Error", "application/json", ApiError)
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
403 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end

View file

@ -34,7 +34,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do
:target_account_id,
:query,
:string,
"Filter by report target account id (not implemented)"
"Filter by report target account id"
)
] ++
pagination_params(),
@ -81,7 +81,8 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do
responses: %{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError)
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
@ -99,11 +100,43 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do
responses: %{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError)
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def assign_to_self_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Assign report to self",
operationId: "MastodonAdmin.ReportController.assign_to_self",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [Operation.parameter(:id, :path, :string, "ID of the report")],
responses: report_update_responses()
}
end
def unassign_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Unassign report",
operationId: "MastodonAdmin.ReportController.unassign",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [Operation.parameter(:id, :path, :string, "ID of the report")],
responses: report_update_responses()
}
end
defp report_update_responses do
%{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
end
defp report do
%Schema{
title: "Admin::Report",
@ -112,7 +145,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do
id: FlakeID,
action_taken: %Schema{type: :boolean},
action_taken_at: %Schema{type: :string, format: "date-time", nullable: true},
category: %Schema{type: :string, enum: ["spam", "violation", "other"]},
category: %Schema{type: :string, enum: ["spam", "legal", "violation", "other"]},
comment: %Schema{type: :string, nullable: true},
forwarded: %Schema{type: :boolean, nullable: true},
created_at: %Schema{type: :string, format: "date-time"},
@ -136,7 +169,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do
items: %Schema{
type: :object,
properties: %{
id: %Schema{type: :integer},
id: %Schema{type: :string},
text: %Schema{type: :string}
}
}

View file

@ -700,7 +700,7 @@ defmodule Pleroma.Web.CommonAPI do
end
def update_report_state(activity_id, state) do
with %Activity{} = activity <- Activity.get_by_id(activity_id, filter: []) do
with %Activity{} = activity <- Activity.get_report(activity_id) do
Utils.update_report_state(activity, state)
else
nil -> {:error, :not_found}
@ -715,7 +715,7 @@ defmodule Pleroma.Web.CommonAPI do
end
def assign_report_to_account(activity_id, user) do
with %Activity{} = activity <- Activity.get_by_id(activity_id) do
with %Activity{} = activity <- Activity.get_report(activity_id) do
Utils.assign_report_to_account(activity, user)
else
nil -> {:error, :not_found}

View file

@ -33,6 +33,8 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do
when action in [:index, :index2, :show]
)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(
OAuthScopesPlug,
%{scopes: ["admin:write:accounts"]}
@ -92,11 +94,18 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do
conn,
_params
) do
{:ok, _user} = handle_account_action(user, admin, type)
with :ok <- validate_account_action(user, type),
{:ok, report} <- associated_report(user, body_params),
:ok <- resolve_report(admin, report),
{:ok, _user} <- handle_account_action(user, admin, type) do
json_response(conn, :no_content, "")
else
{:error, :not_found} ->
{:error, :not_found}
resolve_report(admin, body_params)
json_response(conn, :no_content, "")
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def delete(%{assigns: %{user: admin, account: user}} = conn, _params) do
@ -161,10 +170,21 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do
User.set_activation(user, false)
end
defp handle_account_action(user, _admin, _type) do
defp handle_account_action(%User{}, _admin, "disable") do
{:error, "Only local accounts can be disabled"}
end
defp handle_account_action(user, _admin, "none") do
{:ok, user}
end
defp validate_account_action(%User{local: true}, "disable"), do: :ok
defp validate_account_action(%User{}, "disable"),
do: {:error, "Only local accounts can be disabled"}
defp validate_account_action(%User{}, "none"), do: :ok
defp build_criteria(params) do
%{}
|> maybe_filter_local(params)
@ -251,7 +271,23 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do
defp unquote(:"maybe_filter_#{filter_param}")(criteria, _params), do: criteria
end
defp resolve_report(admin, %{report_id: id}) do
defp associated_report(user, %{report_id: id}) do
case Activity.get_report(id) do
%Activity{data: %{"object" => [target_ap_id | _]}} = report
when target_ap_id == user.ap_id ->
{:ok, report}
%Activity{} ->
{:error, "Report does not target this account"}
nil ->
{:error, :not_found}
end
end
defp associated_report(_user, _params), do: {:ok, nil}
defp resolve_report(admin, %Activity{id: id}) do
with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
@ -260,9 +296,10 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do
subject: activity,
subject_actor: report.user_actor
})
:ok
end
end
defp resolve_report(_admin, _params) do
end
defp resolve_report(_admin, nil), do: :ok
end

View file

@ -26,6 +26,13 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
plug(OAuthScopesPlug, %{scopes: ["admin:write:reports"]} when action in [:resolve, :reopen])
plug(
OAuthScopesPlug,
%{scopes: ["admin:write:reports"]} when action in [:assign_to_self, :unassign]
)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation
def index(conn, params) do
@ -37,6 +44,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
|> Map.put(:total, true)
|> restrict_state(params)
|> restrict_actor(params)
|> restrict_target(params)
reports =
ActivityPub.fetch_activities_query([], opts)
@ -67,6 +75,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
@ -84,6 +95,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
@ -102,6 +116,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
@ -119,14 +136,17 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved")
defp restrict_state(opts, %{resolved: true, unresolved: true}), do: opts
defp restrict_state(opts, %{resolved: _}), do: Map.put(opts, :state, "resolved")
defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved")
defp restrict_state(opts, _params), do: Map.put(opts, :state, "open")
@ -139,4 +159,13 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
end
defp restrict_actor(opts, _params), do: opts
defp restrict_target(opts, %{target_account_id: actor}) do
case User.get_by_id(actor) do
%User{ap_id: ap_id} -> Map.put(opts, :report_target_id, ap_id)
_ -> Map.put(opts, :report_target_id, actor)
end
end
defp restrict_target(opts, _params), do: opts
end

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do
use Pleroma.Web, :view
alias Pleroma.HTML
alias Pleroma.Rule
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.Admin.AccountView
@ -21,9 +22,12 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do
report: report,
user: account,
account: target_account,
statuses: statuses
statuses: statuses,
assigned_account: assigned_account
}) do
created_at = Utils.to_masto_date(report.data["published"])
updated_at = Utils.to_masto_date(report.updated_at)
action_taken = report.data["state"] != "open"
content =
unless is_nil(report.data["content"]) do
@ -34,23 +38,34 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do
%{
id: report.id,
action_taken: report.data["state"] != "open",
action_taken_at: nil,
action_taken: action_taken,
action_taken_at: if(action_taken, do: updated_at),
category: "other",
comment: content,
forwarded: nil,
forwarded: false,
created_at: created_at,
updated_at: created_at,
updated_at: updated_at,
account: AccountView.render("show.json", %{user: account}),
target_account: AccountView.render("show.json", %{user: target_account}),
assigned_account: nil,
assigned_account: render_account(assigned_account),
action_taken_by_account: nil,
statuses:
StatusView.render("index.json", %{
activities: statuses,
as: :activity
}),
rules: []
rules: rules(report.data["rules"])
}
end
defp render_account(nil), do: nil
defp render_account(account), do: AccountView.render("show.json", %{user: account})
defp rules(nil), do: []
defp rules(rule_ids) do
rule_ids
|> Rule.get()
|> Enum.map(&%{id: to_string(&1.id), text: &1.text})
end
end

View file

@ -137,6 +137,12 @@ defmodule Pleroma.Web.Router do
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
end
pipeline :require_privileged_role_account_actions do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :reports_manage_reports)
end
pipeline :require_privileged_role_users_manage_invites do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_invites)
@ -443,10 +449,15 @@ defmodule Pleroma.Web.Router do
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_manage_activation_state])
post("/accounts/:id/action", AccountController, :account_action)
post("/accounts/:id/enable", AccountController, :enable)
end
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_account_actions])
post("/accounts/:id/action", AccountController, :account_action)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_manage_invites])
@ -462,6 +473,8 @@ defmodule Pleroma.Web.Router do
get("/reports/:id", ReportController, :show)
post("/reports/:id/resolve", ReportController, :resolve)
post("/reports/:id/reopen", ReportController, :reopen)
post("/reports/:id/assign_to_self", ReportController, :assign_to_self)
post("/reports/:id/unassign", ReportController, :unassign)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)

View file

@ -183,6 +183,20 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do
assert %{is_active: false} = user
end
test "does not disable a remote account", %{conn: conn} do
%{id: id} = user = insert(:user, local: false)
assert %{"error" => "Only local accounts can be disabled"} =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/admin/accounts/#{id}/action", %{
"type" => "disable"
})
|> json_response_and_validate_schema(400)
assert %{is_active: true} = Repo.reload!(user)
end
test "perform action with assigned report", %{conn: conn} do
[reporter, target_user] = insert_pair(:user)
@ -191,11 +205,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do
account_id: target_user.id
})
%{id: id} = insert(:user)
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/admin/accounts/#{id}/action", %{
|> post("/api/v1/admin/accounts/#{target_user.id}/action", %{
"type" => "none",
"report_id" => report_id
})
@ -205,6 +217,56 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do
assert %{data: %{"state" => "resolved"}} = report
end
test "requires the report management privilege", %{conn: conn} do
clear_config([:instance, :admin_privileges], [:users_manage_activation_state])
%{id: id} = insert(:user)
assert %{"error" => _} =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/admin/accounts/#{id}/action", %{"type" => "none"})
|> json_response(403)
end
test "does not resolve a report for another account", %{conn: conn} do
[reporter, target_user, other_user] = insert_list(3, :user)
{:ok, report} =
CommonAPI.report(reporter, %{
account_id: target_user.id
})
assert %{"error" => _} =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/admin/accounts/#{other_user.id}/action", %{
"type" => "none",
"report_id" => report.id
})
|> json_response_and_validate_schema(400)
assert %{data: %{"state" => "open"}} = Repo.reload!(report)
end
test "requires an action type", %{conn: conn} do
%{id: id} = insert(:user)
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/admin/accounts/#{id}/action", %{})
|> json_response_and_validate_schema(400)
end
test "rejects unsupported action types", %{conn: conn} do
%{id: id} = insert(:user)
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/admin/accounts/#{id}/action", %{"type" => "sensitive"})
|> json_response_and_validate_schema(400)
end
end
describe "POST /api/v1/admin/accounts/:id/enable" do

View file

@ -8,6 +8,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do
import Pleroma.Factory
alias Pleroma.Activity
alias Pleroma.Repo
alias Pleroma.Rule
alias Pleroma.Web.CommonAPI
setup_all do
@ -58,6 +61,11 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do
|> get("/api/v1/admin/reports")
|> json_response_and_validate_schema(200)
assert [%{"id" => ^report_id1}] =
conn
|> get("/api/v1/admin/reports?resolved=false")
|> json_response_and_validate_schema(200)
assert [%{"id" => ^report_id2}, %{"id" => ^report_id1}] =
conn
|> get("/api/v1/admin/reports?resolved=true&unresolved=true")
@ -68,6 +76,25 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do
|> get("/api/v1/admin/reports?resolved=true")
|> json_response_and_validate_schema(200)
end
test "get reports by target account", %{conn: conn} do
[reporter, target_user, other_target] = insert_list(3, :user)
{:ok, %{id: report_id}} =
CommonAPI.report(reporter, %{
account_id: target_user.id
})
{:ok, _report} =
CommonAPI.report(reporter, %{
account_id: other_target.id
})
assert [%{"id" => ^report_id}] =
conn
|> get("/api/v1/admin/reports?target_account_id=#{target_user.id}")
|> json_response_and_validate_schema(200)
end
end
describe "GET /api/v1/admin/reports/:id" do
@ -86,6 +113,34 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do
|> get("/api/v1/admin/reports/#{report_id}")
|> json_response_and_validate_schema(200)
end
test "returns report rules", %{conn: conn} do
[reporter, target_user] = insert_pair(:user)
rule = Rule.create(%{text: "Be excellent to each other"})
{:ok, %{id: report_id}} =
CommonAPI.report(reporter, %{
account_id: target_user.id,
rule_ids: [rule.id]
})
assert %{
"rules" => [
%{"id" => rule_id, "text" => "Be excellent to each other"}
]
} =
conn
|> get("/api/v1/admin/reports/#{report_id}")
|> json_response_and_validate_schema(200)
assert rule_id == to_string(rule.id)
end
test "returns 404 for a missing report", %{conn: conn} do
conn
|> get("/api/v1/admin/reports/0")
|> json_response_and_validate_schema(404)
end
end
describe "POST /api/v1/admin/reports/:id/resolve" do
@ -104,6 +159,16 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do
|> post("/api/v1/admin/reports/#{report_id}/resolve")
|> json_response_and_validate_schema(200)
end
test "does not mutate a non-report activity", %{conn: conn} do
activity = insert(:note_activity)
conn
|> post("/api/v1/admin/reports/#{activity.id}/resolve")
|> json_response_and_validate_schema(404)
refute Repo.reload!(activity).data["state"]
end
end
describe "POST /api/v1/admin/reports/:id/reopen" do
@ -124,5 +189,51 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do
|> post("/api/v1/admin/reports/#{report_id}/reopen")
|> json_response_and_validate_schema(200)
end
test "does not mutate a non-report activity", %{conn: conn} do
activity = insert(:note_activity)
conn
|> post("/api/v1/admin/reports/#{activity.id}/reopen")
|> json_response_and_validate_schema(404)
refute Repo.reload!(activity).data["state"]
end
end
describe "POST /api/v1/admin/reports/:id/assign_to_self" do
test "assign and unassign a report", %{admin: admin, conn: conn} do
[reporter, target_user] = insert_pair(:user)
{:ok, report} =
CommonAPI.report(reporter, %{
account_id: target_user.id
})
assert %{"assigned_account" => %{"id" => admin_id}} =
conn
|> post("/api/v1/admin/reports/#{report.id}/assign_to_self")
|> json_response_and_validate_schema(200)
assert admin_id == admin.id
assert Repo.reload!(report).data["assigned_account"] == admin.id
assert %{"assigned_account" => nil} =
conn
|> post("/api/v1/admin/reports/#{report.id}/unassign")
|> json_response_and_validate_schema(200)
refute Repo.reload!(report).data["assigned_account"]
end
test "does not assign a non-report activity", %{conn: conn} do
%Activity{id: id} = activity = insert(:note_activity)
conn
|> post("/api/v1/admin/reports/#{id}/assign_to_self")
|> json_response_and_validate_schema(404)
refute Repo.reload!(activity).data["assigned_account"]
end
end
end