From 791e052a64e2aa36430a2c01eac888cbb346386c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 23:03:11 +0200 Subject: [PATCH] feat: add cookie_from, csrf_token_from and run_phase --- blog_password_reset_demo.rb | 158 ++++++++++++++++++++++++++++ blog_rate_limit_persistence_demo.rb | 45 ++++++++ 2 files changed, 203 insertions(+) create mode 100644 blog_password_reset_demo.rb create mode 100644 blog_rate_limit_persistence_demo.rb diff --git a/blog_password_reset_demo.rb b/blog_password_reset_demo.rb new file mode 100644 index 0000000..ed36543 --- /dev/null +++ b/blog_password_reset_demo.rb @@ -0,0 +1,158 @@ +# End-to-end proof of PasswordResetsController (session 99: Prism::Orm:: +# TokenFor, generic ActiveRecord::TokenFor-lite built on MessageVerifier's +# new expires_in:/purpose: envelope) through real HTTP against the actual +# blog app - not just the isolated unit coverage in test_message_verifier.rb +# and test_orm_token_for.rb. Own scratch database directory and own +# in-memory rate limiter store, same pattern as +# blog_remember_sessions_demo.rb, so this never shares data or a throttle +# budget with blog_demo.rb or any other standalone demo script. +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'prism' +require 'net/http' +require 'fileutils' + +ROOT = __dir__ +SCRATCH_DB_DIR = File.join(ROOT, 'examples/blog/db/password_reset_demo') +FileUtils.rm_rf(SCRATCH_DB_DIR) +ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR + +require_relative 'examples/blog/app' + +server = Prism::Server.new(Blog, port: 0).start +port = server.port +puts "blog_password_reset_demo server on port #{port}" +puts '-' * 40 + +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] || html[/name="authenticity_token" value="([^"]+)"/, 1] +end + +EMAIL = 'forgetful@example.com' +OLD_PASSWORD = 'correct horse battery staple' +NEW_PASSWORD = 'a totally different passphrase' + +def sign_in(http, password) + page = http.get('/session/new') + cookie = cookie_from(page) + token = csrf_token_from(page.body) + body = URI.encode_www_form('user[email]' => EMAIL, 'user[password]' => password, 'authenticity_token' => token) + http.post('/session', body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) +end + +Net::HTTP.start('127.0.0.1', port) do |http| + new_session_page = http.get('/session/new') + cookie = cookie_from(new_session_page) + token = csrf_token_from(new_session_page.body) + signup_body = URI.encode_www_form( + 'user[email]' => EMAIL, 'user[password]' => OLD_PASSWORD, 'user[password_confirmation]' => OLD_PASSWORD, + 'authenticity_token' => token + ) + signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302' + puts "POST /users (sign up #{EMAIL}) -> #{signup.code}" + + worker = Prism::Jobs::Worker.new(Prism::Jobs.default_queue) + worker.work_off + Prism::Mailer.deliveries.clear + + puts '-' * 40 + puts 'requesting a reset link for a real account' + request_page = http.get('/password_resets/new') + raise "expected GET /password_resets/new to render, got #{request_page.code}" unless request_page.code == '200' + request_cookie = cookie_from(request_page) + request_token = csrf_token_from(request_page.body) + + request_body = URI.encode_www_form('password_reset[email]' => EMAIL, 'authenticity_token' => request_token) + requested = http.post('/password_resets', request_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => request_cookie) + raise "expected POST /password_resets to redirect, got #{requested.code}" unless requested.code == '302' + raise 'expected the redirect to land on the sign-in page' unless requested['location'] == '/session/new' + puts "POST /password_resets (email=#{EMAIL}) -> #{requested.code}, redirected to #{requested['location']}" + + processed = worker.work_off + raise 'expected exactly one password_reset email to have been delivered' unless Prism::Mailer.deliveries.size == 1 + delivery = Prism::Mailer.deliveries.last + raise "expected the delivery to go to #{EMAIL}, got #{delivery.to.inspect}" unless delivery.to == [EMAIL] + reset_path = delivery.html_body[%r{href="http://[^"]*(/password_resets/[^"]+)"}, 1] + raise 'expected an absolute reset link in the delivered email body' unless reset_path + reset_token = reset_path[%r{/password_resets/([^/]+)/edit}, 1] + raise 'expected to be able to pull the raw token out of the mailed reset link' unless reset_token + puts "worker processed #{processed} job(s); mailed reset link -> #{reset_path}" + + puts '-' * 40 + puts 'requesting a reset link for an email that has NO account - same response, no enumeration oracle' + unknown_body = URI.encode_www_form('password_reset[email]' => 'nobody@example.com', 'authenticity_token' => request_token) + unknown = http.post('/password_resets', unknown_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => request_cookie) + raise "expected the SAME 302/location for an unknown email, got #{unknown.code}/#{unknown['location']}" unless + unknown.code == '302' && unknown['location'] == requested['location'] + worker.work_off + raise 'expected zero emails for the unknown address' unless Prism::Mailer.deliveries.size == 1 + puts "POST /password_resets (unknown email) -> #{unknown.code}, identical redirect, no email actually sent" + + puts '-' * 40 + puts 'following the real mailed link to set a new password' + edit_page = http.get(reset_path) + raise "expected the mailed link to render the reset form, got #{edit_page.code}" unless edit_page.code == '200' + edit_cookie = cookie_from(edit_page) + edit_token = csrf_token_from(edit_page.body) + puts "GET #{reset_path} -> #{edit_page.code}" + + update_body = URI.encode_www_form( + '_method' => 'PATCH', 'password_reset[password]' => NEW_PASSWORD, + 'password_reset[password_confirmation]' => NEW_PASSWORD, 'authenticity_token' => edit_token + ) + updated = http.post(reset_path.sub('/edit', ''), update_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => edit_cookie) + raise "expected the password update to redirect, got #{updated.code}" unless updated.code == '302' + raise 'expected the update to redirect to sign-in' unless updated['location'] == '/session/new' + puts "PATCH #{reset_path.sub('/edit', '')} (new password) -> #{updated.code}" + + puts '-' * 40 + puts 'the OLD password no longer authenticates; the NEW one does' + old_attempt = sign_in(http, OLD_PASSWORD) + raise "expected the old password to be rejected (422), got #{old_attempt.code}" unless old_attempt.code == '422' + puts " POST /session with the OLD password -> #{old_attempt.code} (rejected)" + + new_attempt = sign_in(http, NEW_PASSWORD) + raise "expected the new password to sign in (302), got #{new_attempt.code}" unless new_attempt.code == '302' + puts " POST /session with the NEW password -> #{new_attempt.code} (signed in)" + + puts '-' * 40 + puts 'the SAME reset link cannot be replayed - resetting a password changes password_digest, which is the ' \ + "fingerprint User.generates_token_for(:password_reset) baked into the token, so it's a one-time link " \ + 'without any separate "used" column' + replayed_edit = http.get(reset_path) + raise "expected the already-used link to redirect away instead of rendering, got #{replayed_edit.code}" unless + replayed_edit.code == '302' + raise 'expected the replay to land back on the request-a-new-link page' unless + replayed_edit['location'] == '/password_resets/new' + puts " GET #{reset_path} again -> #{replayed_edit.code}, redirected to #{replayed_edit['location']} (dead link)" + + puts '-' * 40 + puts "checking the password_resets/ip throttle (app.rb, limit 3/60s) - can't be hammered into an email-bomb" + # The "real account" and "unknown email" requests above already spent 2 of + # the 3-per-60s budget on this same IP, so only ONE more request here + # still succeeds before every following one is throttled. + 3.times do |i| + body = URI.encode_www_form('password_reset[email]' => EMAIL, 'authenticity_token' => request_token) + attempt = http.post('/password_resets', body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => request_cookie) + expected = i.zero? ? '302' : '429' + raise "attempt #{i + 1}: expected #{expected}, got #{attempt.code}" unless attempt.code == expected + raise 'the throttled attempt should carry a Retry-After header' if i == 1 && attempt['retry-after'].nil? + puts " attempt #{i + 1}/3 (5th/6th/7th POST /password_resets overall) -> " \ + "#{attempt.code}#{i.zero? ? '' : " (throttled, Retry-After: #{attempt['retry-after']})"}" + end +end + +puts '-' * 40 +puts 'blog_password_reset_demo done: forgot-password link is mailed via the real background job queue, resolves ' \ + 'through real HTTP to a working password change, the old password stops working and the new one starts, ' \ + 'the exact same link cannot be replayed afterwards, an unknown email gets an identical non-committal ' \ + 'response, and the request endpoint itself is rate-limited.' diff --git a/blog_rate_limit_persistence_demo.rb b/blog_rate_limit_persistence_demo.rb new file mode 100644 index 0000000..258fe31 --- /dev/null +++ b/blog_rate_limit_persistence_demo.rb @@ -0,0 +1,45 @@ +# Exercises Prism::RateLimiter::PStoreStore (wired into examples/blog in +# this session, see app.rb) as a real, multi-process lifecycle: trip the +# logins/ip throttle from one OS process, kill it, then boot a completely +# independent second process and prove its very first request already +# comes back throttled - the durability MemoryStore could never give, +# proven the same way blog_rotation_demo.rb proves key rotation: each +# phase is a genuinely separate `ruby` invocation (see +# blog_rate_limit_worker.rb), not just a different in-process call. +# +# Repeats the same lifecycle for signups/ip, the OTHER throttle rule +# sharing this PStoreStore file - a prior session proved only logins/ip +# survives a restart; nothing had shown the two rules do not somehow +# clobber each other's window in the same file (they are keyed by rule +# name, not just by IP, but that was only true by reading the code). +require 'fileutils' +require 'rbconfig' + +ROOT = __dir__ +DB_DIR = File.join(ROOT, 'examples/blog/db/rate_limit_demo') +RUBY = RbConfig.ruby + +# Fresh scratch database every run, isolated from examples/blog/db/ +# development.pstore (blog_demo.rb's own data) - re-running this file is +# always deterministic and never fights blog_demo.rb for the same throttle +# window. +FileUtils.rm_rf(DB_DIR) + +def run_phase(phase) + puts '-' * 60 + puts "phase: #{phase}" + pid = Process.spawn({ 'PRISM_BLOG_DB_DIR' => DB_DIR }, RUBY, '-Ilib', 'blog_rate_limit_worker.rb', phase, chdir: ROOT) + _, status = Process.wait2(pid) + raise "phase #{phase} failed with #{status}" unless status.success? +end + +run_phase('trip') +run_phase('confirm_still_blocked') +run_phase('trip_signup') +run_phase('confirm_signup_still_blocked') + +puts '-' * 60 +puts 'blog_rate_limit_persistence_demo done: both the login throttle and the independent signup throttle ' \ + 'tripped by one process were still enforced by a completely independent later process against the ' \ + 'same PStoreStore file - a plain process restart (deploy, crash, worker respawn) does not hand an ' \ + 'attacker a fresh throttle window for either rule, and the two rules did not clobber each other.'