Nic Lin's Blog

喜歡在地上滾的工程師

如何用 Rack::Attack 阻擋 DDOS / 惡意流量

基本上開發 Rails 有在接 error monitoring(Airbrake、Rollbar)或是最基本有在看 Log 的應該都會知道網站一上線後,就會有些不尋常的流量,像是會有人來猜後台網址、夾帶和一般使用者不一樣的 params

通常這些人可能會是幾種

  1. 惡意機器人,找到 domain 就 try 常見漏洞
  2. 盯上你網站的駭客

像我自己 rollbar 的後台就可以常收到類似

  • /.well-known/security.txt
  • /app-ads.txt
  • /api/v1/pod

甚至有人會嘗試大量請求 API,可能想看看能不能搞垮你的機器之類的?

那麼身為網站開發者的我們當然不希望因為這些流量而增加成本或是倒站的風險,這時候就可以把一些比較誇張的行為檔下。

我們可以用 Rack::Attack 來做一些基本的防禦。

直接加到 Gemfile

gem "rack-attack"

bundle install 後在 config/application.rb 檔案內指定 middleware 要過 Rack::Attack

config.middleware.use Rack::Attack

接下來新增 config/initializers/rack_attack.rb 我們會把一些過濾和設定放在這裡

Rake::Attack 預設會直接把這些流量記錄在 Rails.cache 內,所以如果你要額外設定在 Redis 內可以寫入

不過如果你的 Rails.cache 也是 Redis 基本上就可以省掉下面這些,除非你要分另一台 redis

redis_client = Redis.connect(url: ENV["REDIS_URL"])
Rack::Attack.cache.store = Rack::Attack::StoreProxy::RedisStoreProxy.new(redis_client)

基本防範

接下來做基本的使用

class Rack::Attack

  # 每個 IP 在 1 分鐘內不能刷超過 40 次,否則會被暫時鎖定
  throttle("req/ip", limit: 40, period: 1.minute) do |req|
    req.remote_ip if req.path == "/"
  end
  
  # 路由 /api/v1 系列的限制 1 分鐘內請求 200 次
  throttle("api_v1_requests", limit: 200, period: 1.minute) do |req|
  if req.path.start_with?("/api/v1")
    req.ip
    end
  end
  
  # 2 分鐘內透過 /users/sign_up 發出 post 超過 10 次,直接鎖 60 分鐘
  Rack::Attack.blocklist('allow2ban sign up scrapers') do |req|
    Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 10, findtime: 2.minutes, bantime: 60.minutes) do
      req.path == '/users/sign_up' and req.post?
    end
  end
end

客製化回應

HTTP 429 狀態碼的意思是 Too Many Requests,通常回這個就可以了,如果想要更清楚的或是給 api 使用者看的可讀訊息,就可以透過 throttled_response 自定義

class Rack::Attack
  Rack::Attack.throttled_response = lambda do |env|
    match_data = env['rack.attack.match_data']
    now = Time.zone.now.to_i
    seconds_before_next_try = match_data[:period] - now % match_data[:period]
    error_message = "Throttled, please try after #{seconds_before_next_try} seconds\n請求過於頻繁,請在 #{seconds_before_next_try} 秒之後嘗試"

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

    if req.api_request?
      content_type = "application/json;charset=utf-8"
      response_body = {
        code: 1234,
        errors: {
          message: error_message
        }
      }.to_json
    else
      content_type = "text/plain;charset=utf-8"
      response_body = error_message
    end

    headers = {
      'X-RateLimit-Limit' => match_data[:limit].to_s,
      'X-RateLimit-Remaining' => '0',
      'X-RateLimit-Reset' => (now + seconds_before_next_try).to_s,
      'Content-Type' => content_type
    }

    [429, headers, [response_body]]
  end
end

安全名單

如果你有自己公司內網或是絕對不想被檔的 IP,可以放在 safelist

class Rack::Attack
  class Request < ::Rack::Request
    def allowed_ip?
      allowed_ips = ["127.0.0.1", "::1"]
      allowed_ips.include?(remote_ip)
    end
  end
  
  ...
  ...
  
  # Do not throttle for allowed IPs
  safelist('allow from localhost') do |req|
    req.allowed_ip?
  end
end

在 Proxy 之後

如果你的流量前面有一台 proxy 或是 Cloudflare,要更有效防止 DDOS 的話,必須知道他的真正 IP,所以我們可以 overwrite Rack::Attack 內的 remote_ip

class Rack::Attack
  class Request < ::Rack::Request
    def remote_ip
      # Cloudflare stores remote IP in CF_CONNECTING_IP header
      @remote_ip ||= (env['HTTP_CF_CONNECTING_IP'] ||
                      env['action_dispatch.remote_ip'] ||
                      ip).to_s
    end
  end
end

記錄被檔下的流量

為了避免我們真的誤判,或是想要研究到底是哪些人被檔 Rack::Attack 也支援 track

ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req|
  if req.env["rack.attack.match_type"] == :throttle
    Rails.logger.info "[Rack::Attack][Blocked]" <<
                      "remote_ip: \"#{req.remote_ip}\"," <<
                      "path: \"#{req.path}\", " <<
                      "headers: #{request_headers.inspect}"
  end
end

記錄下來觀察幾天,就可以在 log 裡面用 grep 找想找的資訊了

grep -c "max_req" log/production.log

grep -c "allow2ban" log/production.log

grep -B 3 -A 2 "pentesters" puma.log

參考資源

comments powered by Disqus