First
[anni] / lib / pleroma / web / plugs / rate_limiter.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.Plugs.RateLimiter do
6   @moduledoc """
7
8   ## Configuration
9
10   A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration.
11   The basic configuration is a tuple where:
12
13   * The first element: `scale` (Integer). The time scale in milliseconds.
14   * The second element: `limit` (Integer). How many requests to limit in the time scale provided.
15
16   It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a
17   list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated.
18
19   To disable a limiter set its value to `nil`.
20
21   ### Example
22
23       config :pleroma, :rate_limit,
24         one: {1000, 10},
25         two: [{10_000, 10}, {10_000, 50}],
26         foobar: nil
27
28   Here we have three limiters:
29
30   * `one` which is not over 10req/1s
31   * `two` which has two limits: 10req/10s for unauthenticated users and 50req/10s for authenticated users
32   * `foobar` which is disabled
33
34   ## Usage
35
36   AllowedSyntax:
37
38       plug(Pleroma.Web.Plugs.RateLimiter, name: :limiter_name)
39       plug(Pleroma.Web.Plugs.RateLimiter, options)   # :name is a required option
40
41   Allowed options:
42
43       * `name` required, always used to fetch the limit values from the config
44       * `bucket_name` overrides name for counting purposes (e.g. to have a separate limit for a set of actions)
45       * `params` appends values of specified request params (e.g. ["id"]) to bucket name
46
47   Inside a controller:
48
49       plug(Pleroma.Web.Plugs.RateLimiter, [name: :one] when action == :one)
50       plug(Pleroma.Web.Plugs.RateLimiter, [name: :two] when action in [:two, :three])
51
52       plug(
53         Pleroma.Web.Plugs.RateLimiter,
54         [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]]
55         when action in ~w(fav_status unfav_status)a
56       )
57
58   or inside a router pipeline:
59
60       pipeline :api do
61         ...
62         plug(Pleroma.Web.Plugs.RateLimiter, name: :one)
63         ...
64       end
65   """
66   import Pleroma.Web.TranslationHelpers
67   import Plug.Conn
68
69   alias Pleroma.Config
70   alias Pleroma.User
71   alias Pleroma.Web.Plugs.RateLimiter.LimiterSupervisor
72
73   require Logger
74
75   @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
76
77   @doc false
78   def init(plug_opts) do
79     plug_opts
80   end
81
82   def call(conn, plug_opts) do
83     if disabled?(conn) do
84       handle_disabled(conn)
85     else
86       action_settings = action_settings(plug_opts)
87       handle(conn, action_settings)
88     end
89   end
90
91   defp handle_disabled(conn) do
92     Logger.warn(
93       "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."
94     )
95
96     conn
97   end
98
99   defp handle(conn, nil), do: conn
100
101   defp handle(conn, action_settings) do
102     action_settings
103     |> incorporate_conn_info(conn)
104     |> check_rate()
105     |> case do
106       {:ok, _count} ->
107         conn
108
109       {:error, _count} ->
110         render_throttled_error(conn)
111     end
112   end
113
114   def disabled?(conn) do
115     if Map.has_key?(conn.assigns, :remote_ip_found),
116       do: !conn.assigns.remote_ip_found,
117       else: false
118   end
119
120   @inspect_bucket_not_found {:error, :not_found}
121
122   def inspect_bucket(conn, bucket_name_root, plug_opts) do
123     with %{name: _} = action_settings <- action_settings(plug_opts) do
124       action_settings = incorporate_conn_info(action_settings, conn)
125       bucket_name = make_bucket_name(%{action_settings | name: bucket_name_root})
126       key_name = make_key_name(action_settings)
127       limit = get_limits(action_settings)
128
129       case @cachex.get(bucket_name, key_name) do
130         {:error, :no_cache} ->
131           @inspect_bucket_not_found
132
133         {:ok, nil} ->
134           {0, limit}
135
136         {:ok, value} ->
137           {value, limit - value}
138       end
139     else
140       _ -> @inspect_bucket_not_found
141     end
142   end
143
144   def action_settings(plug_opts) do
145     with limiter_name when is_atom(limiter_name) <- plug_opts[:name],
146          limits when not is_nil(limits) <- Config.get([:rate_limit, limiter_name]) do
147       bucket_name_root = Keyword.get(plug_opts, :bucket_name, limiter_name)
148
149       %{
150         name: bucket_name_root,
151         limits: limits,
152         opts: plug_opts
153       }
154     end
155   end
156
157   defp check_rate(action_settings) do
158     bucket_name = make_bucket_name(action_settings)
159     key_name = make_key_name(action_settings)
160     limit = get_limits(action_settings)
161
162     case @cachex.get_and_update(bucket_name, key_name, &increment_value(&1, limit)) do
163       {:commit, value} ->
164         {:ok, value}
165
166       {:ignore, value} ->
167         {:error, value}
168
169       {:error, :no_cache} ->
170         initialize_buckets!(action_settings)
171         check_rate(action_settings)
172     end
173   end
174
175   defp increment_value(nil, _limit), do: {:commit, 1}
176
177   defp increment_value(val, limit) when val >= limit, do: {:ignore, val}
178
179   defp increment_value(val, _limit), do: {:commit, val + 1}
180
181   defp incorporate_conn_info(action_settings, %{
182          assigns: %{user: %User{id: user_id}},
183          params: params
184        }) do
185     Map.merge(action_settings, %{
186       mode: :user,
187       conn_params: params,
188       conn_info: "#{user_id}"
189     })
190   end
191
192   defp incorporate_conn_info(action_settings, %{params: params} = conn) do
193     Map.merge(action_settings, %{
194       mode: :anon,
195       conn_params: params,
196       conn_info: "#{ip(conn)}"
197     })
198   end
199
200   defp ip(%{remote_ip: remote_ip}) do
201     remote_ip
202     |> Tuple.to_list()
203     |> Enum.join(".")
204   end
205
206   defp render_throttled_error(conn) do
207     conn
208     |> render_error(:too_many_requests, "Throttled")
209     |> halt()
210   end
211
212   defp make_key_name(action_settings) do
213     ""
214     |> attach_selected_params(action_settings)
215     |> attach_identity(action_settings)
216   end
217
218   defp get_scale(_, {scale, _}), do: scale
219
220   defp get_scale(:anon, [{scale, _}, {_, _}]), do: scale
221
222   defp get_scale(:user, [{_, _}, {scale, _}]), do: scale
223
224   defp get_limits(%{limits: {_scale, limit}}), do: limit
225
226   defp get_limits(%{mode: :user, limits: [_, {_, limit}]}), do: limit
227
228   defp get_limits(%{limits: [{_, limit}, _]}), do: limit
229
230   defp make_bucket_name(%{mode: :user, name: bucket_name_root}),
231     do: user_bucket_name(bucket_name_root)
232
233   defp make_bucket_name(%{mode: :anon, name: bucket_name_root}),
234     do: anon_bucket_name(bucket_name_root)
235
236   defp attach_selected_params(input, %{conn_params: conn_params, opts: plug_opts}) do
237     params_string =
238       plug_opts
239       |> Keyword.get(:params, [])
240       |> Enum.sort()
241       |> Enum.map(&Map.get(conn_params, &1, ""))
242       |> Enum.join(":")
243
244     [input, params_string]
245     |> Enum.join(":")
246     |> String.replace_leading(":", "")
247   end
248
249   defp initialize_buckets!(%{name: _name, limits: nil}), do: :ok
250
251   defp initialize_buckets!(%{name: name, limits: limits}) do
252     {:ok, _pid} =
253       LimiterSupervisor.add_or_return_limiter(anon_bucket_name(name), get_scale(:anon, limits))
254
255     {:ok, _pid} =
256       LimiterSupervisor.add_or_return_limiter(user_bucket_name(name), get_scale(:user, limits))
257
258     :ok
259   end
260
261   defp attach_identity(base, %{mode: :user, conn_info: conn_info}),
262     do: "user:#{base}:#{conn_info}"
263
264   defp attach_identity(base, %{mode: :anon, conn_info: conn_info}),
265     do: "ip:#{base}:#{conn_info}"
266
267   defp user_bucket_name(bucket_name_root), do: "user:#{bucket_name_root}" |> String.to_atom()
268   defp anon_bucket_name(bucket_name_root), do: "anon:#{bucket_name_root}" |> String.to_atom()
269 end