Skip to content

Commit

Permalink
added rack_attack
Browse files Browse the repository at this point in the history
  • Loading branch information
simonfranzen committed Sep 19, 2020
1 parent 828aa28 commit 90c236b
Show file tree
Hide file tree
Showing 6 changed files with 438 additions and 3 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ gem 'image_processing', '~> 1.2' # Image processing
gem 'mini_magick' # Image manipulation with rmagick
gem 'friendly_id', '5.3.0' # Auto generate slugs for resources
gem 'foreman'
gem 'rack-attack' # request limiter and ip blocker

# I18n
gem 'rails-i18n', '~> 6.0.0'
Expand Down Expand Up @@ -71,6 +72,7 @@ group :test do
gem 'shoulda-matchers', '4.0.0.rc1'
gem 'simplecov', require: false
gem 'i18n-spec'
gem 'timecop'
end

group :development do
Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ GEM
pg (1.1.4)
puma (3.12.6)
rack (2.2.3)
rack-attack (6.3.1)
rack (>= 1.0, < 3)
rack-cors (1.0.5)
rack (>= 1.6.0)
rack-pjax (1.1.0)
Expand Down Expand Up @@ -329,6 +331,7 @@ GEM
thor (0.20.3)
thread_safe (0.3.6)
tilt (2.0.10)
timecop (0.9.1)
tty-color (0.5.0)
tty-pager (0.12.1)
strings (~> 0.1.4)
Expand Down Expand Up @@ -375,6 +378,7 @@ DEPENDENCIES
mini_magick
pg
puma (~> 3.12)
rack-attack
rack-cors
rails (~> 6.0.3)
rails-controller-testing
Expand All @@ -391,6 +395,7 @@ DEPENDENCIES
simplecov
spring
spring-watcher-listen (~> 2.0.0)
timecop

RUBY VERSION
ruby 2.6.5p114
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This boilerplate works like a charm with the following gems:
- graphql-auth
- graphql-errors
- rack-cors
- rack_attack
- rails_admin
- cancancan
- image_processing
Expand Down Expand Up @@ -269,8 +270,17 @@ Open test coverage results with

