Add RateLimiter
authorEgor Kislitsyn <egor@kislitsyn.com>
Tue, 11 Jun 2019 07:27:41 +0000 (14:27 +0700)
committerEgor Kislitsyn <egor@kislitsyn.com>
Tue, 11 Jun 2019 07:27:41 +0000 (14:27 +0700)
docs/config.md
lib/pleroma/plugs/rate_limiter.ex [new file with mode: 0644]
mix.exs
test/plugs/rate_limiter_test.exs [new file with mode: 0644]

index c61a5d8a3cc587e55bd01ce8043e8a7bde137793..e31e2b90f58629a4cbc71594572fc601eae6cfcc 100644 (file)
@@ -616,3 +616,14 @@ To enable them, both the `rum_enabled` flag has to be set and the following spec
 `mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/`
 
 This will probably take a long time.
+
+## :rate_limit
+
+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.
+
+See [`Pleroma.Plugs.RateLimiter`](Pleroma.Plugs.RateLimiter.html) documentation for examples.
diff --git a/lib/pleroma/plugs/rate_limiter.ex b/lib/pleroma/plugs/rate_limiter.ex
new file mode 100644 (file)
index 0000000..e02ba42
--- /dev/null
@@ -0,0 +1,87 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.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.
+
+  ### Example
+
+      config :pleroma, :rate_limit,
+        one: {1000, 10},
+        two: [{10_000, 10}, {10_000, 50}]
+
+  Here we have two limiters: `one` which is not over 10req/1s and `two` which has two limits 10req/10s for unauthenticated users and 50req/10s for authenticated users.
+
+  ## Usage
+
+  Inside a controller:
+
+      plug(Pleroma.Plugs.RateLimiter, :one when action == :one)
+      plug(Pleroma.Plugs.RateLimiter, :two when action in [:two, :three])
+
+  or inside a router pipiline:
+
+      pipeline :api do
+        ...
+        plug(Pleroma.Plugs.RateLimiter, :one)
+        ...
+      end
+  """
+
+  import Phoenix.Controller, only: [json: 2]
+  import Plug.Conn
+
+  alias Pleroma.User
+
+  def init(limiter_name) do
+    case Pleroma.Config.get([:rate_limit, limiter_name]) do
+      nil -> nil
+      config -> {limiter_name, config}
+    end
+  end
+
+  # do not limit if there is no limiter configuration
+  def call(conn, nil), do: conn
+
+  def call(conn, opts) do
+    case check_rate(conn, opts) do
+      {:ok, _count} -> conn
+      {:error, _count} -> render_error(conn)
+    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)
+  end
+
+  defp check_rate(conn, {limiter_name, [{scale, limit} | _]}) do
+    ExRated.check_rate("#{limiter_name}:#{ip(conn)}", scale, limit)
+  end
+
+  defp check_rate(conn, {limiter_name, {scale, limit}}) do
+    check_rate(conn, {limiter_name, [{scale, limit}]})
+  end
+
+  def ip(%{remote_ip: remote_ip}) do
+    remote_ip
+    |> Tuple.to_list()
+    |> Enum.join(".")
+  end
+
+  defp render_error(conn) do
+    conn
+    |> put_status(:too_many_requests)
+    |> json(%{error: "Throttled"})
+    |> halt()
+  end
+end
diff --git a/mix.exs b/mix.exs
index 9447a2e4f955ae6f06d614d9901fe7ee27ebf58c..1b78c5ca8da5ff7633389b86b7565d3a06630de2 100644 (file)
--- a/mix.exs
+++ b/mix.exs
@@ -129,7 +129,7 @@ defmodule Pleroma.Mixfile do
       {:quack, "~> 0.1.1"},
       {:benchee, "~> 1.0"},
       {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)},
-      {:ex_rated, "~> 1.2"},
+      {:ex_rated, "~> 1.3"},
       {:plug_static_index_html, "~> 1.0.0"},
       {:excoveralls, "~> 0.11.1", only: :test}
     ] ++ oauth_deps()
diff --git a/test/plugs/rate_limiter_test.exs b/test/plugs/rate_limiter_test.exs
new file mode 100644 (file)
index 0000000..b3798bf
--- /dev/null
@@ -0,0 +1,108 @@
+defmodule Pleroma.Plugs.RateLimiterTest do
+  use ExUnit.Case, async: true
+  use Plug.Test
+
+  alias Pleroma.Plugs.RateLimiter
+
+  import Pleroma.Factory
+
+  @limiter_name :testing
+
+  test "init/1" do
+    Pleroma.Config.put([:rate_limit, @limiter_name], {1, 1})
+
+    assert {@limiter_name, {1, 1}} == RateLimiter.init(@limiter_name)
+    assert nil == RateLimiter.init(:foo)
+  end
+
+  test "ip/1" do
+    assert "127.0.0.1" == RateLimiter.ip(%{remote_ip: {127, 0, 0, 1}})
+  end
+
+  test "it restricts by opts" do
+    scale = 100
+    limit = 5
+
+    Pleroma.Config.put([:rate_limit, @limiter_name], {scale, limit})
+
+    opts = RateLimiter.init(@limiter_name)
+    conn = conn(:get, "/")
+    bucket_name = "#{@limiter_name}:#{RateLimiter.ip(conn)}"
+
+    conn = RateLimiter.call(conn, opts)
+    assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {2, 3, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {3, 2, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {4, 1, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {5, 0, to_reset, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+
+    assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
+    assert conn.halted
+
+    Process.sleep(to_reset)
+
+    conn = conn(:get, "/")
+
+    conn = RateLimiter.call(conn, opts)
+    assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    refute conn.status == Plug.Conn.Status.code(:too_many_requests)
+    refute conn.resp_body
+    refute conn.halted
+  end
+
+  test "optional limits for authenticated users" do
+    Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo)
+
+    scale = 100
+    limit = 5
+    Pleroma.Config.put([:rate_limit, @limiter_name], [{1, 10}, {scale, limit}])
+
+    opts = RateLimiter.init(@limiter_name)
+
+    user = insert(:user)
+    conn = conn(:get, "/") |> assign(:user, user)
+    bucket_name = "#{@limiter_name}:#{user.id}"
+
+    conn = RateLimiter.call(conn, opts)
+    assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {2, 3, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {3, 2, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {4, 1, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {5, 0, to_reset, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    conn = RateLimiter.call(conn, opts)
+
+    assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
+    assert conn.halted
+
+    Process.sleep(to_reset)
+
+    conn = conn(:get, "/") |> assign(:user, user)
+
+    conn = RateLimiter.call(conn, opts)
+    assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+
+    refute conn.status == Plug.Conn.Status.code(:too_many_requests)
+    refute conn.resp_body
+    refute conn.halted
+  end
+end