Phoenixアプリでレート制限

[Elixir](https://elixir-lang.org/)プログラミングが楽しくてたまらず、毎日Elixirを使ってなにかに取り組んでます。[elixir.jp](https://join.slack.com/t/elixirjp/shared_invite/zt-ae8m5bad-WW69GH1w4iuafm1tKNgd~w)ではみんなが熱心に勤勉にモクモクしてるので自分も負けじと気合が入ります。

[autoracex](https://autoracex.connpass.com/)主催者@torifukukaiouさんの[記事「AHT20で温度湿度を取得して全世界に惜しげもなくあたい（値）を公開する(Elixir/Nerves/Phoenix)」](https://qiita.com/torifukukaiou/items/5876bc4576e7b7991347)を参考に自宅の温度と湿度をリアルタイムで監視できるシステムを作りましたが、今の所うまく動いているのでコードをリファクタリングしたりその他の改善に取り組んでます。先日、[Phoenix PubSubでリアルタイムにページ更新](https://qiita.com/mnishiguchi/items/b528dccde6c531206eb9)と[簡易トークン認証](https://qiita.com/mnishiguchi/items/2a447f9138c5b61e1147)について書きましたが、その続きとして、今日はレート制限についてまとめます。

[![hello-nerves-2](https://user-images.githubusercontent.com/7563926/113411295-6508b380-9383-11eb-81ef-942e0999d0cd.gif)](https://dev.to/mnishiguchi/iot-development-using-rapberry-pi-and-elixir-iij)


[4/17(土) 00:00〜 4/19(月) 23:59開催のautoracex #23](https://autoracex.connpass.com/event/210477/)での成果です。
[English edition](https://dev.to/mnishiguchi/rate-limiter-for-phoenix-app-3j2n)

普通に趣味で遊ぶ程度なら特にレート制限なんていらないのかもしれませんが、突然悪いハッカーが[DDoS攻撃](https://ja.wikipedia.org/wiki/DoS%E6%94%BB%E6%92%83)を仕掛けてくる可能性がゼロではありません。IPアドレスごとにリクエスト送信できる数に制限をかければ、ひとつの対策となると思います。

Phoenixアプリでどうやってレート制限ができるのか調べてみたところ、便利なライブラリがありました。[ExRated](https://github.com/grempe/ex_rated)です。レート制限の処理自体はそのライブラリが隠蔽してくれているので、実装は比較的簡単でした。自分で実装するのはプラグになります。

## ExRatedをインストール

`mix.exs`に追加。

```diff_elixir
 defmodule Mnishiguchi.MixProject do
   use Mix.Project

   ...
   def application do
     [
       mod: {Mnishiguchi.Application, []},
-      extra_applications: [:logger, :runtime_tools]
+      extra_applications: [:logger, :runtime_tools, :ex_rated]
     ]
   end

   ...

   defp deps do
     [
       ...
+      {:ex_rated, "~> 2.0"}
     ]
   end
```

そして`mix deps.get`します。

## プラグの実装

[README](https://github.com/grempe/ex_rated)によると、[danielberkompas](https://github.com/danielberkompas)さんの記事[Rate Limiting a Phoenix API](https://blog.danielberkompas.com/2015/06/16/rate-limiting-a-phoenix-api)を参考にしてくださいとのことでした。たしかに、必要な情報はすべてその記事にありました。

プラグの書き方については、[Phoenix公式ドキュメント](https://hexdocs.pm/phoenix/plug.html)に書かれています。

最終的にこういうプラグに仕上がりました。

```elixir
defmodule MnishiguchiWeb.API.RateLimitPlug do
  @moduledoc false

  import Plug.Conn, only: [put_status: 2, halt: 1]
  import Phoenix.Controller, only: [render: 2, put_view: 2]
  require Logger

  @doc """
  A function plug that does the rate limiting.

  ## Examples

      # In a controller
      import MnishiguchiWeb.API.RateLimitPlug, only: [rate_limit: 2]
      plug :rate_limit, max_requests: 5, interval_seconds: 10

  """
  def rate_limit(conn, opts \\ []) do
    case check_rate(conn, opts) do
      {:ok, _count} ->
        conn

      error ->
        Logger.info(rate_limit: error)
        render_error(conn)
    end
  end

  defp check_rate(conn, opts) do
    interval_ms = Keyword.fetch!(opts, :interval_seconds) * 1000
    max_requests = Keyword.fetch!(opts, :max_requests)

    ExRated.check_rate(bucket_name(conn), interval_ms, max_requests)
  end

  # Bucket name should be a combination of IP address and request path.
  defp bucket_name(conn) do
    path = "/" <> Enum.join(conn.path_info, "/")
    ip = conn.remote_ip |> Tuple.to_list() |> Enum.join(".")

    # E.g., "127.0.0.1:/api/v1/example"
    "#{ip}:#{path}"
  end

  defp render_error(conn) do
    # Using 503 because it may make attacker think that they have successfully DOSed the site.
    conn
    |> put_status(:service_unavailable)
    |> put_view(MnishiguchiWeb.ErrorView)
    |> render(:"503")
    # Stop any downstream transformations.
    |> halt()
  end
end
```

レート制限に引っかかった場合に戻すHTTPエラーコードとして[`503 service unavailable`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503)を使うことにしました。[Ruby用のRack AttackライブラリのREADME](https://github.com/rack/rack-attack#customizing-responses)に503を使うとハッカーが攻撃に成功したと勘違いさせることができるかもと書いてあり、いいアイデアだと思ったからです。

コントローラではこんな感じにつかってます。センサーから1秒ごとにデータを受け取っているので、10秒間に10回としています。レート制限エラーはログファイルで見れるように`Logger.info`してます。

```elixir
defmodule MnishiguchiWeb.ExampleController do
  use MnishiguchiWeb, :controller

  import MnishiguchiWeb.API.RateLimitPlug, only: [rate_limit: 2]

  ...

  plug :rate_limit, max_requests: 10, interval_seconds: 10

  ...
```

## プラグのテスト

想像していたより手こずりました。複数回のリクエストをどう再現するかでハマりました。[`build_conn/0`](https://hexdocs.pm/phoenix/Phoenix.ConnTest.html#build_conn/0)をつかって最低限の機能はカバーできました。

設定が「上限１分間に1回」の場合、一回目のリクエストはOK。直後の2回目はNG。テスト終了時に`ExRated`に記録されているデータを消去します。

```elixir
defmodule MnishiguchiWeb.API.RateLimitPlugTest do
  use MnishiguchiWeb.ConnCase, async: true

  alias MnishiguchiWeb.API.RateLimitPlug

  @path "/"
  @rate_limit_options [max_requests: 1, interval_seconds: 60]

  setup do
    bucket_name = "127.0.0.1:" <> @path

    on_exit(fn ->
      ExRated.delete_bucket(bucket_name)
    end)
  end

  describe "rate_limit" do
    test "503 Service Unavailable when beyond limit", %{conn: _conn} do
      conn1 =
        build_conn()
        |> bypass_through(MnishiguchiWeb.Router, :api)
        |> get(@path)
        |> RateLimitPlug.rate_limit(@rate_limit_options)

      refute conn1.halted

      conn2 =
        build_conn()
        |> bypass_through(MnishiguchiWeb.Router, :api)
        |> get(@path)
        |> RateLimitPlug.rate_limit(@rate_limit_options)

      assert conn2.halted
      assert json_response(conn2, 503) == "Service Unavailable"
    end
  end
end
```

以上

https://autoracex.connpass.com/

https://join.slack.com/t/elixirjp/shared_invite/zt-ae8m5bad-WW69GH1w4iuafm1tKNgd~w

https://qiita.com/torifukukaiou/items/5876bc4576e7b7991347

https://qiita.com/mnishiguchi/items/b528dccde6c531206eb9

https://qiita.com/mnishiguchi/items/2a447f9138c5b61e1147

https://github.com/grempe/ex_rated

https://blog.danielberkompas.com/2015/06/16/rate-limiting-a-phoenix-api

https://hexdocs.pm/phoenix/plug.html

https://hexdocs.pm/phoenix/Phoenix.ConnTest.html#build_conn/0

https://github.com/rack/rack-attack#customizing-responses