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. ...
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 "]] 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: "example5@gmail.com"}, "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: "example6@gmail.com"}, "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: "example7@gmail.com"}, "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: "example8@gmail.com"}, "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
-
https://github.com/kickstarter/rack-attack
-
http://blog.hayleyanderson.us/2015/06/05/using-and-testing-rack-attack-to-improve-the-security-of-your-rails-app/
-
http://www.mavengineering.com/blog/2014/06/20/how-to-test-rack-attack-with-rspec/
-
https://www.youtube.com/watch?v=m1UwxsZD6sw