102 lines
5.5 KiB
Ruby
102 lines
5.5 KiB
Ruby
# Runs ONE phase of the rate-limiter persistence lifecycle against
|
|
# examples/blog, over a real HTTP request cycle. Always invoked as a brand
|
|
# new `ruby` process by blog_rate_limit_persistence_demo.rb (never required
|
|
# directly) - the whole point is that Prism::RateLimiter.store is now a
|
|
# Prism::RateLimiter::PStoreStore (see examples/blog/app.rb), so a ban or a
|
|
# throttle window tripped by one process must still be in effect for a
|
|
# completely fresh process that never made any of the earlier requests
|
|
# itself, exactly like a real deploy restarting the app mid-attack.
|
|
#
|
|
# Covers both throttles examples/blog declares against the same
|
|
# PStoreStore file (logins/ip and signups/ip) - session 85 only proved
|
|
# logins/ip durability; signups/ip shares the store but was never
|
|
# exercised across a real restart of its own.
|
|
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
|
require 'prism'
|
|
require 'net/http'
|
|
require_relative 'examples/blog/app'
|
|
|
|
def cookie_from(response)
|
|
lines = response.get_fields('set-cookie') || []
|
|
lines.map { |line| line.split(';').first }.join('; ')
|
|
end
|
|
|
|
def csrf_token_from(html)
|
|
html[/name="csrf-token" content="([^"]+)"/, 1]
|
|
end
|
|
|
|
phase = ARGV[0]
|
|
|
|
server = Prism::Server.new(Blog, port: 0).start
|
|
port = server.port
|
|
|
|
Net::HTTP.start('127.0.0.1', port) do |http|
|
|
home = http.get('/')
|
|
cookie = cookie_from(home)
|
|
token = csrf_token_from(home.body)
|
|
login_body = URI.encode_www_form(
|
|
'user[email]' => 'nobody@example.com', 'user[password]' => 'wrong password', 'authenticity_token' => token
|
|
)
|
|
# Mismatched confirmation is a validation failure (422) that never
|
|
# touches the uniqueness index, so the same body can be POSTed
|
|
# repeatedly without tripping User's unique-email constraint instead of
|
|
# the throttle we actually want to exercise.
|
|
signup_body = URI.encode_www_form(
|
|
'user[email]' => 'nobody@example.com', 'user[password]' => 'secret123',
|
|
'user[password_confirmation]' => 'does not match', 'authenticity_token' => token
|
|
)
|
|
|
|
case phase
|
|
when 'trip'
|
|
# logins/ip is throttle(limit: 5, period: 60) in examples/blog/app.rb -
|
|
# attempts 1..5 are the credentials check itself (422, wrong password),
|
|
# attempt 6 never reaches the controller at all.
|
|
5.times do |i|
|
|
attempt = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "trip attempt #{i + 1}: expected 422 (invalid credentials), got #{attempt.code}" unless attempt.code == '422'
|
|
end
|
|
sixth = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "trip attempt 6: expected 429 (throttled), got #{sixth.code}" unless sixth.code == '429'
|
|
puts 'trip: 5 attempts allowed through to the controller, the 6th throttled with 429 - process now exiting'
|
|
when 'confirm_still_blocked'
|
|
# Brand new process, brand new Ruby-level RateLimiter::Rule registry
|
|
# (throttles are re-declared by requiring app.rb again) and a brand new
|
|
# in-memory window if the store were MemoryStore - the ONLY way this
|
|
# first request can already be 429, with zero attempts made by THIS
|
|
# process, is if the PStoreStore file from the 'trip' phase is still
|
|
# being read.
|
|
immediate = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
unless immediate.code == '429'
|
|
raise "expected the very first request from a fresh process to already be throttled (durable state), got #{immediate.code}"
|
|
end
|
|
raise 'the still-blocked response should carry a Retry-After header' if immediate['retry-after'].nil?
|
|
puts 'confirm_still_blocked: a fresh process inherited the ban/window from the previous process via PStoreStore - ' \
|
|
"first request already 429, Retry-After=#{immediate['retry-after']}"
|
|
when 'trip_signup'
|
|
# signups/ip is throttle(limit: 3, period: 60) in examples/blog/app.rb -
|
|
# attempts 1..3 are the validation check itself (422, password
|
|
# mismatch), attempt 4 never reaches the controller at all.
|
|
3.times do |i|
|
|
attempt = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "trip_signup attempt #{i + 1}: expected 422 (mismatched confirmation), got #{attempt.code}" unless attempt.code == '422'
|
|
end
|
|
fourth = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "trip_signup attempt 4: expected 429 (throttled), got #{fourth.code}" unless fourth.code == '429'
|
|
puts 'trip_signup: 3 attempts allowed through to the controller, the 4th throttled with 429 - process now exiting'
|
|
when 'confirm_signup_still_blocked'
|
|
# Same durability claim as confirm_still_blocked above, for the
|
|
# independent signups/ip throttle rule.
|
|
immediate = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
unless immediate.code == '429'
|
|
raise "expected the very first signup request from a fresh process to already be throttled (durable state), got #{immediate.code}"
|
|
end
|
|
raise 'the still-blocked response should carry a Retry-After header' if immediate['retry-after'].nil?
|
|
puts 'confirm_signup_still_blocked: a fresh process inherited the signups/ip window from the previous process via ' \
|
|
"PStoreStore - first request already 429, Retry-After=#{immediate['retry-after']}"
|
|
else
|
|
raise "unknown phase #{phase.inspect} (expected trip, confirm_still_blocked, trip_signup or confirm_signup_still_blocked)"
|
|
end
|
|
end
|
|
|
|
server.stop
|