Skip to content
Merged
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
12 changes: 8 additions & 4 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,19 @@ class Rack::Attack
# bad intent. We are assuming good intent and thus provide users with info
# about how many requests are allowed in a time period and how to remove
# those restrictions by registering.
Rack::Attack.throttled_responder = lambda do |env|
match_data = env['rack.attack.match_data']
now = match_data[:epoch_time]
# Rack::Attack 6.x calls throttled_responder with a request object.
# Read throttle metadata from request.env rather than from a raw env hash.
Rack::Attack.throttled_responder = lambda do |request|
match_data = request.env['rack.attack.match_data'] || {}
now = match_data[:epoch_time] || Time.now.to_i
period = match_data[:period].to_i
reset = period.positive? ? (now + (period - now % period)) : now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a world where we extract lines 85-88 to some helper method to make the assembly of this value more separate / testable? I really like the approach of calculating reset separately from the invocation of it down on line 94, but working out the logic of how we got there took me a minute.


headers = {
'Content-Type' => 'application/json',
'RateLimit-Limit' => match_data[:limit].to_s,
'RateLimit-Remaining' => '0',
'RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
'RateLimit-Reset' => reset.to_s
}

body = {
Expand Down
52 changes: 52 additions & 0 deletions test/initializers/rack_attack_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require 'test_helper'

class RackAttackTest < ActiveSupport::TestCase
test 'throttled_responder accepts Rack::Attack::Request and returns 429 response' do
env = {
'rack.attack.match_data' => {
limit: 100,
count: 101,
period: 60,
epoch_time: 1_700_000_000
}
}

request = Rack::Attack::Request.new(env)

status, headers, body = Rack::Attack.throttled_responder.call(request)

assert_equal 429, status
assert_equal 'application/json', headers['Content-Type']
assert_equal '100', headers['RateLimit-Limit']
assert_equal '0', headers['RateLimit-Remaining']
assert_equal '1700000040', headers['RateLimit-Reset']

parsed_body = JSON.parse(body.first)
assert_equal '100', parsed_body['request_limit']
assert_equal '101', parsed_body['request_count']
assert_equal true, parsed_body.key?('error')
end

test 'safelist allows authenticated request with valid bearer token and user' do
user = users(:yo)
request = Rack::Attack::Request.new('HTTP_AUTHORIZATION' => 'Bearer valid.token')

JwtWrapper.stubs(:decode).with('valid.token').returns({ 'user_id' => user.id })

assert_equal true, Rack::Attack.configuration.safelisted?(request)
end

test 'safelist rejects invalid bearer token' do
request = Rack::Attack::Request.new('HTTP_AUTHORIZATION' => 'Bearer invalid.token')

JwtWrapper.stubs(:decode).with('invalid.token').raises(JWT::DecodeError)

assert_equal false, Rack::Attack.configuration.safelisted?(request)
end

test 'safelist rejects non-bearer authorization strategy' do
request = Rack::Attack::Request.new('HTTP_AUTHORIZATION' => 'Basic abc123')

assert_equal false, Rack::Attack.configuration.safelisted?(request)
end
end