Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
13 KB
Referenced Files
None
Subscribers
None
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

Mime Type
text/x-diff
Expires
Sun, Nov 1, 12:50 AM (17 h, 48 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801994
Default Alt Text
(13 KB)

Event Timeline