[#1041] Rate-limited status actions (per user and per user+status).
authorIvan Tashkinov <ivant.business@gmail.com>
Sat, 13 Jul 2019 11:49:39 +0000 (14:49 +0300)
committerIvan Tashkinov <ivant.business@gmail.com>
Sat, 13 Jul 2019 11:49:39 +0000 (14:49 +0300)
CHANGELOG.md
config/config.exs
lib/pleroma/plugs/rate_limiter.ex
lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
test/plugs/rate_limiter_test.exs

index 405b7680c860d3489eebc1007478d8e579d837a1..c6c1ba1f9bc25516d2685bbd3aaaf50a248ce887 100644 (file)
@@ -21,7 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 ### Added
 - MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`)
-Configuration: `federation_incoming_replies_max_depth` option
+Configuration: `federation_incoming_replies_max_depth` option
 - Mastodon API: Support for the [`tagged` filter](https://github.com/tootsuite/mastodon/pull/9755) in [`GET /api/v1/accounts/:id/statuses`](https://docs.joinmastodon.org/api/rest/accounts/#get-api-v1-accounts-id-statuses)
 - Mastodon API, streaming: Add support for passing the token in the `Sec-WebSocket-Protocol` header
 - Mastodon API, extension: Ability to reset avatar, profile banner, and background
@@ -32,6 +32,7 @@ Configuration: `federation_incoming_replies_max_depth` option
 - Added synchronization of following/followers counters for external users
 - Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`.
 - Mastodon API: Add support for categories for custom emojis by reusing the group feature. <https://github.com/tootsuite/mastodon/pull/11196>
+- Configuration: Pleroma.Plugs.RateLimiter `bucket_name`, `params` options.
 
 ### Changed
 - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text
index 99b500993c7284c72723a5e284f1e12374bbc060..3d48a55841aa2f31a8b780c0351abdf281f0129f 100644 (file)
@@ -519,7 +519,9 @@ config :http_signatures,
 
 config :pleroma, :rate_limit,
   search: [{1000, 10}, {1000, 30}],
-  app_account_creation: {1_800_000, 25}
+  app_account_creation: {1_800_000, 25},
+  statuses_actions: {10_000, 15},
+  status_id_action: {60_000, 3}
 
 # Import environment specific config. This must remain at the bottom
 # of this file so it overrides the configuration defined above.
index c5e0957e8823b2369ed414c26f2a57348b26821d..31388f574c1bfa70c2b19171b50a763e672217fb 100644 (file)
@@ -31,12 +31,28 @@ defmodule Pleroma.Plugs.RateLimiter do
 
   ## Usage
 
+  AllowedSyntax:
+
+      plug(Pleroma.Plugs.RateLimiter, :limiter_name)
+      plug(Pleroma.Plugs.RateLimiter, {:limiter_name, options})
+
+  Allowed options:
+
+      * `bucket_name` overrides bucket name (e.g. to have a separate limit for a set of actions)
+      * `params` appends values of specified request params (e.g. ["id"]) to bucket name
+
   Inside a controller:
 
       plug(Pleroma.Plugs.RateLimiter, :one when action == :one)
       plug(Pleroma.Plugs.RateLimiter, :two when action in [:two, :three])
 
-  or inside a router pipiline:
+      plug(
+        Pleroma.Plugs.RateLimiter,
+        {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]}
+        when action in ~w(fav_status unfav_status)a
+      )
+
+  or inside a router pipeline:
 
       pipeline :api do
         ...
@@ -49,33 +65,56 @@ defmodule Pleroma.Plugs.RateLimiter do
 
   alias Pleroma.User
 
-  def init(limiter_name) do
+  def init(limiter_name) when is_atom(limiter_name) do
+    init({limiter_name, []})
+  end
+
+  def init({limiter_name, opts}) do
     case Pleroma.Config.get([:rate_limit, limiter_name]) do
       nil -> nil
-      config -> {limiter_name, config}
+      config -> {limiter_name, config, opts}
     end
   end
 
-  # do not limit if there is no limiter configuration
+  # Do not limit if there is no limiter configuration
   def call(conn, nil), do: conn
 
