Elixirプログラミングが楽しくてたまらず、毎日Elixirを使ってなにかに取り組んでます。elixir.jpではみんなが熱心に勤勉にモクモクしてるので自分も負けじと気合が入ります。
autoracex主催者@torifukukaiouさんの記事「AHT20で温度湿度を取得して全世界に惜しげもなくあたい(値)を公開する(Elixir/Nerves/Phoenix)」を参考に自宅の温度と湿度をリアルタイムで監視できるシステムを作りましたが、今の所うまく動いているのでコードをリファクタリングしたりその他の改善に取り組んでます。先日、Phoenix PubSubでリアルタイムにページ更新と簡易トークン認証について書きましたが、その続きとして、今日はレート制限についてまとめます。
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
以上