Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: Rack-attack gem README(翻訳)

概要

MITライセンスに基づいて翻訳・公開いたします。

rack/rack-attack - GitHub

Ruby: Rack-attack gem README(翻訳)

Rack-attack gemは、不正なリクエストをブロック・スロットリングするRackミドルウェアです。

RailsアプリやRackアプリを不正なクライアントから保護します。Rack::Attackを使うと、リクエストのプロパティに応じて「許可(allow)」「ブロック(block)」「スロットル(throttle: 一時的な抑制)」を行うタイミングを手軽に決定できます。

Rack::Attackを紹介するブログ記事もどうぞ。

🔗 導入方法

🔗 インストール

アプリケーションのGemfileに以下を追加します。

# Gemfile

gem "rack-attack", "~> 6.8"

続いて以下を実行します。

$ bundle

または以下のようにbundlerを使わずにインストールすることも可能です。

$ gem install rack-attack

🔗 アプリケーションで有効にする

以下の方法で、rack-attackをRackミドルウェアとして使うようRuby Webアプリケーションに指示します。

  • a) Railsアプリケーションの場合: デフォルトで利用できます。

特定の環境などでrack-attackを一時的(特定のテストケースで使う場合など)または永続的に無効にしたい場合は、以下を設定します。

Rack::Attack.enabled = false
  • b) Rackアプリケーションの場合:
# config.ru

require "rack/attack"
use Rack::Attack

重要: デフォルトのrack-attackは、ブロッキングやスロットリングを行いません。保護対象を指定して何らかのルールを設定する必要があります。

🔗 利用法

ヒント

rack-attackをすぐ使いたい場合は、docs/example_configuration.mdを元にニーズに応じてカスタマイズできます。
詳しい設定サンプルはdocs/advanced_configuration.mdで参照できます。

ルールの定義は、Rack::Attackのpublicメソッド呼び出しをアプリケーションの初期化時に実行されるファイルに書くことで行われます。
Railsアプリの場合はconfig/initializers/rack_attack.rbというファイルを新規作成して、ここにルールを記入します。

🔗 1: セーフリスト

セーフリスト(safelist)の優先順位は最も高いため、セーフリストにマッチしたリクエストは、それ以外のブロックリストやスロットルにいくつマッチしても許可されます。

🔗 1-1: safelist_ip(ip_address_string)

例:

# config/initializers/rack_attack.rb (for rails app)

Rack::Attack.safelist_ip("5.6.7.8")
🔗1-2: safelist_ip(ip_subnet_string)

例:

# config/initializers/rack_attack.rb (for rails app)

Rack::Attack.safelist_ip("5.6.7.0/24")
🔗1-3: safelist(name, &block)

カスタムのセーフリストに名前をつけます。リクエストを許可したい場合はRubyのブロック引数でtrueに相当する値を返し、許可したくない場合はfalseに相当する値を返します。

リクエストオブジェクトはRack::Requestです。

例:

# config/initializers/rack_attack.rb (for rails apps)

# 信頼できるユーザーがAPIKeyというHTTPリクエストヘッダを使っている場合
Rack::Attack.safelist("mark any authenticated access safe") do |request|
  # 戻り値がtrueに相当する場合リクエストは許可される
  request.env["HTTP_APIKEY"] == "secret-string"
end

# localhostからのリクエストは常に許可する
# ブロックリストやスロットルはすべてスキップされる
Rack::Attack.safelist('allow from localhost') do |req|
  # 戻り値がtrueに相当する場合リクエストは許可される
  '127.0.0.1' == req.ip || '::1' == req.ip
end

🔗 2: ブロッキング

🔗2-1: blocklist_ip(ip_address_string)

例:

# config/initializers/rack_attack.rb (for rails apps)

Rack::Attack.blocklist_ip("1.2.3.4")
🔗 2-2: blocklist_ip(ip_subnet_string)

# config/initializers/rack_attack.rb (for rails apps)

Rack::Attack.blocklist_ip("1.2.0.0/16")
🔗 2-3: blocklist(name, &block)

カスタムのブロックリストに名前を付け、リクエストをブロックしたい場合はRubyのブロック引数でtrueに相当する値を返し、ブロックしたくない場合はfalseに相当する値を返します。

リクエストオブジェクトはRack::Requestです。

例:

# config/initializers/rack_attack.rb (for rails apps)

Rack::Attack.blocklist("block all access to admin") do |request|
  # 戻り値がtrue相当の場合、リクエストはブロックされる
  request.path.start_with?("/admin")
end

Rack::Attack.blocklist('block bad UA logins') do |req|
  req.path == '/login' && req.post? && req.user_agent == 'BadUA'
