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 | ### Added | ||||
- MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`) | - 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: 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, streaming: Add support for passing the token in the `Sec-WebSocket-Protocol` header | ||||
- Mastodon API, extension: Ability to reset avatar, profile banner, and background | - 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 | - Added synchronization of following/followers counters for external users | ||||
- Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`. | - 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> | - 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 | ### Changed | ||||
- Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text | - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text | ||||
@@ -521,7 +521,9 @@ config :http_signatures, | |||||
config :pleroma, :rate_limit, | config :pleroma, :rate_limit, | ||||
search: [{1000, 10}, {1000, 30}], | 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 | # Import environment specific config. This must remain at the bottom | ||||
# of this file so it overrides the configuration defined above. | # 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. | 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. | 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 | ## 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: | Inside a controller: | ||||
plug(Pleroma.Plugs.RateLimiter, :one when action == :one) | plug(Pleroma.Plugs.RateLimiter, :one when action == :one) | ||||
plug(Pleroma.Plugs.RateLimiter, :two when action in [:two, :three]) | 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 | pipeline :api do | ||||
... | ... | ||||
@@ -49,33 +65,56 @@ defmodule Pleroma.Plugs.RateLimiter do | |||||
alias Pleroma.User | 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 | case Pleroma.Config.get([:rate_limit, limiter_name]) do | ||||
nil -> nil | nil -> nil | ||||
config -> {limiter_name, config} | |||||
config -> {limiter_name, config, opts} | |||||
end | end | ||||
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, 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 | ||||
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 | 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 | 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 | end | ||||
def ip(%{remote_ip: remote_ip}) do | def ip(%{remote_ip: remote_ip}) do | ||||
@@ -15,6 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||||
alias Pleroma.Notification | alias Pleroma.Notification | ||||
alias Pleroma.Object | alias Pleroma.Object | ||||
alias Pleroma.Pagination | alias Pleroma.Pagination | ||||
alias Pleroma.Plugs.RateLimiter | |||||
alias Pleroma.Repo | alias Pleroma.Repo | ||||
alias Pleroma.ScheduledActivity | alias Pleroma.ScheduledActivity | ||||
alias Pleroma.Stats | alias Pleroma.Stats | ||||
@@ -46,8 +47,24 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||||
require Logger | 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" | @local_mastodon_name "Mastodon-Local" | ||||
@@ -10,12 +10,13 @@ defmodule Pleroma.Plugs.RateLimiterTest do | |||||
import Pleroma.Factory | import Pleroma.Factory | ||||
@limiter_name :testing | |||||
# Note: each example must work with separate buckets in order to prevent concurrency issues | |||||
test "init/1" do | 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) | assert nil == RateLimiter.init(:foo) | ||||
end | end | ||||
@@ -24,14 +25,15 @@ defmodule Pleroma.Plugs.RateLimiterTest do | |||||
end | end | ||||
test "it restricts by opts" do | test "it restricts by opts" do | ||||
limiter_name = :test_opts | |||||
scale = 1000 | scale = 1000 | ||||
limit = 5 | 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, "/") | conn = conn(:get, "/") | ||||
bucket_name = "#{@limiter_name}:#{RateLimiter.ip(conn)}" | |||||
bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}" | |||||
conn = RateLimiter.call(conn, opts) | conn = RateLimiter.call(conn, opts) | ||||
assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit) | assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit) | ||||
@@ -65,18 +67,78 @@ defmodule Pleroma.Plugs.RateLimiterTest do | |||||
refute conn.halted | refute conn.halted | ||||
end | 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 | test "optional limits for authenticated users" do | ||||
limiter_name = :test_authenticated | |||||
Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo) | Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo) | ||||
scale = 1000 | scale = 1000 | ||||
limit = 5 | 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) | user = insert(:user) | ||||
conn = conn(:get, "/") |> assign(:user, user) | conn = conn(:get, "/") |> assign(:user, user) | ||||
bucket_name = "#{@limiter_name}:#{user.id}" | |||||
bucket_name = "#{limiter_name}:#{user.id}" | |||||
conn = RateLimiter.call(conn, opts) | conn = RateLimiter.call(conn, opts) | ||||
assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit) | assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit) | ||||