[#1041] Rate-limited status actions (per user and per user+status).
This commit is contained in:
parent
02cdedbf9f
commit
369e9bb42f
|
@ -21,7 +21,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
|
||||||
|
@ -32,6 +32,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
|
||||||
|
|
|
@ -519,7 +519,9 @@
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
@ -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
|
def call(conn, settings) do
|
||||||
case check_rate(conn, opts) do
|
case check_rate(conn, settings) do
|
||||||
{:ok, _count} -> conn
|
{:ok, _count} ->
|
||||||
{:error, _count} -> render_throttled_error(conn)
|
conn
|
||||||
|
|
||||||
|
{:error, _count} ->
|
||||||
|
render_throttled_error(conn)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp check_rate(%{assigns: %{user: %User{id: user_id}}}, {limiter_name, [_, {scale, limit}]}) do
|
defp bucket_name(conn, limiter_name, opts) do
|
||||||
ExRated.check_rate("#{limiter_name}:#{user_id}", scale, limit)
|
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
|
||||||
|
|
||||||
defp check_rate(conn, {limiter_name, [{scale, limit} | _]}) do
|
defp check_rate(
|
||||||
ExRated.check_rate("#{limiter_name}:#{ip(conn)}", scale, limit)
|
%{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
|
defp check_rate(conn, {limiter_name, [{scale, limit} | _], opts}) do
|
||||||
check_rate(conn, {limiter_name, [{scale, limit}]})
|
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}, 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,25 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
plug(Pleroma.Plugs.RateLimiter, :app_account_creation when action == :account_register)
|
@rate_limited_status_crud_actions ~w(post_status delete_status)a
|
||||||
plug(Pleroma.Plugs.RateLimiter, :search when action in [:search, :search2, :account_search])
|
@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"
|
@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 @@ test "ip/1" 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 @@ test "it restricts by opts" 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)
|
||||||
|
|
Loading…
Reference in a new issue