end
🔗 2-4: Fail2Ban

ブロックリスト内でFail2Ban.filterを呼び出すと、不正なクライアントからのリクエストをすべてブロックできます。
このパターンはfail2banというツールからヒントを得ました。パラメータの仕組みについて詳しくはfail2banのドキュメントを参照してください。

fail2banフィルタを複数設置する場合は、必ずフィルタごとに別のブロックリストを渡し、fail2banフィルタに一意の識別名(discriminator)を与えてください。

Fail2Banのステートは設定可能なキャッシュに保存されます(Rails.cacheが存在する場合はデフォルトでこれが使われます)。

# '/etc/passwd'やWordPress特有のパスへの怪しいリクエストをブロックする
# 10分間に3件のリクエストがブロックされると、
# そのIPからのすべてのリクエストを5分間ブロックする
Rack::Attack.blocklist('fail2ban pentesters') do |req|
  # `filter`は、リクエストが失敗した場合や
  # banされたことがあるIPからのリクエストの場合は
  # true相当の値を返すので、リクエストはブロックされる
  Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 5.minutes) do
    # 戻り値がtrue相当の場合はそのIPのカウントを1増やす
    CGI.unescape(req.query_string) =~ %r{/etc/passwd} ||
    req.path.include?('/etc/passwd') ||
    req.path.include?('wp-admin') ||
    req.path.include?('wp-login')

  end
end

Fail2Banフィルタはブロックリストごとに自動的に分離されない点に注意してください。そのため、アプリケーションでフィルタを複数使う場合は、"pentesters-#{req.ip}"のように識別名にプレフィックスを個別に追加する工夫が必要です。

訳注

原文の意図に沿ってサンプルを追加します。

# フィルタが1つだけなら識別名はIPだけでもよい
Rack::Attack::Fail2Ban.filter(req.ip, ...) { ... }

# フィルタを複数使う場合はプレフィックスで区別する
Rack::Attack::Fail2Ban.filter("pentest:#{req.ip}", ...) { ... }
Rack::Attack::Fail2Ban.filter("badpath:#{req.ip}", ...) { ... }
🔗 2-5: Allow2Ban

Allow2Ban.filterの動作は基本的にFail2Ban.filterと同様ですが、不正なクライアントからのリクエストをmaxretryの回数に達するまでは「許可」しmaxretryに達すると通常通り切断する点が異なります。

Allow2banのステートは設定可能なキャッシュに保存されます(Rails.cacheが存在する場合はデフォルトでこれが使われます)。

# loginページに大量のアクセスを集中させているIPアドレスをロックアウトする。
# 1分あたり20リクエストに達すると、そのIPからのアクセスを1時間ブロックする。
Rack::Attack.blocklist('allow2ban login scrapers') do |req|
  # `filter`は、リクエスト対象がloginページの場合は
  # false相当の値を返す(ただしカウントアップは行なう)。
  # このため、制限に達するまでリクエストはブロックされない。
  # 制限に達するとフィルタはtrueを返してブロックする。
  Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do
    # 戻り値がtrue相当の場合はそのIPのカウントを1増やす
    req.path == '/login' and req.post?
  end
end

🔗 3: スロットリング

throttleのステートは設定可能なキャッシュに保存されます(Rails.cacheが存在する場合はデフォルトでこれが使われます)。

🔗 3-1: throttle(name, options, &block)

カスタムのスロットルに名前を付け(オプションとしてlimitperiodを指定可能)、Rubyのブロック引数で識別名(discriminator)を返すようにします。この識別名は、IPアドレスごとに制限するか、メールアドレスやその他の基準で制限するかをrack-attackに指示するのに使われます。

リクエストオブジェクトはRack::Requestです。

例:

# config/initializers/rack_attack.rb (for rails apps)

Rack::Attack.throttle("requests by ip", limit: 5, period: 2) do |request|
  request.ip
end

# 指定のemailパラメータによるログイン試行を1分あたり6回までに制限する。
# `POST /login`リクエストの識別子として「正規化された」emailを返す。
Rack::Attack.throttle('limit logins per email', limit: 6, period: 60) do |req|
  if req.path == '/login' && req.post?
    # メールの大文字小文字を変更してレート制限をバイパスするのを防ぐため、
    # 認証プロセスと同じロジックでメールアドレスを正規化する
    req.params['email'].to_s.downcase.gsub(/\s+/, "")
  end
end

# Rack::Auth::Basicでユーザーを認証した後なら、
# limitとperiodを以下のようにprocでも設定可能:
limit_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 100 : 1 }
period_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 1 : 60 }

Rack::Attack.throttle('request per ip', limit: limit_proc, period: period_proc) do |request|
  request.ip
