Railsで不正なアクセスをBANして、IPを永続化してSlackに通知する


## 動作環境
* Rails 5.0.1
* Ruby 2.4.0

## お断り
本記事はセキュリティ要件を担保するものではありませんので、セキュリティ要件は各自でご判断ください。

## 不正なアクセスを検知 & BAN

### やりたいこと
今回は一定時間以内に一定回数以上ログインを試みた攻撃者をBANするケースを想定。
こんな感じのことをやりたい。

![attack-detection.gif](https://qiita-image-store.s3.amazonaws.com/0/30233/3bd6e51e-1509-c488-f8f8-4a99a8e82b1e.gif "attack-detection.gif")

他にも共有用のPublicなランダム文字列のURLがあって、そこを全探索しようとしてきた際などにも使える。


### READMEに沿った実装

不正なアクセスの検知とBANに[rack-attack](https://github.com/kickstarter/rack-attack)というGemを使う。READMEに沿って進めると下記のようになる。

```Gemfile:Gemfile
gem 'rack-attack'
```

```console:console
$ bundle install
```

```diff:config/application.rb
+    config.middleware.use Rack::Attack
```


```ruby:config/initializers/rack_attack.rb
class Rack::Attack
  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
  Rack::Attack.blocklist('fail2ban pentesters') do |req|
    cache_id = "pentesters-#{req.ip}-#{req.user_agent}"

    Rack::Attack::Fail2Ban.filter(
      cache_id,
      maxretry: 3,
      findtime: 10.minutes,
      bantime: 5.minutes
    ) do
      # The count for the IP is incremented if the return value is truthy
      self.attack?(req)
    end
  end

  def self.attack?(req)
    # Your attack detection logic
  end
end
```

### カスタマイズ項目の解説

ここから解説を含めて、詳細な設定の手順を示すが、TL;DRな人は少し先に「最終的なrack_attack.rb」を示しているので、そこまで読み飛ばしても可。


`self.attack?`にBANする条件を記載する。
[Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request)を取得できるので、Pathを判定するもよし、Methodを判定するもよしで、ここはよしなに条件を書く。Rack::Requestから取得できる値はリンク先を参照。


一例として、deviseで作成したログイン画面でログインを試みた時の条件を下記に示す。
このリクエスト自体は攻撃ではないが、一定回数以上ログインを試みていた場合にBANをする想定で攻撃としてカウントする。

```diff:config/initializers/rack_attack.rb
def self.attack?(req)
  # Your attack detection logic
+ req.path == Rails.application.routes.url_helpers.user_session_path && req.post?
end
```

注意点としては、攻撃の条件のアクセスの場合にtrueを返すように記述する。
ここでtrueとなった回数が攻撃回数として記録される。
`Rack::Attack::Fail2Ban.filter`に記述された条件の閾値を越えたアクセスが来た際にBANを行う。

```ruby:config/initializers/rack_attack.rb
Rack::Attack::Fail2Ban.filter(
  cache_id,
  maxretry: 3,
  findtime: 10.minutes,
  bantime: 5.minutes
) do
```

デフォルトの例だと、過去10分間に3回以上、cache_idに該当するアクセスから攻撃があった場合、cache_idに該当するアクセスを5分間BANするようになっている。ここの条件は各自のセキュリティ要件に合わせてカスタマイズする。攻撃者はスクリプトを組んで叩きにくるケースが多いと思うので、BANする条件を見抜かれて、叩き方を変えられた時用に、複数の`Rack::Attack::Fail2Ban.filter`を書いて複数の条件を設定しておくと良いかと思う。

また、`cache_id`に記録するアクセスの条件を記述する。デフォルトだとIPのみ。今回はIPとUserAgent毎に攻撃回数を記録するような設定とした。

```ruby:config/initializers/rack_attack.rb
cache_id = "pentesters-#{req.ip}-#{req.user_agent}"
```

上記の設定でアクセス制限が可能なのだが、
ただ、一点、問題があって、`blocklist`は内部の条件がtrueの際に403に飛ばす設定となっている、かつ、内部で呼び出している[Rack::Attack::Fail2Ban.fail!](https://github.com/kickstarter/rack-attack/blob/master/lib/rack/attack/fail2ban.rb#L34)はtrueを返すので、不正なアクセス以外も`self.attack?`の条件に合致した場合は403に飛んでしまう。今回で言うとログインしようとした際に、403に飛んでしまう。BANされたユーザのアクセスだけを403に飛ばしたい場合は、下記の記述を行う。

```diff:config/initializers/rack_attack.rb
Rack::Attack.blocklist('fail2ban pentesters') do |req|


+ Rack::Attack::Fail2Ban.banned?(cache_id)
end

```

ちなみに、この403に飛ばす設定は変更できて、下記の記述を加えることで、任意のstatusでエラーページを表示することができる。
下記、公式のコメント通り、攻撃者が攻撃に成功していると思わせるために503を返すことを薦めている。

```ruby:config/initializers/rack_attack.rb
Rack::Attack.blocklisted_response = lambda do |env|
  # Using 503 because it may make attacker think that they have successfully
  # DOSed the site. Rack::Attack returns 403 for blocklists by default
  [ 503, {}, ['Blocked']]
end
```

**--- 読み飛ばす人はここまで ---**
### 最終的なrack_attack.rb

諸々書いたが、整理のために、最終的な`config/initializers/rack_attack.rb`を示す。

```ruby:config/initializers/rack_attack.rb
class Rack::Attack
  BANTIME = 30.minutes.freeze

  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
  Rack::Attack.blocklist('fail2ban pentesters') do |req|
    cache_id = "pentesters-#{req.ip}-#{req.user_agent}"

	# あくまで一例
    conditions = [
      { maxretry: 5, findtime: 10.seconds },
      { maxretry: 15, findtime: 10.minutes },
      { maxretry: 30, findtime: 1.hour },
    ]
    
    conditions.each do |condition|
      Rack::Attack::Fail2Ban.filter(cache_id, condition.merge(bantime: BANTIME)) do
        # The count for the IP is incremented if the return value is truthy
        self.attack?(req)
      end
    end

    Rack::Attack::Fail2Ban.banned?(cache_id)
  end

  Rack::Attack.blocklisted_response = lambda do |env|
    [ 503, {}, ['Blocked']]
  end

  def self.attack?(req)
    req.path == Rails.application.routes.url_helpers.user_session_path && req.post?
  end
end
```

ここまでの実装で不正なアクセスをBANできるように🎉

### 冗長化構成の場合
`ActiveSupport::Cache::MemoryStore`を使用しているので、複数インスタンス間で攻撃回数は共有されないので、キャッシュする領域をRedisなどに変更する対応が必要。

## 永続化
不正なアクセスをBANできるようになったが、このままでは、人知れずBANをしているので、BANする条件の見直しや攻撃元の管理のためにBANした際に永続化して、通知を行うようにしたい。

まずはAttackLogモデルの追加から

```console:console
$ rails g model AttackLog ip:string user_agent:string
$ rails db:migrate	
```

永続化を行う処理の追加。BANしたタイミングをハンドリングできないので、`Rack::Attack::Fail2Ban.banned?`でBANされているかをハンドリングする。

```diff:config/initializers/rack_attack.rb
class Rack::Attack
  BANTIME = 30.minutes.freeze

  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
  Rack::Attack.blocklist('fail2ban pentesters') do |req|
    cache_id = "pentesters-#{req.ip}-#{req.user_agent}"

	# あくまで一例
    conditions = [
      { maxretry: 5, findtime: 10.seconds },
      { maxretry: 15, findtime: 10.minutes },
      { maxretry: 30, findtime: 1.hour },
    ]
    
    conditions.each do |condition|
      Rack::Attack::Fail2Ban.filter(cache_id, condition.merge(bantime: BANTIME)) do
        # The count for the IP is incremented if the return value is truthy
        self.attack?(req)
      end
    end

-   Rack::Attack::Fail2Ban.banned?(cache_id)
+   is_banned = Rack::Attack::Fail2Ban.banned?(cache_id)
+   if is_banned
+     range = (Time.zone.now - BANTIME)..Time.zone.now
+     AttackLog.find_or_create_by!(ip: req.ip, user_agent: req.user_agent, created_at: range)
+   end
+
+   is_banned
  end

  Rack::Attack.blocklisted_response = lambda do |env|
    [ 503, {}, ['Blocked']]
  end

  def self.attack?(req)
    req.path == Rails.application.routes.url_helpers.user_session_path && req.post?
  end
end
```

これでBANしたタイミングでDBにレコードが追加されるようになる🎉

## Slackに通知

[こちらの記事](http://qiita.com/suzan2go/items/967da1bb0e138dd30e0e)を参考にslackのWebhook URLを取得し、環境変数に追加する。

```text:.env
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXX
```

slackへの通知は[slack-notifier](https://github.com/stevenosloan/slack-notifier)というGemを使う。

```Gemfile:Gemfile
gem 'slack-notifier'
```

```console:console
$ bundle install
```

slackに通知する用のテンプレートを追加する。

```ruby:app/views/layouts/attack_notifier.text.erb
不正なアクセスを検知しました。

IP = <%= attack_log.ip %>
UA = <%= attack_log.user_agent %>
DATETIME = <%= Time.zone.now %>
```

永続化したタイミングでSlackに通知を行いたいので、先ほど追加した`AttackLog.find_or_create_by!`のafter_createで`notifier.ping`を呼び出す。

```ruby:app/models/attack_log.rb
class AttackLog < ApplicationRecord
  after_create do |attack_log|
    erb = Rails.root.join('app/views/layouts/attack_notifier.text.erb').read
    notifier = Slack::Notifier.new(ENV["SLACK_WEBHOOK_URL"])
    notifier.ping(ERB.new(erb).result(binding))
  end
end
```

上記の設定を完了すると、BANしたタイミングで、永続化され、Slackに通知がきて、IPとUAを通知してくれる🎉

![slack_notifier.png](https://qiita-image-store.s3.amazonaws.com/0/30233/d62b6154-d046-cbcd-d5f3-7a6bc93d045b.png "slack_notifier.png")

## 終わりに
攻撃者からサービスとユーザを守りましょう🙆