Add rack attack (#360)

This commit is contained in:
Max Wofford 2025-06-25 12:29:56 -04:00 committed by GitHub
parent bdafa0f1b4
commit bce1b6078f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 71 additions and 0 deletions

View file

@ -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"

View file

@ -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)

View file

@ -48,5 +48,6 @@ module Harbor
httponly: true
config.middleware.use HtmlCompressor::Rack
config.middleware.use Rack::Attack
end
end

View file

@ -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