98cb255d12
OTP builds to 1.15 Changelog entry Ensure policies are fully loaded Fix :warn use main branch for linkify Fix warn in tests Migrations for phoenix 1.17 Revert "Migrations for phoenix 1.17" This reverts commit 6a3b2f15b74ea5e33150529385215b7a531f3999. Oban upgrade Add default empty whitelist mix format limit test to amd64 OTP 26 tests for 1.15 use OTP_VERSION tag baka just 1.15 Massive deps update Update locale, deps Mix format shell???? multiline??? ? max cases 1 use assert_recieve don't put_env in async tests don't async conn/fs tests mix format FIx some uploader issues Fix tests
275 lines
7.6 KiB
Elixir
275 lines
7.6 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
defmodule Pleroma.Web.Plugs.RateLimiter do
|
|
@moduledoc """
|
|
|
|
## Configuration
|
|
|
|
A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration.
|
|
The basic configuration is a tuple where:
|
|
|
|
* The first element: `scale` (Integer). The time scale in milliseconds.
|
|
* The second element: `limit` (Integer). How many requests to limit in the time scale provided.
|
|
|
|
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.
|
|
|
|
To disable a limiter set its value to `nil`.
|
|
|
|
### Example
|
|
|
|
config :pleroma, :rate_limit,
|
|
one: {1000, 10},
|
|
two: [{10_000, 10}, {10_000, 50}],
|
|
foobar: nil
|
|
|
|
Here we have three limiters:
|
|
|
|
* `one` which is not over 10req/1s
|
|
* `two` which has two limits: 10req/10s for unauthenticated users and 50req/10s for authenticated users
|
|
* `foobar` which is disabled
|
|
|
|
## Usage
|
|
|
|
AllowedSyntax:
|
|
|
|
plug(Pleroma.Web.Plugs.RateLimiter, name: :limiter_name)
|
|
plug(Pleroma.Web.Plugs.RateLimiter, options) # :name is a required option
|
|
|
|
Allowed options:
|
|
|
|
* `name` required, always used to fetch the limit values from the config
|
|
* `bucket_name` overrides name for counting purposes (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.Web.Plugs.RateLimiter, [name: :one] when action == :one)
|
|
plug(Pleroma.Web.Plugs.RateLimiter, [name: :two] when action in [:two, :three])
|
|
|
|
plug(
|
|
Pleroma.Web.Plugs.RateLimiter,
|
|
[name: :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
|
|
...
|
|
plug(Pleroma.Web.Plugs.RateLimiter, name: :one)
|
|
...
|
|
end
|
|
"""
|
|
import Pleroma.Web.TranslationHelpers
|
|
import Plug.Conn
|
|
|
|
alias Pleroma.Config
|
|
alias Pleroma.User
|
|
alias Pleroma.Web.Plugs.RateLimiter.LimiterSupervisor
|
|
|
|
require Logger
|
|
|
|
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
|
|
|
|
@doc false
|
|
def init(plug_opts) do
|
|
plug_opts
|
|
end
|
|
|
|
def call(conn, plug_opts) do
|
|
if disabled?(conn) do
|
|
handle_disabled(conn)
|
|
else
|
|
action_settings = action_settings(plug_opts)
|
|
handle(conn, action_settings)
|
|
end
|
|
end
|
|
|
|
defp handle_disabled(conn) do
|
|
Logger.warning(
|
|
"Rate limiter disabled due to forwarded IP not being found. Please ensure your reverse proxy is providing the X-Forwarded-For header or disable the RemoteIP plug/rate limiter."
|
|
)
|
|
|
|
conn
|
|
end
|
|
|
|
defp handle(conn, nil), do: conn
|
|
|
|
defp handle(conn, action_settings) do
|
|
action_settings
|
|
|> incorporate_conn_info(conn)
|
|
|> check_rate()
|
|
|> case do
|
|
{:ok, _count} ->
|
|
conn
|
|
|
|
{:error, _count} ->
|
|
render_throttled_error(conn)
|
|
end
|
|
end
|
|
|
|
def disabled?(conn) do
|
|
if Map.has_key?(conn.assigns, :remote_ip_found),
|
|
do: !conn.assigns.remote_ip_found,
|
|
else: false
|
|
end
|
|
|
|
@inspect_bucket_not_found {:error, :not_found}
|
|
|
|
def inspect_bucket(conn, bucket_name_root, plug_opts) do
|
|
with %{name: _} = action_settings <- action_settings(plug_opts) do
|
|
action_settings = incorporate_conn_info(action_settings, conn)
|
|
bucket_name = make_bucket_name(%{action_settings | name: bucket_name_root})
|
|
key_name = make_key_name(action_settings)
|
|
limit = get_limits(action_settings)
|
|
|
|
case @cachex.get(bucket_name, key_name) do
|
|
{:error, :no_cache} ->
|
|
@inspect_bucket_not_found
|
|
|
|
{:ok, nil} ->
|
|
{0, limit}
|
|
|
|
{:ok, value} ->
|
|
{value, limit - value}
|
|
end
|
|
else
|
|
_ -> @inspect_bucket_not_found
|
|
end
|
|
end
|
|
|
|
def action_settings(plug_opts) do
|
|
with limiter_name when is_atom(limiter_name) <- plug_opts[:name],
|
|
limits when not is_nil(limits) <- Config.get([:rate_limit, limiter_name]) do
|
|
bucket_name_root = Keyword.get(plug_opts, :bucket_name, limiter_name)
|
|
|
|
%{
|
|
name: bucket_name_root,
|
|
limits: limits,
|
|
opts: plug_opts
|
|
}
|
|
end
|
|
end
|
|
|
|
defp check_rate(action_settings) do
|
|
bucket_name = make_bucket_name(action_settings)
|
|
key_name = make_key_name(action_settings)
|
|
limit = get_limits(action_settings)
|
|
|
|
case @cachex.get_and_update(bucket_name, key_name, &increment_value(&1, limit)) do
|
|
{:commit, value} ->
|
|
{:ok, value}
|
|
|
|
{:ignore, value} ->
|
|
{:error, value}
|
|
|
|
{:error, :no_cache} ->
|
|
initialize_buckets!(action_settings)
|
|
check_rate(action_settings)
|
|
end
|
|
end
|
|
|
|
defp increment_value(nil, _limit), do: {:commit, 1}
|
|
|
|
defp increment_value(val, limit) when val >= limit, do: {:ignore, val}
|
|
|
|
defp increment_value(val, _limit), do: {:commit, val + 1}
|
|
|
|
defp incorporate_conn_info(action_settings, %{
|
|
assigns: %{user: %User{id: user_id}},
|
|
params: params
|
|
}) do
|
|
Map.merge(action_settings, %{
|
|
mode: :user,
|
|
conn_params: params,
|
|
conn_info: "#{user_id}"
|
|
})
|
|
end
|
|
|
|
defp incorporate_conn_info(action_settings, %{params: params} = conn) do
|
|
Map.merge(action_settings, %{
|
|
mode: :anon,
|
|
conn_params: params,
|
|
conn_info: "#{ip(conn)}"
|
|
})
|
|
end
|
|
|
|
defp ip(%{remote_ip: remote_ip}) when is_binary(remote_ip) do
|
|
remote_ip
|
|
end
|
|
|
|
defp ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do
|
|
remote_ip
|
|
|> Tuple.to_list()
|
|
|> Enum.join(".")
|
|
end
|
|
|
|
defp ip(_), do: nil
|
|
|
|
defp render_throttled_error(conn) do
|
|
conn
|
|
|> render_error(:too_many_requests, "Throttled")
|
|
|> halt()
|
|
end
|
|
|
|
defp make_key_name(action_settings) do
|
|
""
|
|
|> attach_selected_params(action_settings)
|
|
|> attach_identity(action_settings)
|
|
end
|
|
|
|
defp get_scale(_, {scale, _}), do: scale
|
|
|
|
defp get_scale(:anon, [{scale, _}, {_, _}]), do: scale
|
|
|
|
defp get_scale(:user, [{_, _}, {scale, _}]), do: scale
|
|
|
|
defp get_limits(%{limits: {_scale, limit}}), do: limit
|
|
|
|
defp get_limits(%{mode: :user, limits: [_, {_, limit}]}), do: limit
|
|
|
|
defp get_limits(%{limits: [{_, limit}, _]}), do: limit
|
|
|
|
defp make_bucket_name(%{mode: :user, name: bucket_name_root}),
|
|
do: user_bucket_name(bucket_name_root)
|
|
|
|
defp make_bucket_name(%{mode: :anon, name: bucket_name_root}),
|
|
do: anon_bucket_name(bucket_name_root)
|
|
|
|
defp attach_selected_params(input, %{conn_params: conn_params, opts: plug_opts}) do
|
|
params_string =
|
|
plug_opts
|
|
|> Keyword.get(:params, [])
|
|
|> Enum.sort()
|
|
|> Enum.map(&Map.get(conn_params, &1, ""))
|
|
|> Enum.join(":")
|
|
|
|
[input, params_string]
|
|
|> Enum.join(":")
|
|
|> String.replace_leading(":", "")
|
|
end
|
|
|
|
defp initialize_buckets!(%{name: _name, limits: nil}), do: :ok
|
|
|
|
defp initialize_buckets!(%{name: name, limits: limits}) do
|
|
{:ok, _pid} =
|
|
LimiterSupervisor.add_or_return_limiter(anon_bucket_name(name), get_scale(:anon, limits))
|
|
|
|
{:ok, _pid} =
|
|
LimiterSupervisor.add_or_return_limiter(user_bucket_name(name), get_scale(:user, limits))
|
|
|
|
:ok
|
|
end
|
|
|
|
defp attach_identity(base, %{mode: :user, conn_info: conn_info}),
|
|
do: "user:#{base}:#{conn_info}"
|
|
|
|
defp attach_identity(base, %{mode: :anon, conn_info: conn_info}),
|
|
do: "ip:#{base}:#{conn_info}"
|
|
|
|
defp user_bucket_name(bucket_name_root), do: "user:#{bucket_name_root}" |> String.to_atom()
|
|
defp anon_bucket_name(bucket_name_root), do: "anon:#{bucket_name_root}" |> String.to_atom()
|
|
end
|