Bảo vệ Rails app của bạn với Rack::Attack

Câu chuyện của mình được bắt đầu khi một trang web của mình đang chạy ngon ơ như bình thường, bỗng dưng vào 1 ngày đẹp trời mình ngồi vào xem report thì thấy có thời điểm lượng request tăng ầm ầm. Khá là bất ngờ và mình quyết định tìm tòi sâu hơn và thấy lượng request chủ yếu tới từ action login. Thế là ngửi thấy có mùi đó không "thơm" rồi, và mình tiếp tục xem báo cáo về login thì thấy được kết quả

Đúng vậy, mình đang ở trong 1 scenario bị dính login attack - 1 kiểu dùng Brute Force Attacks để hòng dò ra tài khoản & mật khẩu người dùng bằng cách thử liên tục các giá trị nhập vào login. Vì thử theo kiểu như vậy nên xác suất login fail là rất lớn, và đó lú do vì sao ở đồ thị trên, lượng request login unsuccessful lại vọt lên rất cao trong 1 đoạn thời gian ngắn như vậy. Ở đây có thể có 1 số bạn cho rằng, ui xời Brute Force Attacks mà lấy được pass là do lỗi người dùng đặt pass quá dễ, hệ thống mình bình thường vẫn chạy ngon là đc. Cách nghĩ đó có 1 phần đúng vì lỗi đặt pass đơn giản của người dùng.

Tuy nhiên có 1 vấn đề đặt ra ở đây là kể cả khi người dùng bạn đặt password tốt đi chăng nữa thì dưới 1 cuộc login attack như này, thì tài nguyên trên hệ thống của bạn đã 1 phần bị hao phí do request tự động này và kéo theo hệ lụy là các request đích thực của người dùng sẽ bị xử lý chậm hơn kéo theo trải nghiệm người dùng bị giảm và chắc chắn rằng điều này chả hay ho tẹo nào.

Vậy giải pháp là gì. Lúc đó tôi đã áp dụng giải pháp tầng ứng dụng đó là dùng recaptcha của google (các bạn có thể tham khảo ở bài viết này ) áp dụng cho trang login. Ok như vậy là về vấn đề an toàn cho tài khoản người dùng trước cuộc tấn công của các con bot login đã được giải quyết. Tuy nhiên vấn đề tốn performance vẫn là 1 vấn đề. Sau 1 thoài gian tìm hiểu thì mình đã phát hiện ra 1 gem rất hay đó là rack-attack

1. Cài đặt

Đầu tiên thêm gem 'rack-attack' vào Gemfile

#Gemfile
 gem 'rack-attack'

Sau đó chạy lệnh bundle để cài đặt gem

bundle install

Tiếp theo ta cần khai báo cho app sử dụng rack-attack middleware

# In config/application.rb
config.middleware.use Rack::Attack

Và thêm file rack-attack.rb vào thư mục config/initializers

# In config/initializers/rack-attack.rb
class Rack::Attack
  # thông số cấu hình cho rack-attack, sẽ được đề cập sau
end

Kế đến để sử dụng chức năng lọc theo throttling và fail2ban (mô tả sẽ được mô tả ở phần sau) các bạn cần khai báo sử dụng cache trên app rails. Mặc định ta sẽ dùng rails cache, các bạn có thể sử dụng các ứng dụng khách như mencache, redis ....

Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # mặc định dùng Rails.cache

2. Sử dụng

Rack-attack sẽ sử dụng 4 loại lọc request là safelists, blocklists, throttles, and tracks

  • Safelist là danh sách request được coi là hợp lệ và cho phép đi vào hệ thống
  • Blocklist là dach sách các request được coi là không hợp lệ và bị từ chối truy cập hệ thống
  • Throttle là danh sách 'ngưỡng', bình thường request sẽ đc chấp nhận nhưng khi số lượng request đạt đến mức nhật định (do ta cấu hình trong file rack-attack.rb) thì các request tiếp sau sẽ bị từ chối.
  • Track kiểm tra tất cả request sau đó requeset sẽ đc thông qua. Track ở đây không ảnh huwongr gì đến viecj xử lý request, mà chỉ là hỗ trợ việc lưu log request lên hệ thống xử lý mà thôi. Thuật toán xử lý của rack-attack như sau:
