Commit 369e9bb4 authored by Ivan Tashkinov's avatar Ivan Tashkinov

[#1041] Rate-limited status actions (per user and per user+status).

parent 02cdedbf
......@@ -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
......
......@@ -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.
......
......@@ -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
......
......@@ -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"
......
......@@ -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)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment