LoginSignup
8
1

Phoenixアプリでレート制限

Last updated at Posted at 2021-04-16

Elixirプログラミングが楽しくてたまらず、毎日Elixirを使ってなにかに取り組んでます。elixir.jpではみんなが熱心に勤勉にモクモクしてるので自分も負けじと気合が入ります。

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

hello-nerves-2

4/17(土) 00:00〜 4/19(月) 23:59開催のautoracex #23での成果です。
English edition

普通に趣味で遊ぶ程度なら特にレート制限なんていらないのかもしれませんが、突然悪いハッカーがDDoS攻撃を仕掛けてくる可能性がゼロではありません。IPアドレスごとにリクエスト送信できる数に制限をかければ、ひとつの対策となると思います。

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

ExRatedをインストール

mix.exsに追加。

 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によると、danielberkompasさんの記事Rate Limiting a Phoenix APIを参考にしてくださいとのことでした。たしかに、必要な情報はすべてその記事にありました。

プラグの書き方については、Phoenix公式ドキュメントに書かれています。

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

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を使うことにしました。Ruby用のRack AttackライブラリのREADMEに503を使うとハッカーが攻撃に成功したと勘違いさせることができるかもと書いてあり、いいアイデアだと思ったからです。

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

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をつかって最低限の機能はカバーできました。

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

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

以上

8
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1