-  def call(conn, opts) do
-    case check_rate(conn, opts) do
-      {:ok, _count} -> conn
-      {:error, _count} -> render_throttled_error(conn)
+  def call(conn, settings) do
+    case check_rate(conn, settings) do
+      {:ok, _count} ->
+        conn
+
+      {:error, _count} ->
+        render_throttled_error(conn)
+    end
+  end
+
+  defp bucket_name(conn, limiter_name, opts) do
+    bucket_name = opts[:bucket_name] || limiter_name
+
+    if params_names = opts[:params] do
+      params_values = for p <- Enum.sort(params_names), do: conn.params[p]
+      Enum.join([bucket_name] ++ params_values, ":")
+    else
+      bucket_name
     end
   end
 
-  defp check_rate(%{assigns: %{user: %User{id: user_id}}}, {limiter_name, [_, {scale, limit}]}) do
-    ExRated.check_rate("#{limiter_name}:#{user_id}", scale, limit)
+  defp check_rate(
+         %{assigns: %{user: %User{id: user_id}}} = conn,
+         {limiter_name, [_, {scale, limit}], opts}
+       ) do
+    bucket_name = bucket_name(conn, limiter_name, opts)
+    ExRated.check_rate("#{bucket_name}:#{user_id}", scale, limit)
   end
 
-  defp check_rate(conn, {limiter_name, [{scale, limit} | _]}) do
-    ExRated.check_rate("#{limiter_name}:#{ip(conn)}", scale, limit)
+  defp check_rate(conn, {limiter_name, [{scale, limit} | _], opts}) do
+    bucket_name = bucket_name(conn, limiter_name, opts)
+    ExRated.check_rate("#{bucket_name}:#{ip(conn)}", scale, limit)
   end
 
-  defp check_rate(conn, {limiter_name, {scale, limit}}) do
-    check_rate(conn, {limiter_name, [{scale, limit}]})
+  defp check_rate(conn, {limiter_name, {scale, limit}, opts}) do
+    check_rate(conn, {limiter_name, [{scale, limit}, {scale, limit}], opts})
   end
 
   def ip(%{remote_ip: remote_ip}) do
index 8c2033c3ab433ad8650638842e7c3c1b0ce8f076..76648b9f7e126470074eab1b7bc465fc6aa51981 100644 (file)
@@ -15,6 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Pagination
+  alias Pleroma.Plugs.RateLimiter
   alias Pleroma.Repo
   alias Pleroma.ScheduledActivity
   alias Pleroma.Stats
@@ -46,8 +47,25 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
 
   require Logger
 
-  plug(Pleroma.Plugs.RateLimiter, :app_account_creation when action == :account_register)
-  plug(Pleroma.Plugs.RateLimiter, :search when action in [:search, :search2, :account_search])
+  @rate_limited_status_crud_actions ~w(post_status delete_status)a
+  @rate_limited_status_reactions ~w(reblog_status unreblog_status fav_status unfav_status)a
+  @rate_limited_status_actions @rate_limited_status_crud_actions ++ @rate_limited_status_reactions
+
+  plug(
+    RateLimiter,
+    {:status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]}
+    when action in ~w(reblog_status unreblog_status)a
+  )
+
+  plug(
+    RateLimiter,
+    {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]}
+    when action in ~w(fav_status unfav_status)a
+  )
+
+  plug(RateLimiter, :statuses_actions when action in @rate_limited_status_actions)
+  plug(RateLimiter, :app_account_creation when action == :account_register)
+  plug(RateLimiter, :search when action in [:search, :search2, :account_search])
 
   @local_mastodon_name "Mastodon-Local"
 
index f8251b5c78ff09c47c1148fd6eeb6ed30da439bb..395095079f1346b377af6e251a2ebab54cb3fdea 100644 (file)
@@ -10,12 +10,13 @@ defmodule Pleroma.Plugs.RateLimiterTest do
 
   import Pleroma.Factory
 
-  @limiter_name :testing
+  # Note: each example must work with separate buckets in order to prevent concurrency issues
 
   test "init/1" do
