Rate-limited status actions (per user and per user+status). Closes #1041 See merge request pleroma/pleroma!1410tags/v1.1.4
@@ -22,7 +22,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 | |||
@@ -33,6 +33,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 | |||
@@ -521,7 +521,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. | |||
@@ -640,3 +640,10 @@ A keyword list of rate limiters where a key is a limiter name and value is the l | |||
It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated. | |||
See [`Pleroma.Plugs.RateLimiter`](Pleroma.Plugs.RateLimiter.html) documentation for examples. | |||
Supported rate limiters: | |||
* `:search` for the search requests (account & status search etc.) | |||
* `:app_account_creation` for registering user accounts from the same IP address | |||
* `:statuses_actions` for create / delete / fav / unfav / reblog / unreblog actions on any statuses | |||
* `:status_id_action` for fav / unfav or reblog / unreblog actions on the same status by the same user |
@@ -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,24 @@ 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_actions ~w(reblog_status unreblog_status fav_status unfav_status | |||
post_status delete_status)a | |||
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) | |||