Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Integrate rack attack #1499

Draft
wants to merge 18 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ APP_HOST=lvh.me:5250
RECAPTCHA_SITE_KEY=6Lc6BAAAAAAAAChqRbQZcn_yyyyyyyyyyyyyyyyy
RECAPTCHA_SECRET_KEY=6Lc6BAAAAAAAAKN3DRm6VA_xxxxxxxxxxxxxxxxx
SECRET_KEY_BASE='38c72586473e364229897f24f1892f1dc5565776878aa4d8c6bf051258622bd2e923b926ab59b40f912b661216f764d993e8d6b8bbfbc33026e5c954b6c51f9b'
REQUEST_PER_MINUTE=5
ERROR_PER_MINUTE=3
PERIOD_MULTIPLIER=2
VIOLET_SERVICE_TIMEOUT=80
2 changes: 2 additions & 0 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
env:
RUBY_BUILD: ${{ matrix.ruby_version }}
SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }}
REQUEST_PER_MINUTE: 5
ERROR_PER_MINUTE: 3
services:
db:
image: postgres:12.3-alpine
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ gem 'stripe-rails'

gem 'devise-two-factor', "4.0.2"

gem 'rack-attack'

gem "slowpoke"

gem "strong_migrations"
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ GEM
rack (2.2.3.1)
rack-accept (0.4.5)
rack (>= 0.4)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-mini-profiler (3.0.0)
Expand Down Expand Up @@ -548,6 +550,7 @@ DEPENDENCIES
pg (~> 1.1)
pry
puma (~> 5.6)
rack-attack
rack-cors
rack-mini-profiler (~> 3.0)
rails (~> 6.1.5)
Expand Down
12 changes: 12 additions & 0 deletions app/mailers/rack_attack_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class RackAttackMailer < ApplicationMailer
def limit_exceeded(user, error_limit_exceeded = false)
@user = user
@error_limit_exceeded = error_limit_exceeded
mail(
to: [user.email],
bcc: User.where(global_admin: true).pluck(:email),
subject: I18n.t("rack_attack.mailer.limit_exceeded.subject.#{@error_limit_exceeded ? 'error_limit_exceeded' : 'request_limit_exceeded'}"),
content_type: "text/html",
)
end
end
9 changes: 9 additions & 0 deletions app/views/rack_attack_mailer/limit_exceeded.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<p><%= t('rack_attack.mailer.limit_exceeded.greeting', user: @user.name || 'User') %></p>

<p><%= t("rack_attack.mailer.limit_exceeded.message.#{@error_limit_exceeded ? 'error_limit_exceeded' : 'request_limit_exceeded'}", time: "12 hours") %></p>

<p><%= t('rack_attack.mailer.limit_exceeded.unblock_instructions') %></p>

<p><%= t('rack_attack.mailer.limit_exceeded.closing') %></p>

<p><%= t('rack_attack.mailer.limit_exceeded.regards') %><br>
89 changes: 89 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Rack::Attack.cache.store = ActiveSupport::Cache::RedisStore.new(ENV[REDIS_URL])
class Rack::Attack
MAX_THROTTLE_LEVEL = 5
REQUEST_LIMIT = ENV['REQUEST_PER_MINUTE'].to_i.nonzero? || 100
ERROR_LIMIT = ENV['ERROR_PER_MINUTE'].to_i.nonzero? || 20
MULTIPLIER = ENV['PERIOD_MULTIPLIER'].to_i.nonzero? || 2

# When REQUEST_PER_MINUTE = 100
# PERIOD_MULTIPLIER = 2
# Allows 100 requests/IP in 1 minute - 100 requests in first 1 minute
# 200 requests/IP in 2 minutes - 100 requests in next 1 minute
# 300 requests/IP in 4 minutes - 100 requests in next 2 minutes
# 400 requests/IP in 8 minutes - 100 requests in next 4 minutes
# 500 requests/IP in 16 minutes - 100 requests in next 8 minutes
# 600 requests/IP in 32 minutes - 100 requests in next 16 minutes
#
# Ban IP for 12 hours if all 5 levels are activated