def call(env)
  req = Rack::Attack::Request.new(env)

  if safelisted?(req) #kiểm tra với safelist
    @app.call(env) # chuyển request cho hệ thống xử lý
  elsif blocklisted?(req) # kiểm tra với blocklist
    self.class.blocklisted_response.call(env) # block request và đưa ra phản hồi
  elsif throttled?(req) #kiểm tra với throttlelist
    self.class.throttled_response.call(env) # kiểm tra xem đã vượt hạn mức đề ra chưa và đưa ra hướng xử lý request
  else
    tracked?(req)
    @app.call(env)
  end
end

Khai báo cấu hình

  • Safelists
# luôn luôn cho phép truy cập từ local host
# (blocklist & throttles sẽ được bỏ qua, không phải kiểm tra)
Rack::Attack.safelist('allow from localhost') do |req|
  # những request từ local host sẽ trả giá trị true
  '127.0.0.1' == req.ip || '::1' == req.ip
end
  • Blocklist
# từ chối các request có địa chỉ ip là 1.2.3.4
Rack::Attack.blocklist('block 1.2.3.4') do |req|
  # Requests are blocked if the return value is truthy
  '1.2.3.4' == req.ip
end

# từ chối request logins từ các bad user agent
Rack::Attack.blocklist('block bad UA logins') do |req|
  req.path == '/login' && req.post? && req.user_agent == 'BadUA'
end
  • Thorttle

# ngưỡng block khi mà có > 5 request / 1s ứng với 1 IP
Rack::Attack.throttle('req/ip', :limit => 5, :period => 1.second) do |req|
  # nếu trả về giá trị thì cache ứng với request đó sẽ được tăng lên
  # sau đó được so sánh với giá trị ngưỡng. key lưu trong cache có dạng
  #   "rack::attack:#{Time.now.to_i/1.second}:req/ip:#{req.ip}"
  #
  # Nếu false, giá trị ở cache sẽ ko tăng
  req.ip
end

# ngưỡng login cho email parameter giới hạn 6 reqs/phút
# trả về giá trị email nếu đường dẫn là login và kiểu request là post
Rack::Attack.throttle('logins/email', :limit => 6, :period => 60.seconds) do |req|
  req.params['email'] if req.path == '/login' && req.post?
end

# Bạn có thể cài đặt giới hạn theo proc như sau
limit_proc = proc {|req| req.env["REMOTE_USER"] == "admin" ? 100 : 1} # giới hạn 100 lần
period_proc = proc {|req| req.env["REMOTE_USER"] == "admin" ? 1.second : 1.minute} #giới hạn là trong 1 phút
Rack::Attack.throttle('req/ip', :limit => limit_proc, :period => period_proc) do |req|
  req.ip
end

  • Track
# Giám sát các requests từ một user agent nhất định nào đó.
Rack::Attack.track("special_agent") do |req|
  req.user_agent == "SpecialAgent"
end

# tùy chọn limit và period, tạo ra thông báo khi mà đạt tới các giới hạn đề ra
Rack::Attack.track("special_agent", :limit => 6, :period => 60.seconds) do |req|
  req.user_agent == "SpecialAgent"
end

# Lưu log dựa vào ActiveSupport::Notification
ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, req|
  if req.env['rack.attack.matched'] == "special_agent" && req.env['rack.attack.match_type'] == :track
    Rails.logger.info "special_agent: #{req.path}" #lưu vào file log của app
    STATSD.increment("special_agent")
  end