end

🔗 4: トラッキング

# 特定のuser agentからのリクエストをトラッキングする
Rack::Attack.track("special_agent") do |req|
  req.user_agent == "SpecialAgent"
end

# オプションでlimitとperiodをサポートし、
# これらの制限に達した場合にのみ通知する
Rack::Attack.track("special_agent", limit: 6, period: 60) do |req|
  req.user_agent == "SpecialAgent"
end

# ActiveSupport::Notificationを用いてトラッキングする
ActiveSupport::Notifications.subscribe("track.rack_attack") do |name, start, finish, instrumenter_id, payload|
  req = payload[:request]
  if req.env['rack.attack.matched'] == "special_agent"
    Rails.logger.info "special_agent: #{req.path}"
    STATSD.increment("special_agent")
  end
end

🔗 キャッシュストアの設定方法

throttletrackAllow2banFail2banのステートは設定可能なキャッシュに保存されます(Rails.cacheが存在する場合はデフォルトでこれが使われます)。
背後では主にmemcachedやredis(gemはv3.0.0以上)が使われます。

# これはデフォルト
Rack::Attack.cache.store = Rails.cache

# スロットリングやAllow2banやFail2banは
# メインと別のデータベースに保存することが推奨される
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: "...")

ほとんどのアプリケーションでは、rack-attack専用のデータベースを別途新たに用意する必要があります。実際に攻撃を受けたりサーバーが高負荷になったりすると、このデータベースに大きな負荷がかかります。rack-attack用のデータベースをメインのデータベースと別インスタンスにしておくことで耐障害性が向上し、他の機能(アプリのキャッシュなど)の停止を防げるようになります。

Rack::Attack.cacheは、throttleAllow2banFail2banの保存にのみ使われ、ブロッキングやセーフリストの保存には使われない点に注意が必要です。
利用するキャッシュストアには、ActiveSupport::Cache::Storeと同様のincrementメソッドとwriteメソッドが実装されている必要があります(つまりActiveSupport::Cache::Storeを継承する他のキャッシュストアも互換性があります)。

外部データベースで支えられていないインメモリストア(ActiveSupport::Cache::MemoryStore.newなど)をrack-attackで利用してもほとんど効果がありません。各Rubyプロセスがそれぞれ独自のステートを持つため、各クライアントが実行できるリクエスト数が、実質的にデプロイしているRubyプロセス数の数倍になってしまいます。

🔗 レスポンスをカスタマイズする

Rackアプリのインターフェイスに準じたオブジェクトを使うと、ブロックされたリクエストやスロットリングされたリクエストへのレスポンスをカスタマイズできます。

Rack::Attack.blocklisted_responder = lambda do |request|
  # 攻撃が成功したと攻撃者に思わせるために503を返す。
  # Rack::Attackのブロックリストはデフォルトで403を返す。
  [ 503, {}, ['Blocked']]
end

Rack::Attack.throttled_responder = lambda do |request|
  # 注意: マッチしたスロットル名などのデータにアクセス可能になる
  #  request.env['rack.attack.matched'],
  #  request.env['rack.attack.match_type'],
  #  request.env['rack.attack.match_data'],
  #  request.env['rack.attack.match_discriminator']

  # 攻撃が成功したと攻撃者に思わせるために503を返す。
  # Rack::Attackのスロットルはデフォルトで429を返す。
  [ 503, {}, ["Server Error\n"]]
end

🔗 善良なクライアント向けにRateLimitヘッダーを設定する

Rack::Attackの主な目的は悪質なクライアントによる被害を最小限にとどめることですが、善良なクライアントにとって有用なレート制限データを返すのにも使えます。

ユーザーがリクエスト送信を再開できるまであと何秒待てばよいかを返すには、Retry-Afterヘッダーを有効にします。

Rack::Attack.throttled_response_retry_after_header = true

RateLimit-*ヘッダーを含むレスポンスとしてよく使われる例を以下に示します。

Rack::Attack.throttled_responder = lambda do |request|
  match_data = request.env['rack.attack.match_data']
  now = match_data[:epoch_time]

  headers = {
    'ratelimit-limit' => match_data[:limit].to_s,
    'ratelimit-remaining' => '0',
    'ratelimit-reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
  }

  [ 429, headers, ["Throttled\n"]]
end

Rack::Attackは、スロットル制限を超えなかったレスポンスについてはマッチしたデータでenvにアノテーションします。

request.env['rack.attack.throttle_data'][name] # => { discriminator: d, count: n, period: p, limit: l, epoch_time: t }

🔗 ログ出力とInstrumentation

Rack::Attackは、可能な場合はActiveSupport::Notifications APIを利用します。