# https://github.com/rack/rack-attack/blob/main/docs/advanced_configuration.md#exponential-backoff
(0..MAX_THROTTLE_LEVEL).each do |level|
throttle("req/ip/#{level}",
:limit => (REQUEST_LIMIT * (level + 1)),
:period => (MULTIPLIER ** level)*60) do |req|
req.ip unless (req.env['warden'].user&.global_admin || req.path.start_with?('/rails/active_storage'))
end
end

Rack::Attack.blocklist("ban/ip}".to_sym) do |req|
Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 0, findtime: (MULTIPLIER ** (MAX_THROTTLE_LEVEL + 1))*60, bantime: 12.hours) do
!req.env['warden'].user&.global_admin && !req.path.start_with?('/rails/active_storage') && configuration.throttles["req/ip/#{MAX_THROTTLE_LEVEL}"].exceeded?(req)
end
end

def call(env)
return @app.call(env) if !self.class.enabled || env["rack.attack.called"]

env["rack.attack.called"] = true
env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
request = Rack::Attack::Request.new(env)

if configuration.safelisted?(request)
@app.call(env)
elsif configuration.blocklisted?(request)
configuration.blocklisted_responder.call(request)
elsif configuration.throttled?(request)
configuration.throttled_responder.call(request)
else
begin
configuration.tracked?(request)
return @app.call(env)
rescue StandardError => error
(0..MAX_THROTTLE_LEVEL).each do |level|
Rack::Attack.throttle("error_req/ip/#{level}",
:limit => (ERROR_LIMIT * (level + 1)),
:period => (MULTIPLIER ** level)*60) do |req|
req.ip unless (req.env['warden'].user&.global_admin || req.path.start_with?('/rails/active_storage'))
end
end

Rack::Attack.blocklist("ban_error/ip}".to_sym) do |req|
Rack::Attack::Allow2Ban.filter("ban_error/ip/#{req.ip}", maxretry: 0, findtime: (MULTIPLIER ** (MAX_THROTTLE_LEVEL + 1))*60, bantime: 12.hours) do
!req.env['warden'].user&.global_admin && !req.path.start_with?('/rails/active_storage') && configuration.throttles["error_req/ip/#{MAX_THROTTLE_LEVEL}"].exceeded?(req)
end
end
raise error
end
end
end
end

ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |name, start, finish, request_id, payload|
user = payload[:request].env['warden'].user
RackAttackMailer.limit_exceeded(user, payload[:request].env["rack.attack.matched"] == :"ban_error/ip}" ).deliver_later if user.present?
end

class Rack::Attack::Throttle
def exceeded?(request)
discriminator = discriminator_for(request)
return false unless discriminator

current_period = period_for(request)
current_limit = limit_for(request)

key = [Time.now.to_i / current_period, name, discriminator].join(':')
count = cache.read(key).to_i

count >= current_limit
end
end
13 changes: 13 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,16 @@
en:
hello: "Hello world"
receiving_notifications_because_mentioned: "You're receiving notifications because you've been mentioned in this thread."
rack_attack:
mailer:
limit_exceeded:
greeting: "Dear %{user},"
unblock_instructions: "To unblock your account, please refrain from sending any further requests until the ban time is over."
closing: "Thank you for your cooperation."
regards: "Best regards,"
message:
error_limit_exceeded: "Your account has been temporarily blocked for %{time} due to exceeding the limit of errors multiple times allowed on our platform. We apologize for any inconvenience caused."
request_limit_exceeded: "Your account has been temporarily blocked for %{time} due to exceeding the limit of requests multiple times allowed on our platform. We apologize for any inconvenience caused."
subject:
error_limit_exceeded: "Error limit exceeded"
request_limit_exceeded: "Request limit exceeded"
Loading
Loading