Skip to content

Commit 7740e48

Browse files
authored
Merge pull request #252 from UWM-Libraries/feature/rack-attack
Feature/rack attack
2 parents 5ed5449 + 1182e91 commit 7740e48

File tree

6 files changed

+117
-2
lines changed

6 files changed

+117
-2
lines changed

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,6 @@ gem "logger", "1.6.0"
149149

150150
# Nokogiri
151151
gem "nokogiri", "~> 1.17.0"
152+
153+
# Rack Attack
154+
gem "rack-attack"

Gemfile.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,8 @@ GEM
368368
nio4r (~> 2.0)
369369
racc (1.8.1)
370370
rack (3.1.14)
371+
rack-attack (6.7.0)
372+
rack (>= 1.0, < 4)
371373
rack-proxy (0.7.7)
372374
rack
373375
rack-session (2.1.1)
@@ -613,6 +615,7 @@ DEPENDENCIES
613615
nokogiri (~> 1.17.0)
614616
passenger (>= 5.0.25)
615617
puma (~> 6.6)
618+
rack-attack
616619
rackup (~> 2.0)
617620
rails (~> 7.2.1)
618621
redis (~> 5.4)

config/environments/development.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
config.action_controller.perform_caching = true
2626
config.action_controller.enable_fragment_cache_logging = true
2727

28-
config.cache_store = :memory_store
28+
config.cache_store = :redis_cache_store, {
29+
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1"),
30+
namespace: "geodiscovery"
31+
}
2932
config.public_file_server.headers = {
3033
"Cache-Control" => "public, max-age=#{2.days.to_i}"
3134
}

config/environments/production.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@
5959
config.log_tags = [:request_id]
6060

6161
# Use a different cache store in production.
62-
# config.cache_store = :mem_cache_store
62+
config.cache_store = :redis_cache_store, {
63+
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1"),
64+
namespace: "geodiscovery"
65+
}
6366

6467
# Use a real queuing backend for Active Job (and separate queues per environment).
6568
# config.active_job.queue_adapter = :resque

config/initializers/rack_attack.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
class Rack::Attack
2+
### Configure Cache ###
3+
4+
# If you don't want to use Rails.cache (Rack::Attack's default), then
5+
# configure it here.
6+
#
7+
# Note: The store is only used for throttling (not blocklisting and
8+
# safelisting). It must implement .increment and .write like
9+
# ActiveSupport::Cache::Store
10+
11+
Rack::Attack.cache.store = Rails.cache
12+
13+
### Throttle Spammy Clients ###
14+
15+
# If any single client IP is making tons of requests, then they're
16+
# probably malicious or a poorly-configured scraper. Either way, they
17+
# don't deserve to hog all of the app server's CPU. Cut them off!
18+
#
19+
# Note: If you're serving assets through rack, those requests may be
20+
# counted by rack-attack and this throttle may be activated too
21+
# quickly. If so, enable the condition to exclude them from tracking.
22+
23+
# Throttle all requests by IP (60rpm)
24+
#
25+
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
26+
throttle("req/ip", limit: 200, period: 5.minutes) do |req|
27+
req.ip # unless req.path.start_with?('/assets')
28+
end
29+
30+
# Set up custom logger for Rack::Attack
31+
RACK_ATTACK_LOGGER = Logger.new(
32+
Rails.root.join("log/rack_attack.log"),
33+
10, # keep 10 rotated files
34+
5 * 1024 * 1024 # 5 MB each
35+
)
36+
RACK_ATTACK_LOGGER.level = Logger::WARN
37+
38+
RACK_ATTACK_LOGGER.formatter = proc do |severity, datetime, progname, msg|
39+
"[#{datetime.utc.iso8601}] #{severity}: #{msg}\n"
40+
end
41+
42+
### Custom Throttle Response ###
43+
44+
# By default, Rack::Attack returns an HTTP 429 for throttled responses,
45+
# which is just fine.
46+
#
47+
# If you want to return 503 so that the attacker might be fooled into
48+
# believing that they've successfully broken your app (or you just want to
49+
# customize the response), then uncomment these lines.
50+
self.throttled_response = lambda do |env|
51+
req = Rack::Request.new(env)
52+
cache_key = "rack::attack:logged:#{req.ip}"
53+
54+
unless Rails.cache.exist?(cache_key)
55+
RACK_ATTACK_LOGGER.warn "Throttled IP #{req.ip} on path #{req.path}"
56+
Rails.cache.write(cache_key, true, expires_in: 5.minutes)
57+
end
58+
59+
[
60+
503,
61+
{"Content-Type" => "text/plain"},
62+
["Service temporarily unavailable. Please try again later.\n"]
63+
]
64+
end
65+
end

test/middleware/rack_attack_test.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
require "test_helper"
2+
3+
class RackAttackTest < ActionDispatch::IntegrationTest
4+
include ActiveSupport::Testing::TimeHelpers
5+
6+
setup do
7+
Rack::Attack.enabled = true
8+
Rack::Attack.reset!
9+
10+
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
11+
12+
Rack::Attack.throttle("test/req/ip", limit: 3, period: 60) { |req| req.ip }
13+
14+
Rack::Attack.throttled_responder = lambda do |_request|
15+
[
16+
503,
17+
{"Content-Type" => "text/plain"},
18+
["Service temporarily unavailable. Please try again later.\n"]
19+
]
20+
end
21+
end
22+
23+
teardown do
24+
Rack::Attack.enabled = false
25+
Rack::Attack.reset!
26+
end
27+
28+
test "blocks request after exceeding limit" do
29+
3.times do
30+
get "/robots.txt", headers: {"REMOTE_ADDR" => "1.2.3.4"}
31+
assert_response :success
32+
end
33+
34+
get "/robots.txt", headers: {"REMOTE_ADDR" => "1.2.3.4"}
35+
assert_response :service_unavailable
36+
assert_match(/Service temporarily unavailable/, response.body)
37+
end
38+
end

0 commit comments

Comments
 (0)