From bce1b6078f4de481e7878d3c1448d6ac75c8dacd Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Wed, 25 Jun 2025 12:29:56 -0400 Subject: [PATCH] Add rack attack (#360) --- Gemfile | 3 ++ Gemfile.lock | 3 ++ config/application.rb | 1 + config/initializers/rack_attack.rb | 64 ++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 config/initializers/rack_attack.rb diff --git a/Gemfile b/Gemfile index 9031f67..844cdd8 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,9 @@ gem "thruster", require: false # For query count tracking gem "query_count" +# Rate limiting +gem "rack-attack" + # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" diff --git a/Gemfile.lock b/Gemfile.lock index 221c723..8f0504f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -346,6 +346,8 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.1.16) + rack-attack (6.7.0) + rack (>= 1.0, < 4) rack-cors (3.0.0) logger rack (>= 3.0.14) @@ -581,6 +583,7 @@ DEPENDENCIES propshaft puma (>= 5.0) query_count + rack-attack rack-cors rack-mini-profiler rails (~> 8.0.2) diff --git a/config/application.rb b/config/application.rb index 395ea29..8258a84 100644 --- a/config/application.rb +++ b/config/application.rb @@ -48,5 +48,6 @@ module Harbor httponly: true config.middleware.use HtmlCompressor::Rack + config.middleware.use Rack::Attack end end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 0000000..d1ff1a7 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,64 @@ +# config/initializers/rack_attack.rb + +class Rack::Attack + # Always allow requests from localhost + # (blocklist & throttles are skipped) + Rack::Attack.safelist("allow from localhost") do |req| + # Requests are allowed if the return value is truthy + "127.0.0.1" == req.ip || "::1" == req.ip + end + + # Allow an IP address to make 5 requests per second + throttle("req/ip", limit: 300, period: 5.minutes) do |req| + req.ip + end + + # Allow an IP address to make 5 POST requests per second + throttle("post/ip", limit: 60, period: 5.minutes) do |req| + req.ip if req.post? + end + + # Throttle requests to /login by IP address + throttle("login/ip", limit: 5, period: 20.seconds) do |req| + if req.path == "/login" && req.post? + req.ip + end + end + + # Throttle requests to /api by IP address + throttle("api/ip", limit: 100, period: 5.minutes) do |req| + if req.path.start_with?("/api") + req.ip + end + end + + # Log blocked requests + ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, request_id, payload| + req = payload[:request] + + case name + when "rack_attack.throttle" + Rails.logger.warn "[Rack::Attack][Throttle] IP: #{req.ip}, Path: #{req.path}, Discriminator: #{payload[:discriminator]}, Matched: #{payload[:matched]}" + when "rack_attack.blocklist" + Rails.logger.warn "[Rack::Attack][Blocklist] IP: #{req.ip}, Path: #{req.path}, Discriminator: #{payload[:discriminator]}, Matched: #{payload[:matched]}" + when "rack_attack.safelist" + Rails.logger.info "[Rack::Attack][Safelist] IP: #{req.ip}, Path: #{req.path}, Discriminator: #{payload[:discriminator]}, Matched: #{payload[:matched]}" + end + end + + # Custom response for throttled requests + self.throttled_response = lambda do |env| + retry_after = (env["rack.attack.match_data"] || {})[:period] + [ + 429, + { + "Content-Type" => "application/json", + "Retry-After" => retry_after.to_s, + "X-RateLimit-Limit" => env["rack.attack.matched"].to_s, + "X-RateLimit-Remaining" => "0", + "X-RateLimit-Reset" => (Time.now + retry_after).to_i.to_s + }, + [ { error: "Too Many Requests", message: "Rate limit exceeded. Try again later." }.to_json ] + ] + end +end