test: cover MiddlewareRateLimitingPStoreStoreTest and teardown
This commit is contained in:
1 parent
0d777aaaca
commit
3e4dc3987b
1 file changed
+138
@@ -0,0 +1,138 @@
|
||||
require 'minitest/autorun'
|
||||
require 'prism'
|
||||
require 'tmpdir'
|
||||
require 'fileutils'
|
||||
require 'support'
|
||||
|
||||
# Session 85 wired Prism::RateLimiter::PStoreStore into examples/blog and
|
||||
# proved durability with a real two-process demo (blog_rate_limit_
|
||||
# persistence_demo.rb), but test_middleware_rate_limiting.rb only ever
|
||||
# exercises the default MemoryStore - so Middleware::RateLimiting itself
|
||||
# was never unit-tested against the durable store it actually ships with
|
||||
# in the example app. This file closes that gap: same middleware, same
|
||||
# rule DSL, but every counter/ban lives in a PStoreStore file on disk, and
|
||||
# the durability claim itself is proven in-process by dropping the store
|
||||
# object and opening a brand new one against the same path (the cheapest
|
||||
# faithful stand-in for "the process restarted").
|
||||
class MiddlewareRateLimitingPStoreStoreTest < Minitest::Test
|
||||
include TestSupport
|
||||
|
||||
def setup
|
||||
# Minitest runs every test method from every loaded file in one
|
||||
# randomized order within a single process, so Prism::RateLimiter's
|
||||
# module-level store must be put back exactly as found - otherwise a
|
||||
# later test in test_middleware_rate_limiting.rb or
|
||||
# test_rate_limiter.rb would inherit this file's PStoreStore pointed
|
||||
# at a tmpdir this test already deleted, and blow up with ENOENT.
|
||||
@original_store = Prism::RateLimiter.store
|
||||
@dir = Dir.mktmpdir('prism-rate-limit-pstore-')
|
||||
@path = File.join(@dir, 'rate_limits.pstore')
|
||||
# Assign the new store BEFORE calling reset! - reset! resets whatever
|
||||
# store is currently set, and it must not touch @original_store's file.
|
||||
Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path)
|
||||
Prism::RateLimiter.reset!
|
||||
@downstream_calls = 0
|
||||
downstream = lambda do |_env|
|
||||
@downstream_calls += 1
|
||||
[200, {}, ['ok']]
|
||||
end
|
||||
@app = Prism::Middleware::RateLimiting.new(downstream)
|
||||
end
|
||||
|
||||
def teardown
|
||||
Prism::RateLimiter.reset!
|
||||
Prism::RateLimiter.store = @original_store
|
||||
FileUtils.remove_entry(@dir)
|
||||
end
|
||||
|
||||
def request(ip: '1.1.1.1', path: '/login')
|
||||
env = env_for('POST', path)
|
||||
env['REMOTE_ADDR'] = ip
|
||||
@app.call(env)
|
||||
end
|
||||
|
||||
def test_requests_under_the_limit_pass_through
|
||||
Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] }
|
||||
|
||||
2.times do
|
||||
status, = request
|
||||
assert_equal 200, status
|
||||
end
|
||||
assert_equal 2, @downstream_calls
|
||||
end
|
||||
|
||||
def test_request_over_the_limit_is_throttled_with_retry_after
|
||||
Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] }
|
||||
|
||||
2.times { request }
|
||||
status, headers, body = request
|
||||
|
||||
assert_equal 429, status
|
||||
assert_equal '60', headers['Retry-After']
|
||||
assert_includes body.join, 'Too Many Requests'
|
||||
assert_equal 2, @downstream_calls, 'the throttled request must not reach the app'
|
||||
end
|
||||
|
||||
def test_blocklisted_request_is_rejected_with_403_before_reaching_app
|
||||
Prism::RateLimiter.blocklist('scanners') { |req| req.path == '/etc/passwd' }
|
||||
|
||||
status, = request(path: '/etc/passwd')
|
||||
assert_equal 403, status
|
||||
assert_equal 0, @downstream_calls
|
||||
end
|
||||
|
||||
def test_fail2ban_style_blocklist_bans_after_repeated_offenses
|
||||
Prism::RateLimiter.blocklist('fail2ban pentesters') do |req|
|
||||
Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do
|
||||
req.path.include?('/etc/passwd')
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal 200, request(ip: '5.5.5.5', path: '/etc/passwd').first
|
||||
assert_equal 403, request(ip: '5.5.5.5', path: '/etc/passwd').first
|
||||
assert_equal 403, request(ip: '5.5.5.5', path: '/harmless').first, 'stays banned without a new offense'
|
||||
end
|
||||
|
||||
def test_throttle_state_survives_a_simulated_process_restart
|
||||
Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] }
|
||||
|
||||
2.times { assert_equal 200, request.first }
|
||||
|
||||
# Simulate the process dying and a brand new one booting: a fresh
|
||||
# PStoreStore instance against the same file, with no in-memory state
|
||||
# inherited from the object above. The rule DSL (throttles/blocklists)
|
||||
# is process-local config re-declared on boot, exactly like
|
||||
# examples/blog/app.rb re-declares its throttles every start; only the
|
||||
# counters/bans themselves need to survive on disk.
|
||||
Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path)
|
||||
Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] }
|
||||
|
||||
status, headers, = request
|
||||
assert_equal 429, status, 'the window counter must have been read back from disk, not reset to zero'
|
||||
assert_equal '60', headers['Retry-After']
|
||||
end
|
||||
|
||||
def test_ban_state_survives_a_simulated_process_restart
|
||||
Prism::RateLimiter.blocklist('fail2ban pentesters') do |req|
|
||||
Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do
|
||||
req.path.include?('/etc/passwd')
|
||||
end
|
||||
end
|
||||
|
||||
# maxretry: 2 - the 1st offense only records a strike (still let
|
||||
# through), the 2nd offense crosses the threshold and this same
|
||||
# request is already rejected.
|
||||
assert_equal 200, request(ip: '7.7.7.7', path: '/etc/passwd').first
|
||||
assert_equal 403, request(ip: '7.7.7.7', path: '/etc/passwd').first
|
||||
|
||||
Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path)
|
||||
Prism::RateLimiter.blocklist('fail2ban pentesters') do |req|
|
||||
Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do
|
||||
req.path.include?('/etc/passwd')
|
||||
end
|
||||
end
|
||||
|
||||
status, = request(ip: '7.7.7.7', path: '/harmless')
|
||||
assert_equal 403, status, 'the ban must have been read back from disk, not lost with the old store object'
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user