# Pleroma: A lightweight social networking server # Copyright © 2017-2023 Pleroma Authors # 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.warn( "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}) do remote_ip |> Tuple.to_list() |> Enum.join(".") end 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