-    Pleroma.Config.put([:rate_limit, @limiter_name], {1, 1})
+    limiter_name = :test_init
+    Pleroma.Config.put([:rate_limit, limiter_name], {1, 1})
 
-    assert {@limiter_name, {1, 1}} == RateLimiter.init(@limiter_name)
+    assert {limiter_name, {1, 1}, []} == RateLimiter.init(limiter_name)
     assert nil == RateLimiter.init(:foo)
   end
 
@@ -24,14 +25,15 @@ defmodule Pleroma.Plugs.RateLimiterTest do
   end
 
   test "it restricts by opts" do
+    limiter_name = :test_opts
     scale = 1000
     limit = 5
 
-    Pleroma.Config.put([:rate_limit, @limiter_name], {scale, limit})
+    Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
 
-    opts = RateLimiter.init(@limiter_name)
+    opts = RateLimiter.init(limiter_name)
     conn = conn(:get, "/")
-    bucket_name = "#{@limiter_name}:#{RateLimiter.ip(conn)}"
+    bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
 
     conn = RateLimiter.call(conn, opts)
     assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
@@ -65,18 +67,78 @@ defmodule Pleroma.Plugs.RateLimiterTest do
     refute conn.halted
   end
 
+  test "`bucket_name` option overrides default bucket name" do
+    limiter_name = :test_bucket_name
+    scale = 1000
+    limit = 5
+
+    Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
+    base_bucket_name = "#{limiter_name}:group1"
+    opts = RateLimiter.init({limiter_name, bucket_name: base_bucket_name})
+
+    conn = conn(:get, "/")
+    default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
+    customized_bucket_name = "#{base_bucket_name}:#{RateLimiter.ip(conn)}"
+
+    RateLimiter.call(conn, opts)
+    assert {1, 4, _, _, _} = ExRated.inspect_bucket(customized_bucket_name, scale, limit)
+    assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
+  end
+
+  test "`params` option appends specified params' values to bucket name" do
+    limiter_name = :test_params
+    scale = 1000
+    limit = 5
+
+    Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
+    opts = RateLimiter.init({limiter_name, params: ["id"]})
+    id = "1"
+
+    conn = conn(:get, "/?id=#{id}")
+    conn = Plug.Conn.fetch_query_params(conn)
+
+    default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
+    parametrized_bucket_name = "#{limiter_name}:#{id}:#{RateLimiter.ip(conn)}"
+
+    RateLimiter.call(conn, opts)
+    assert {1, 4, _, _, _} = ExRated.inspect_bucket(parametrized_bucket_name, scale, limit)
+    assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
+  end
+
+  test "it supports combination of options modifying bucket name" do
+    limiter_name = :test_options_combo
+    scale = 1000
+    limit = 5
+
+    Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
+    base_bucket_name = "#{limiter_name}:group1"
+    opts = RateLimiter.init({limiter_name, bucket_name: base_bucket_name, params: ["id"]})
+    id = "100"
+
+    conn = conn(:get, "/?id=#{id}")
+    conn = Plug.Conn.fetch_query_params(conn)
+
+    default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
+    parametrized_bucket_name = "#{base_bucket_name}:#{id}:#{RateLimiter.ip(conn)}"
+
+    RateLimiter.call(conn, opts)
+    assert {1, 4, _, _, _} = ExRated.inspect_bucket(parametrized_bucket_name, scale, limit)
+    assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
+  end
+
   test "optional limits for authenticated users" do
+    limiter_name = :test_authenticated
     Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo)
 
     scale = 1000
     limit = 5
-    Pleroma.Config.put([:rate_limit, @limiter_name], [{1, 10}, {scale, limit}])
+    Pleroma.Config.put([:rate_limit, limiter_name], [{1, 10}, {scale, limit}])
 
-    opts = RateLimiter.init(@limiter_name)
+    opts = RateLimiter.init(limiter_name)
 
     user = insert(:user)
     conn = conn(:get, "/") |> assign(:user, user)
-    bucket_name = "#{@limiter_name}:#{user.id}"
+    bucket_name = "#{limiter_name}:#{user.id}"
 
     conn = RateLimiter.call(conn, opts)
     assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)