diff --git a/docs/config.md b/docs/config.md index c61a5d8a3..e31e2b90f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 index 000000000..e02ba4213 --- /dev/null +++ b/lib/pleroma/plugs/rate_limiter.ex @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# 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 9447a2e4f..1b78c5ca8 100644 --- a/mix.exs +++ b/mix.exs @@ -129,7 +129,7 @@ defp deps 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 index 000000000..b3798bf03 --- /dev/null +++ b/test/plugs/rate_limiter_test.exs @@ -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