Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85829832
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 e85bc09cd..a77275e41 100644
--- a/changelog.d/c2s-hardening.security
+++ b/changelog.d/c2s-hardening.security
@@ -2,3 +2,4 @@ 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
Only allow pinning own posts, and only public or unlisted ones for local users; the featured collection skips non-public posts
Validate the object of local Updates; the server builds the edit history and edit time and keeps poll results
+Hold new local polls to the poll limits and reject preset vote counts
diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex
index 3c9cf38e3..a522231ec 100644
--- a/lib/pleroma/web/activity_pub/object_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validator.ex
@@ -301,7 +301,18 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
defp validate_new_object(%{"type" => "Answer"} = answer, _meta),
do: AnswerValidator.validate_vote(answer)
- defp validate_new_object(_object, _meta), do: :ok
+ defp validate_new_object(object, meta) do
+ if Keyword.get(meta, :local, false) do
+ validate_new_local_object(object)
+ else
+ :ok
+ end
+ end
+
+ defp validate_new_local_object(%{"type" => "Question"} = question),
+ do: QuestionValidator.validate_local_poll(question)
+
+ defp validate_new_local_object(_object), do: :ok
# The server owns the edit history, the edit time and the poll results of
# local objects. Only the editable fields come from the new version.
diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex
index 91ec9ee6f..0116aa347 100644
--- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex
@@ -92,4 +92,77 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do
|> CommonValidations.validate_any_presence([:oneOf, :anyOf])
|> CommonValidations.validate_host_match()
end
+
+ @doc """
+ Checks a new local poll: `[:instance, :poll_limits]` hold and nobody has
+ voted yet. Remote polls follow the limits of their own server.
+ """
+ @spec validate_local_poll(map()) :: :ok | {:error, String.t()}
+ def validate_local_poll(%{"type" => "Question"} = data) do
+ options = List.wrap(data["oneOf"]) ++ List.wrap(data["anyOf"])
+ names = Enum.map(options, & &1["name"])
+
+ with :ok <- validate_poll_options(names),
+ {_, true} <- {:unique, Enum.uniq(names) == names},
+ # The end time was computed a moment before validation.
+ :ok <- validate_poll_expiration(seconds_until(data["closed"]), 60),
+ {_, true} <- {:no_votes, no_votes?(data, options)} do
+ :ok
+ else
+ {:unique, _} -> {:error, "Poll options must be unique"}
+ {:no_votes, _} -> {:error, "New polls can't have votes"}
+ error -> error
+ end
+ end
+
+ @spec validate_poll_options([String.t()]) :: :ok | {:error, String.t()}
+ def validate_poll_options(options) do
+ %{max_options: max_options, max_option_chars: max_option_chars} = poll_limits()
+
+ cond do
+ Enum.count(options) < 2 ->
+ {:error, "Poll must contain at least 2 options"}
+
+ Enum.count(options) > max_options ->
+ {:error, "Poll can't contain more than #{max_options} options"}
+
+ Enum.any?(options, &(String.length(&1) > max_option_chars)) ->
+ {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
+
+ true ->
+ :ok
+ end
+ end
+
+ @spec validate_poll_expiration(integer() | nil, non_neg_integer()) ::
+ :ok | {:error, String.t()}
+ def validate_poll_expiration(expires_in, slack \\ 0)
+
+ def validate_poll_expiration(nil, _slack), do: {:error, "Poll must have an end time"}
+
+ def validate_poll_expiration(expires_in, slack) do
+ %{min_expiration: min, max_expiration: max} = poll_limits()
+
+ cond do
+ expires_in > max -> {:error, "Expiration date is too far in the future"}
+ expires_in < min - slack -> {:error, "Expiration date is too soon"}
+ true -> :ok
+ end
+ end
+
+ defp poll_limits, do: Pleroma.Config.get([:instance, :poll_limits])
+
+ defp seconds_until(closed) when is_binary(closed) do
+ case DateTime.from_iso8601(closed) do
+ {:ok, closed, _} -> DateTime.diff(closed, DateTime.utc_now())
+ _ -> nil
+ end
+ end
+
+ defp seconds_until(_), do: nil
+
+ defp no_votes?(data, options) do
+ data["votersCount"] in [nil, 0] and data["voters"] in [nil, []] and
+ Enum.all?(options, &(get_in(&1, ["replies", "totalItems"]) in [nil, 0]))
+ end
end
diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex
index 78c6b4024..26a5d6718 100644
--- a/lib/pleroma/web/common_api/utils.ex
+++ b/lib/pleroma/web/common_api/utils.ex
@@ -13,6 +13,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI.ActivityDraft
@@ -156,13 +157,10 @@ defmodule Pleroma.Web.CommonAPI.Utils do
def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
when is_list(options) do
- limits = Config.get([:instance, :poll_limits])
-
options = options |> Enum.uniq()
- with :ok <- validate_poll_expiration(expires_in, limits),
- :ok <- validate_poll_options_amount(options, limits),
- :ok <- validate_poll_options_length(options, limits) do
+ with :ok <- QuestionValidator.validate_poll_expiration(expires_in),
+ :ok <- QuestionValidator.validate_poll_options(options) do
{option_notes, emoji} =
Enum.map_reduce(options, %{}, fn option, emoji ->
note = %{
@@ -194,35 +192,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do
{:ok, {%{}, %{}}}
end
- defp validate_poll_options_amount(options, %{max_options: max_options}) do
- cond do
- Enum.count(options) < 2 ->
- {:error, "Poll must contain at least 2 options"}
-
- Enum.count(options) > max_options ->
- {:error, "Poll can't contain more than #{max_options} options"}
-
- true ->
- :ok
- end
- end
-
- defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
- if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
- {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
- else
- :ok
- end
- end
-
- defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
- cond do
- expires_in > max -> {:error, "Expiration date is too far in the future"}
- expires_in < min -> {:error, "Expiration date is too soon"}
- true -> :ok
- end
- end
-
def make_content_html(%ActivityDraft{} = draft) do
attachment_links =
draft.params
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 e84942303..b5dc0f220 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
@@ -255,4 +255,72 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerC2STest do
assert Enum.all?(stored["oneOf"], &(&1["replies"]["totalItems"] == 0))
end
end
+
+ describe "Create of a Question" do
+ setup do
+ clear_config([:instance, :poll_limits], %{
+ max_options: 4,
+ max_option_chars: 20,
+ min_expiration: 60,
+ max_expiration: 86_400
+ })
+ end
+
+ defp question(extra) do
+ closed = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.to_iso8601()
+
+ object =
+ Map.merge(
+ %{
+ "type" => "Question",
+ "content" => "?",
+ "to" => [@public],
+ "cc" => [],
+ "closed" => closed,
+ "oneOf" => [option("a"), option("b")]
+ },
+ extra
+ )
+
+ %{"type" => "Create", "object" => object}
+ end
+
+ defp option(name, votes \\ 0),
+ do: %{
+ "type" => "Note",
+ "name" => name,
+ "replies" => %{"type" => "Collection", "totalItems" => votes}
+ }
+
+ test "accepts a poll within the limits", %{conn: conn} do
+ result = post_outbox(conn, insert(:user), question(%{}), 201)
+
+ assert %Object{data: %{"type" => "Question"}} =
+ Object.normalize(result["object"], fetch: false)
+ end
+
+ test "rejects polls over the limits", %{conn: conn} do
+ far = DateTime.utc_now() |> DateTime.add(86_400 * 365) |> DateTime.to_iso8601()
+
+ for extra <- [
+ %{"oneOf" => Enum.map(1..5, &option("opt#{&1}"))},
+ %{"oneOf" => [option("a"), option(String.duplicate("b", 21))]},
+ %{"oneOf" => [option("a")]},
+ %{"oneOf" => [option("a"), option("a")]},
+ %{"closed" => far},
+ %{"closed" => "2000-01-01T00:00:00Z"}
+ ] do
+ post_outbox(conn, insert(:user), question(extra), 400)
+ end
+ end
+
+ test "rejects polls with votes", %{conn: conn} do
+ for extra <- [
+ %{"oneOf" => [option("a", 1000), option("b")]},
+ %{"votersCount" => 12_345}
+ ] do
+ post_outbox(conn, insert(:user), question(extra), 400)
+ end
+ end
+ end
end
diff --git a/test/pleroma/web/activity_pub/object_validators/question_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/question_validation_test.exs
new file mode 100644
index 000000000..f7336add4
--- /dev/null
+++ b/test/pleroma/web/activity_pub/object_validators/question_validation_test.exs
@@ -0,0 +1,102 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidationTest do
+ use Pleroma.DataCase, async: true
+
+ alias Pleroma.Web.ActivityPub.ObjectValidator
+ alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator
+ alias Pleroma.Web.ActivityPub.Utils
+
+ import Pleroma.Factory
+
+ require Pleroma.Constants
+
+ setup do
+ clear_config([:instance, :poll_limits], %{
+ max_options: 4,
+ max_option_chars: 20,
+ min_expiration: 60,
+ max_expiration: 86_400
+ })
+ end
+
+ defp option(name, votes \\ 0),
+ do: %{"type" => "Note", "name" => name, "replies" => %{"totalItems" => votes}}
+
+ defp in_seconds(seconds),
+ do: DateTime.utc_now() |> DateTime.add(seconds) |> DateTime.to_iso8601()
+
+ defp question(extra) do
+ Map.merge(
+ %{"type" => "Question", "oneOf" => [option("a"), option("b")], "closed" => in_seconds(600)},
+ extra
+ )
+ end
+
+ describe "validate_local_poll/1" do
+ test "accepts a poll within the limits" do
+ assert :ok = QuestionValidator.validate_local_poll(question(%{}))
+
+ assert :ok =
+ QuestionValidator.validate_local_poll(
+ question(%{"oneOf" => nil, "anyOf" => [option("a"), option("b")]})
+ )
+ end
+
+ test "rejects polls with votes" do
+ for extra <- [
+ %{"oneOf" => [option("a", 1), option("b")]},
+ %{"votersCount" => 3},
+ %{"voters" => ["https://remote.example/users/x"]}
+ ] do
+ assert {:error, "New polls can't have votes"} =
+ QuestionValidator.validate_local_poll(question(extra))
+ end
+ end
+
+ test "rejects polls over the limits or without an end time" do
+ for extra <- [
+ %{"oneOf" => Enum.map(1..5, &option("#{&1}"))},
+ %{"oneOf" => [option("a"), option(String.duplicate("b", 21))]},
+ %{"oneOf" => [option("a"), option("a")]},
+ %{"closed" => in_seconds(86_400 * 2)},
+ %{"closed" => in_seconds(-10)},
+ %{"closed" => nil}
+ ] do
+ assert {:error, _} = QuestionValidator.validate_local_poll(question(extra))
+ end
+ end
+ end
+
+ test "remote polls are not held to the local limits" do
+ user = insert(:user, local: false, ap_id: "https://remote.example/users/pollster")
+ id = "https://remote.example/objects/#{Ecto.UUID.generate()}"
+
+ object =
+ question(%{
+ "id" => id,
+ "actor" => user.ap_id,
+ "attributedTo" => user.ap_id,
+ "to" => [Pleroma.Constants.as_public()],
+ "cc" => [],
+ "context" => Utils.generate_context_id(),
+ "content" => "?",
+ "oneOf" => Enum.map(1..10, &option("#{&1}", 5)),
+ "votersCount" => 50,
+ "closed" => in_seconds(86_400 * 30)
+ })
+
+ create = %{
+ "id" => id <> "/activity",
+ "type" => "Create",
+ "actor" => user.ap_id,
+ "to" => object["to"],
+ "cc" => [],
+ "object" => object
+ }
+
+ assert {:ok, _, _} = ObjectValidator.validate(create, local: false)
+ end
+end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Nov 1, 12:50 AM (17 h, 53 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801997
Default Alt Text
(13 KB)
Attached To
Mode
rPUBE pleroma-upstream
Attached
Detach File
Event Timeline