We are using the wonderful [rubocop](https://github.com/rubocop-hq/rubocop-rails) to lint and auto fix the code. Install the rubocop VSCode extension to get best experience during development.

### 16. Security with Rack Attack
See `config/initializers/rack_attack.rb` file. We have defined a common set of rules to block users trying to access the application multiple times with wrong credentials, or trying to create a hundreds requests per minute.

### 16. Sending emails
To speed up tests add this to your `.env.test`

```
ATTACK_REQUEST_LIMIT=30
ATTACK_AUTHENTICATED_REQUEST_LIMIT=30
```

### 17. Sending emails
Set your SMTP settings with these environment variables:
- `SMTP_ADDRESS`
- `SMTP_PORT`
Expand All @@ -287,7 +297,7 @@ Have a look at `config/environments/production.rb` where we set the `config.acti
Set the email address for your `ApplicationMailer` and devise emails with env var `DEVISE_MAILER_FROM`.


### 17. Deployment
### 18. Deployment
The project runs on every server with ruby installed. The only dependency is a PostgreSQL database. Create a block `production:` in the`config/database.yml` for your connection.

#### Heroku
Expand Down
211 changes: 211 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# frozen_string_literal: true

Rack::Attack.enabled = false if ENV['RACK_ATTACK_ENABLED'] == 'false'
# otherwise default ON

# rubocop:disable Metrics/ClassLength
module Rack
# Define roles for blocking users
class Attack
def self.user_session?(req)
# stored in cookies
if req.env['rack.request.cookie_hash'] && req.env['rack.request.cookie_hash']['user']
user = JSON.parse(req.env['rack.request.cookie_hash']['user'])
return true if user && user['email'].present?
end

# devise is storing user id in rack.session after a valid authentication
req.env['rack.session'] &&
req.env['rack.session']['warden.user.user.key'] &&
req.env['rack.session']['warden.user.user.key'][0][0]
end

# 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
req.ip == '127.0.0.1' || req.ip == '::1'
end

# Always allow requests from localhost
# (blocklist & throttles are skipped)
whitelist = ENV['RACK_WHITELIST'] ? ENV['RACK_WHITELIST'].split(',') : []
Rack::Attack.safelist('allow from ENV ATTACK_WHITELIST') do |req|
# Requests are allowed if the return value is truthy
whitelist.include?(req.ip)
end

# If any single client IP is making tons of requests, then they're
# probably malicious or a poorly-configured scraper. Either way, they
# don't deserve to hog all of the app server's CPU. Cut them off!
#
# Note: If you're serving assets through rack, those requests may be
# counted by rack-attack and this throttle may be activated too
# quickly. If so, enable the condition to exclude them from tracking.

# Throttle all requests by IP
request_limit = (ENV['ATTACK_REQUEST_LIMIT'] || 300).to_i
request_period = (ENV['ATTACK_REQUEST_PERIOD_IN_MINUTES'] || 5).to_i
ban_time = (ENV['ATTACK_REQUEST_BAN_TIME_IN_MINUTES'] || 30).to_i
(1..3).each do |level|
# level 1 -> 300 requests in 5 minutes (60rpm), ban for 30 minutes
# level 2 -> 600 requests in 25 minutes (24rpm), ban for 60 minutes
# level 3 -> 900 requests in 125 minutes (7.2rpm), ban for 90 minutes
throttle(
"request/ip/#{level}",
limit: request_limit * level,
period: (request_period**level).minutes,
bantime: (ban_time * level).minutes
) do |req|
req.ip if !req.path.start_with?('/assets') && !Rack::Attack.user_session?(req)
end
end

# Throttle authenticated requests by IP
request_limit = (ENV['ATTACK_AUTHENTICATED_REQUEST_LIMIT'] || 500).to_i
request_period = (ENV['ATTACK_AUTHENTICATED_REQUEST_PERIOD_IN_MINUTES'] || 5).to_i
ban_time = (ENV['ATTACK_AUTHENTICATED_REQUEST_BAN_TIME_IN_MINUTES'] || 10).to_i
(1..3).each do |level|
# level 1 -> 500 requests in 5 minutes (100rpm), ban for 10 minute
# level 2 -> 1000 requests in 25 minutes (40rpm), ban for 20 minutes
# level 3 -> 1500 requests in 125 minutes (12rpm), ban for 30 minutes
throttle(
"request/authenticated/ip/#{level}",
limit: request_limit * level,
period: (request_period**level).minutes,
bantime: (ban_time * level).minutes
) do |req|
req.ip if !req.path.start_with?('/assets') && Rack::Attack.user_session?(req)
end
end

### Prevent Brute-Force Attacks ###

# The most common brute-force login attack is a brute-force password
# attack where an attacker simply tries a large number of emails and
# passwords to see if any credentials match.
#
# Another common method of attack is to use a swarm of computers with
# different IPs to try brute-forcing a password for a specific account.

# Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}"
# LOGIN / SIGN UP

auth_limit = (ENV['ATTACK_AUTH_LIMIT'] || 30).to_i
auth_period = (ENV['ATTACK_AUTH_PERIOD_IN_MINUTES'] || 10).to_i
auth_ban_time = (ENV['ATTACK_AUTH_BAN_TIME_IN_MINUTES'] || 30).to_i

(1..3).each do |level|
# level 1 -> 30 auth requests in 10 minutes, ban for 30 minutes
# level 2 -> 60 auth requests in 100 minutes, ban for 60 minutes
# level 3 -> 90 auth requests per 1000 minutes (16,5 hours), ban for 120 minutes

# Devise sign_in
throttle(
"request/devise/ip/#{level}",
limit: auth_limit * level,
period: (auth_period**level).minutes,
bantime: (auth_ban_time * level).minutes
) do |req|
req.ip if req.path == '/users/sign_in' && (req.post? || req.put?)
end

# Devise password reset
throttle(
"request/devise/password/ip/#{level}",
limit: auth_limit * level,
period: (auth_period**level).minutes,
bantime: (auth_ban_time * level).minutes
) do |req|
req.ip if req.path == '/users/password' && (req.post? || req.put?)
end

# GraphQL login & signup via api
throttle(
"request/graphql/auth/ip/#{level}",
limit: auth_limit * level,
period: (auth_period**level).minutes,
bantime: (auth_ban_time * level).minutes
) do |req|
if req.path == '/graphql' && req.post? && req.body
params = JSON.parse(req.body.read)
req.body.rewind # needed a rewind after parsing it to JSON
if params['query'].include?('signIn') ||
params['query'].include?('signUp')
req.ip
end
end
end

# GraphQL password reset
throttle(
"request/graphql/password_reset/ip/#{level}",
limit: auth_limit * level,
period: (auth_period**level).minutes,
bantime: (auth_ban_time * level).minutes
) do |req|
if req.path == '/graphql' && req.post? && req.body
params = JSON.parse(req.body.read)
req.body.rewind # needed a rewind after parsing it to JSON
req.ip if params['query'].include?('resetPassword')
end
end
end

# Actions
# Limits actions like locking a user or create a message
public_action_limit = (ENV['ATTACK_PUBLIC_ACTION_LIMIT'] || 30).to_i
public_action_period = (ENV['ATTACK_PUBLIC_ACTION_PERIOD_IN_MINUTES'] || 60).to_i
public_action_ban_time = (ENV['ATTACK_PUBLIC_ACTION_BAN_TIME_IN_MINUTES'] || 30).to_i

throttle(
'request/public/action/ip',
limit: public_action_limit,
period: public_action_period.minutes,
bantime: public_action_ban_time.minutes
) do |req|
if req.path == '/graphql' && req.post? && req.body
params = JSON.parse(req.body.read)
req.body.rewind # needed a rewind after parsing it to JSON
if params['query'].include?('unlockAccount') ||
params['query'].include?('lockAccount') ||
params['query'].include?('createConversation') ||
params['query'].include?('createMessage')
req.ip
end
end
end

### Custom Throttle Response ###
# Add a helpful response about the rate limit for clients
# For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
# request.env['rack.attack.throttle_data'][name]
# => { discriminator: d, count: n, period: p, limit: l, epoch_time: t }
self.throttled_response = lambda do |_env|
# match_data = env['rack.attack.match_data']
# now = match_data[:epoch_time]

headers = {}

[429, headers, [{ 'errors': [{ 'message': 'Too many requests' }] }.to_json]]
end
end
end

# Error reporting
ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, _finish, _request_id, payload|
# request object available in payload[:request]
if %i[throttle blacklist].include? payload[:request].env['rack.attack.match_type']
error = [
payload[:request].env['rack.attack.match_type'],
name,
start,
payload[:request].ip,
payload[:request].request_method,
payload[:request].fullpath
].join(' ')
Rails.logger.warn error
# Rollbar.warning(error, payload[:request].env)
end
end
# rubocop:enable Metrics/ClassLength
3 changes: 2 additions & 1 deletion env_sample
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ SMTP_DOMAIN=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_AUTH=login
SMTP_ENABLE_STARTTLS_AUTO=true
SMTP_ENABLE_STARTTLS_AUTO=true
RACK_ATTACK_ENABLED=false
Loading

0 comments on commit 90c236b

Please sign in to comment.