end

  • Tùy chọn phản hồi khi từ chối request không hợp lệ

Rack::Attack.blocklisted_response = lambda do |env|
  # Sử dụng lỗi 503 để khiến kẻ tấn công tưởng rằng hắn đã DOS trang web thành công
  # Mặc định Rack::Attack trả về 403 nếu truy cập có trong blocklist
  [ 503, {}, ['Blocked']]
end

Rack::Attack.throttled_response = lambda do |env|
  #  Lưu ý: ở dây bạn có thể lấy thêm các thông tin trong dữ liệu như
  #  env['rack.attack.matched'],
  #  env['rack.attack.match_type'],
  #  env['rack.attack.match_data']

  # Sử dụng lỗi 503 để khiến kẻ tấn công tưởng rằng hắn đã DOS trang web thành công
  # Mặc định Rack::Attack trả về 429 nếu truy cập quá ngưỡng - thorttled
  [ 503, {}, ["Server Error\n"]]
end

Lưu ý: throttle thường yêu cầu kiểm tra với cache nên để tối ưu hiệu năng của máy chủ, hãy kiểm tra bằng thorttle hạn chế nhất có thể

Ok, cấu hình như vậy chắc các bạn đã phần nào hiểu được cách sử dụng gem rack-attack để bảo vệ app mình. Sau đây mình sẽ áp dụng thử 1 tính năng throttle và áp dụng viết test để test thử.

Đầu tiên minh cần khai báo cấu hình bộ lọc throttle như sau

class Rack::Attack
  # Throttle theo tần xuất request của IP address
  throttle('req/ip', limit: 20, period: 20.seconds) do |req|
    req.ip unless req.path.starts_with?('/assets')
  end
  # Throttle request login theo IP address
  throttle('logins/ip', limit: 5, period: 20.seconds) do |req|
    if req.path == '/admins/sign_in' && req.post?
      req.ip
    elsif req.path == '/users/sign_in' && req.post?
      req.ip
    end
  end
  # Throttle request login ứng với email address
  throttle("logins/email", limit: 5, period: 20.seconds) do |req|
    if req.path == '/admins/sign_in' && req.post?
      req.params['email'].presence
    elsif req.path == '/users/sign_in' && req.post?
      req.params['email'].presence
    end
  end
end

Lưu ý: Hãy nhớ là bạn đã bật cache Rails lên và config để sử dụng cache cho rack-attack như ở phần cấu hình ở đầu bài đã nói.

Để phục vụ việc test ta sẽ chỉ check throttle trong đoạn thời gian ngắn (20s) đồng thời ta cũng ko tính các request load asset. Ở đây mình chia riêng từng case cho mỗi kiểu login vì nếu gộp chung vào kết quả chạy sẽ không chuẩn.

Tiếp tới, ta sẽ cần vietes test để kiểm tra. File rsepc cơ bản cho rack-attack như sau


require 'rails_helper'
describe Rack::Attack do
  include Rack::Test::Methods
  def app
    Rails.application
  end
  # Your tests
end

Lưu ý khi viết test bạn nên thay đổi thông số cho mỗi trường hợp để đề phòng các test ảnh hưởng lẫn nhau dẫn đến kết quả test không chuẩn

Ok, bắt tay vào viết test thôi nào.