rack_attackイベントをサブスクライブしてログ出力したりグラフ化したりできます。

特定の種類のイベントについて通知を受け取るには、イベント名に続けてrack_attack名前空間を指定します。

例: スロットルの通知を受け取るには以下のようにします。

ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, instrumenter_id, payload|
  # リクエストオブジェクトはpayload[:request]で利用可能

  # ここにコードを書く
end

rack_attackのすべてのイベントをサブスクライブするには以下のようにします。

ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, instrumenter_id, payload|
  # リクエストオブジェクトはpayload[:request]で利用可能

  # ここにコードを書く
end

🔗 テストについて

Rack::Attackを利用するアプリの開発やテストを行う場合の注意事項があります。
特にスロットルを使う場合は、ローカルのdevelopment環境でキャッシュを有効にしておく必要があります。方法について詳しくは以下のRailsガイドを参照してください。

参考: Rails のキャッシュ機構 - Railsガイド

🔗 rack-attackを無効にする

Rack::Attack.enabled = falseを指定すると、test環境全体や特定のテストケースでRack::Attackを無効にできます。

🔗 テストケースを分離する

テストスイートでRack::Attack.reset!を使うことで、テストケース間のRack::Attackステートをクリアできるようになります。

ブロックリストやセーフリストの設定をテストする場合は、Rack::Attack.clear_configurationでテストケース間のリストの値を設定解除することを検討してください。

🔗 rack-attackのしくみ

Rack::Attackミドルウェアは、アプリケーションで定義された「safelist」「blocklist」「throttle」「track」をリクエストごとに比較します。デフォルトでは何も設定されません。

  • safelistにマッチしたリクエストは無条件で許可されます。
  • マッチしなかった場合、blocklistにマッチしたリクエストはブロックされます。
  • マッチしなかった場合、throttleにマッチしたリクエストのカウンタはRack::Attack.cache内でカウントアップされます。throttleのいずれかが制限を超えると、そのリクエストはブロックされます。
  • それ以外の場合、すべてのtrackがチェックされ、リクエストは許可されます。

このアルゴリズムはコードで見る方が簡潔です(完全なコードについてはRack::Attack.callを参照してください)。

def call(env)
  req = Rack::Attack::Request.new(env)

  if safelisted?(req)
    @app.call(env)
  elsif blocklisted?(req)
    self.class.blocklisted_responder.call(req)
  elsif throttled?(req)
    self.class.throttled_responder.call(req)
  else
    tracked?(req)
    @app.call(env)
  end
end

メモ: Rack::Attack::RequestRack::Requestのサブクラスなので、lib/rack/attack/request.rbでモンキーパッチをクリーンに当てられます。

🔗 トラッキングについて

Rack::Attack.trackはリクエスト処理に影響しません。トラッキングを使うと、任意の属性にマッチするリクエストを手軽にログ出力して測定できます。

🔗 パフォーマンス

Rack::Attackを実行するときのオーバーヘッドは通常であれば無視できるレベルです(リクエストあたり数msec程度)が、チェックの個数によって変わってきます。
スロットルはキャッシュサーバーにネットワーク経由でアクセスする必要が生じることが多いので、リクエストあたりのスロットルチェック数は少なめにしておきましょう。

リクエストがブロックリストやスロットルにマッチした場合のレスポンスには、ごくシンプルなRackレスポンスが使われているので、典型的なRuby Webサーバーのスレッド1個で、秒あたり数百件のリクエストをブロックできます。

Rack::Attackは、iptablesやnginxのlimit_conn_zone moduleなどのツールを補完します。

🔗 開発の動機

不正なクライアントには、悪質なログインクラッカーから雑に書かれたスクレーパーまでさまざまなものがありますが、いずれもWebサーバーのセキュリティ・パフォーマンス・可用性を阻害します。

不正なクライアントを完全にブロックすることは、不可能とはいかなくても現実的ではありません。

Rack::Attackは、そうした不正なリクエストを開発者が手早く緩和できるようにすることで、その場しのぎのハックで攻撃を阻止することを減らすのが目的です。

🔗 貢献方法

CONTRIBUTING.mdを参照してください

🔗 行動規範

このプロジェクトは、安心してコラボできる空間となることを目指しており、貢献者はCODE_OF_CONDUCT.mdに従うことが求められます。

🔗 開発用セットアップ

docs/development.mdを参照してください。

ライセンス

Copyright Kickstarter, PBC.

Released under an MIT License.

関連記事

Rails 8: 組み込みのレート制限APIを導入(翻訳)

Solid Cache README: DBベースのキャッシュストア(翻訳)

Rails: rack-attack gemでアクセスをRack middlewareレベルで制限する


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。