Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85829829
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
13 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/changelog.d/c2s-hardening.security b/changelog.d/c2s-hardening.security
index e10756143..5974de72c 100644
--- a/changelog.d/c2s-hardening.security
+++ b/changelog.d/c2s-hardening.security
@@ -1 +1,2 @@
Reject EmojiReact activities whose name is not a single emoji shortcode
+Enforce poll voting rules (visible, open poll, not the author, one vote per option) for all votes on local polls and all local votes
diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex
index cfb8e55f1..b57d9d562 100644
--- a/lib/pleroma/web/activity_pub/object_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validator.ex
@@ -146,6 +146,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
|> CommonFixes.maybe_set_attributed_to_from_activity(create_activity)
|> CommonFixes.maybe_add_language_from_activity(create_activity)
|> cast_and_apply_and_stringify_with_history(),
+ :ok <- validate_new_object(object_data, meta),
meta = Keyword.put(meta, :object_data, object_data),
{:ok, create_activity} <-
create_activity
@@ -303,6 +304,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
def validate_remote_limit(_), do: :ok
+ # Rules for new objects that depend on other objects or on the creator.
+ defp validate_new_object(%{"type" => "Answer"} = answer, _meta),
+ do: AnswerValidator.validate_vote(answer)
+
+ defp validate_new_object(_object, _meta), do: :ok
+
defp validate_updated_actor(%{"object" => %{"type" => type} = actor}, meta)
when type in actor_types() do
with {_, false} <- {:local, Access.get(meta, :local, false)},
diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex
index 2d9b8ba02..41e8de9c1 100644
--- a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex
@@ -6,8 +6,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do
use Ecto.Schema
alias Pleroma.EctoType.ActivityPub.ObjectValidators
+ alias Pleroma.Object
+ alias Pleroma.User
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
+ alias Pleroma.Web.ActivityPub.Utils
+ alias Pleroma.Web.ActivityPub.Visibility
import Ecto.Changeset
@@ -67,4 +71,69 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do
|> CommonValidations.validate_actor_presence()
|> CommonValidations.validate_host_match()
end
+
+ @doc """
+ Checks that a new Answer is a valid vote: the poll is open and visible to
+ the voter, the voter is not its author, the option exists and the voter has
+ not voted for it before (or at all, for single-choice polls).
+
+ The rules apply when the voter or the poll is local. Votes between two remote
+ parties are not ours to judge.
+ """
+ @spec validate_vote(map()) :: :ok | {:error, Ecto.Changeset.t()}
+ def validate_vote(answer) do
+ voter = User.get_cached_by_ap_id(answer["actor"])
+ poll = Object.normalize(answer["inReplyTo"], fetch: false)
+
+ if local?(voter, poll) do
+ case vote_error(answer, voter, poll) do
+ nil -> :ok
+ message -> {:error, answer |> cast_data() |> add_error(:inReplyTo, message)}
+ end
+ else
+ :ok
+ end
+ end
+
+ defp local?(%User{local: true}, _poll), do: true
+ defp local?(_voter, %Object{} = poll), do: Object.local?(poll)
+ defp local?(_voter, _poll), do: false
+
+ defp vote_error(answer, voter, %Object{data: %{"type" => "Question"}} = poll) do
+ cond do
+ poll.data["actor"] == answer["actor"] -> "poll's author can't vote"
+ !Visibility.visible_for_user?(poll, voter) -> "is not visible to the voter"
+ closed?(poll) -> "poll has already ended"
+ answer["name"] not in option_names(poll) -> "is not a poll option"
+ voted?(answer, poll) -> "already voted"
+ true -> nil
+ end
+ end
+
+ defp vote_error(_answer, _voter, _poll), do: "is not a poll"
+
+ defp option_names(%Object{data: data}) do
+ (List.wrap(data["oneOf"]) ++ List.wrap(data["anyOf"]))
+ |> Enum.map(& &1["name"])
+ end
+
+ defp closed?(%Object{data: %{"closed" => closed}}) when is_binary(closed) do
+ case DateTime.from_iso8601(closed) do
+ {:ok, closed, _} -> DateTime.compare(closed, DateTime.utc_now()) != :gt
+ _ -> false
+ end
+ end
+
+ defp closed?(_poll), do: false
+
+ # Multiple-choice votes arrive as one Answer per option.
+ defp voted?(%{"actor" => actor, "name" => name}, %Object{data: data} = poll) do
+ votes = Utils.get_existing_votes(actor, poll)
+
+ if match?([_ | _], data["anyOf"]) do
+ Enum.any?(votes, &(&1.object.data["name"] == name))
+ else
+ votes != []
+ end
+ end
end
diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex
index 88aa81b4b..ca7ea94b1 100644
--- a/lib/pleroma/web/common_api.ex
+++ b/lib/pleroma/web/common_api.ex
@@ -372,35 +372,50 @@ defmodule Pleroma.Web.CommonAPI do
@spec vote(Object.t(), User.t(), list()) :: {:ok, list(), Object.t()} | Pipeline.errors()
def vote(%Object{data: %{"type" => "Question"}} = object, %User{} = user, choices) do
- with :ok <- validate_not_author(object, user),
- :ok <- validate_existing_votes(user, object),
- {:ok, options, choices} <- normalize_and_validate_choices(choices, object) do
- answer_activities =
- Enum.map(choices, fn index ->
- {:ok, answer_object, _meta} =
- Builder.answer(user, object, Enum.at(options, index)["name"])
-
- {:ok, activity_data, _meta} = Builder.create(user, answer_object, [])
-
- {:ok, activity, _meta} =
- activity_data
- |> Map.put("cc", answer_object["cc"])
- |> Map.put("context", answer_object["context"])
- |> Pipeline.common_pipeline(local: true)
-
- # TODO: Do preload of Pleroma.Object in Pipeline
- Activity.normalize(activity.data)
- end)
-
+ # The Answer validator checks each vote. A multiple-choice vote is several
+ # Answers, so "voted before" for the whole vote is checked here.
+ with :ok <- validate_existing_votes(user, object),
+ {:ok, options, choices} <- normalize_and_validate_choices(choices, object),
+ {:ok, answer_activities} <- create_answers(user, object, options, choices) do
object = Object.get_cached_by_ap_id(object.data["id"])
{:ok, answer_activities, object}
end
end
- defp validate_not_author(%{data: %{"actor" => ap_id}}, %{ap_id: ap_id}),
- do: {:error, dgettext("errors", "Poll's author can't vote")}
+ defp create_answers(user, object, options, choices) do
+ Enum.reduce_while(choices, {:ok, []}, fn index, {:ok, activities} ->
+ case create_answer(user, object, Enum.at(options, index)["name"]) do
+ {:ok, activity} -> {:cont, {:ok, activities ++ [activity]}}
+ error -> {:halt, error}
+ end
+ end)
+ end
+
+ defp create_answer(user, object, name) do
+ {:ok, answer_object, _meta} = Builder.answer(user, object, name)
+ {:ok, activity_data, _meta} = Builder.create(user, answer_object, [])
+
+ activity_data
+ |> Map.put("cc", answer_object["cc"])
+ |> Map.put("context", answer_object["context"])
+ |> Pipeline.common_pipeline(local: true)
+ |> case do
+ # TODO: Do preload of Pleroma.Object in Pipeline
+ {:ok, activity, _meta} -> {:ok, Activity.normalize(activity.data)}
+ {:error, {:validate, {:error, changeset}}} -> {:error, vote_error_message(changeset)}
+ error -> error
+ end
+ end
- defp validate_not_author(_, _), do: :ok
+ defp vote_error_message(%Ecto.Changeset{errors: errors}) do
+ case errors[:inReplyTo] do
+ {"poll's author can't vote", _} -> dgettext("errors", "Poll's author can't vote")
+ {"poll has already ended", _} -> dgettext("errors", "Poll has already ended")
+ {"already voted", _} -> dgettext("errors", "Already voted")
+ {"is not a poll option", _} -> dgettext("errors", "Invalid indices")
+ _ -> dgettext("errors", "Could not vote")
+ end
+ end
defp validate_existing_votes(%{ap_id: ap_id}, object) do
if Utils.get_existing_votes(ap_id, object) == [] do
diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_c2s_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_c2s_test.exs
index 067550f08..09b9d2cff 100644
--- a/test/pleroma/web/activity_pub/activity_pub_controller_c2s_test.exs
+++ b/test/pleroma/web/activity_pub/activity_pub_controller_c2s_test.exs
@@ -72,4 +72,69 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerC2STest do
Object.get_by_ap_id(id).data["reactions"]
end
end
+
+ describe "Answer (poll votes)" do
+ setup do
+ author = insert(:user)
+
+ {:ok, poll} =
+ CommonAPI.post(author, %{status: "?", poll: %{options: ["a", "b"], expires_in: 600}})
+
+ %{author: author, poll_id: poll.data["object"]}
+ end
+
+ defp vote(poll_id, author, name) do
+ %{
+ "type" => "Create",
+ "object" => %{
+ "type" => "Answer",
+ "name" => name,
+ "inReplyTo" => poll_id,
+ "to" => [author.ap_id],
+ "cc" => []
+ }
+ }
+ end
+
+ defp vote_counts(poll_id) do
+ Object.get_by_ap_id(poll_id).data["oneOf"]
+ |> Map.new(&{&1["name"], &1["replies"]["totalItems"]})
+ end
+
+ test "counts one vote per voter on a single-choice poll", ctx do
+ voter = insert(:user)
+ post_outbox(ctx.conn, voter, vote(ctx.poll_id, ctx.author, "a"), 201)
+ post_outbox(ctx.conn, voter, vote(ctx.poll_id, ctx.author, "a"), 400)
+ post_outbox(ctx.conn, voter, vote(ctx.poll_id, ctx.author, "b"), 400)
+
+ assert vote_counts(ctx.poll_id) == %{"a" => 1, "b" => 0}
+ end
+
+ test "rejects votes of the author, on unknown options and on closed polls", ctx do
+ post_outbox(ctx.conn, ctx.author, vote(ctx.poll_id, ctx.author, "a"), 400)
+ post_outbox(ctx.conn, insert(:user), vote(ctx.poll_id, ctx.author, "c"), 400)
+
+ object = Object.get_by_ap_id(ctx.poll_id)
+
+ object
+ |> Object.change(%{data: Map.put(object.data, "closed", "2000-01-01T00:00:00Z")})
+ |> Object.update_and_set_cache()
+
+ post_outbox(ctx.conn, insert(:user), vote(ctx.poll_id, ctx.author, "a"), 400)
+
+ assert vote_counts(ctx.poll_id) == %{"a" => 0, "b" => 0}
+ end
+
+ test "rejects votes on polls the voter cannot see", %{conn: conn, author: author} do
+ {:ok, poll} =
+ CommonAPI.post(author, %{
+ status: "?",
+ visibility: "private",
+ poll: %{options: ["a", "b"], expires_in: 600}
+ })
+
+ post_outbox(conn, insert(:user), vote(poll.data["object"], author, "a"), 400)
+ assert vote_counts(poll.data["object"]) == %{"a" => 0, "b" => 0}
+ end
+ end
end
diff --git a/test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs
index c141059ee..cc7e99686 100644
--- a/test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs
+++ b/test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs
@@ -142,6 +142,54 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.AnswerHandlingTest do
end
end
+ describe "incoming votes on local polls" do
+ setup do
+ user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{
+ status: "suya...",
+ poll: %{options: ["suya", "suya.", "suya.."], expires_in: 600}
+ })
+
+ vote =
+ File.read!("test/fixtures/mastodon-vote.json")
+ |> Jason.decode!()
+ |> put_in(["to"], user.ap_id)
+ |> put_in(["object", "to"], user.ap_id)
+ |> put_in(["object", "inReplyTo"], activity.data["object"])
+
+ %{poll_id: activity.data["object"], vote: vote}
+ end
+
+ defp second_vote(vote, name) do
+ vote
+ |> put_in(["id"], vote["id"] <> "/2")
+ |> put_in(["object", "id"], vote["object"]["id"] <> "/2")
+ |> put_in(["object", "name"], name)
+ end
+
+ test "rejects a second vote on a single-choice poll", %{poll_id: poll_id, vote: vote} do
+ {:ok, _} = Transmogrifier.handle_incoming(vote)
+ assert {:error, _} = Transmogrifier.handle_incoming(second_vote(vote, "suya"))
+
+ assert [0, 0, 1] ==
+ Enum.map(Object.get_by_ap_id(poll_id).data["oneOf"], & &1["replies"]["totalItems"])
+ end
+
+ test "rejects votes for unknown options and on closed polls", %{poll_id: poll_id, vote: vote} do
+ assert {:error, _} = Transmogrifier.handle_incoming(put_in(vote, ["object", "name"], "x"))
+
+ object = Object.get_by_ap_id(poll_id)
+
+ object
+ |> Object.change(%{data: Map.put(object.data, "closed", "2000-01-01T00:00:00Z")})
+ |> Object.update_and_set_cache()
+
+ assert {:error, _} = Transmogrifier.handle_incoming(vote)
+ end
+ end
+
test "outgoing, rewrites Answer to Note" do
user = insert(:user)
diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs
index 6b1417916..3b13db432 100644
--- a/test/pleroma/web/common_api_test.exs
+++ b/test/pleroma/web/common_api_test.exs
@@ -1866,6 +1866,24 @@ defmodule Pleroma.Web.CommonAPITest do
assert {:error, "Already voted"} == CommonAPI.vote(object, other_user, [1])
end
+
+ test "maps validator errors to readable messages" do
+ user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{status: "?", poll: %{options: ["Yes", "No"], expires_in: 20}})
+
+ object = Object.normalize(activity, fetch: false)
+
+ assert {:error, "Poll's author can't vote"} == CommonAPI.vote(object, user, [0])
+
+ {:ok, object} =
+ object
+ |> Object.change(%{data: Map.put(object.data, "closed", "2000-01-01T00:00:00Z")})
+ |> Object.update_and_set_cache()
+
+ assert {:error, "Poll has already ended"} == CommonAPI.vote(object, insert(:user), [0])
+ end
end
describe "listen/2" do
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Nov 1, 12:50 AM (17 h, 26 s)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801994
Default Alt Text
(13 KB)
Attached To
Mode
rPUBE pleroma-upstream
Attached
Detach File
Event Timeline