# test trường hợp ip truy cập trong 20s
describe "throttle excessive requests by IP address"
do let (: limit) {20}
  context "number of requests is lower than the limit" do
    it "does not change the request status" do
      limit.times do
        get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
        expect(last_response.status).to_not eq(429) # các request đều hợp lệ
      end
    end
  end
  context "number of requests is higher than the limit" do
    it "changes the request status to 429"do
      (limit * 2).times do |i |
        get "/", {}, "REMOTE_ADDR" => "1.2.3.5" # đôi ip để tránh trùng với ip đã test trước đó
        expect(last_response.status).to eq(429) if i > limit #từ request thứ 20 trở đi sẽ bị trả ra mã lỗi mặc định là 429
       end
     end
   end
 end
 # test trường hợp login
 describe "throttle excessive POST requests to admin sign in by IP address"do
 let (: limit) {5}
 # trường hợp số request trong điều kiện (<5 lần/s)
  context "number of requests is lower than the limit" do
    it "does not change the request status" do
      limit.times do |i |
        post "/admins/sign_in", {email: "example1#{i}@gmail.com"}, "REMOTE_ADDR" => "1.2.3.6"
        expect(last_response.status).to_not eq(429) # ko trả về lỗi
      end
    end
  end
  # trường hợp số request login vướt quá ngưỡng cho phép
  context "number of admin requests is higher than the limit" do
    it "changes the request status to 429" do
      (limit * 2).times do |i |
        post "/admins/sign_in", {email: "example2#{i}@gmail.com"}, "REMOTE_ADDR" => "1.2.3.8"
        expect(last_response.status).to eq(429) if i > limit # chỉ trả ra lỗi với cacs request vượt quá ngưỡng
      end
    end
  end
end
describe "throttle excessive POST requests to user sign in by IP address"do
  let (: limit) {5}
  context "number of requests is lower than the limit" do
    it "does not change the request status" do
      limit.times do |i | post "/users/sign_in", {email: "example3#{i}@gmail.com"}, "REMOTE_ADDR" =>  "1.2.3.7"
        expect(last_response.status).to_not eq(429)
      end
    end
  end
  context "number of user requests is higher than the limit" do
    it "changes the request status to 429" do(limit * 2).times do |i |
      post "/users/sign_in", {email: "example4#{i}@gmail.com"}, "REMOTE_ADDR" => "1.2.3.9"
      expect(last_response.status).to eq(429) if i > limit
    end
  end
end

# trường hợp check email đăng nhập vào hệ thống
describe "throttle excessive POST requests to admin sign in by email address" do
let (: limit) {5}
# số lần truy cập vẫn ở dưới ngưỡng cho phép
context "number of requests is lower than the limit"do
  it "does not change the request status" do
    limit.times do |i |
      post "/admins/sign_in", {email: "[email protected]"}, "REMOTE_ADDR" => "#{i}.2.4.9"
      expect(last_response.status).to_not eq(429) # không trả ra lỗi
      end
    end
  end
  # số truy câp theo email vượt ngưỡng cho phép
  context "number of requests is higher than the limit" do
    it "changes the request status to 429" do
    (limit * 2).times do |i |
      post "/admins/sign_in", {email: "[email protected]"}, "REMOTE_ADDR" => "#{i}.2.5.9"
      expect(last_response.status). to eq(429) if i > limit
      end
    end
  end
end
describe "throttle excessive POST requests to user sign in by email address"
  do let (: limit) {5}
  context "number of requests is lower than the limit" do
    it "does not change the request status" do
      limit.times do |i | post "/users/sign_in", {email: "[email protected]"}, "REMOTE_ADDR" => "#{i}.2.6.9"
      expect(last_response.status).to_not eq(429)
    end
  end
 end
 context "number of requests is higher than the limit" do
   it "changes the request status to 429"
     do(limit * 2).times do |i | post "/users/sign_in", {email: "[email protected]"}, "REMOTE_ADDR" => "#{i}.2.7.9"
    expect(last_response.status).to eq(429) if i > limit end
    end
  end
end

Ok chạy thử ta sẽ thấy các test đều pass là chuẩn.

PS: à mà câu chuyện mình kẻ ở phần đầu ko phải của mình đâu mà là của tác giả gem rack-attack đấy :p, nếu có hứng thú các bạn có thể xem bài thuyết trình full giới thiệu gem của tác giả trong ruby conference qua đường dẫn https://www.youtube.com/watch?v=m1UwxsZD6sw

Reference

All Rights Reserved