From 40bbd834589050107400955f500abc5a5e93fe71 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 17:17:18 +0200 Subject: [PATCH] feat: add cookie_from and csrf_token_from --- blog_session_secret_rotation_worker.rb | 139 +++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 blog_session_secret_rotation_worker.rb diff --git a/blog_session_secret_rotation_worker.rb b/blog_session_secret_rotation_worker.rb new file mode 100644 index 0000000..c6ceca6 --- /dev/null +++ b/blog_session_secret_rotation_worker.rb @@ -0,0 +1,139 @@ +# Runs ONE phase of the SESSION secret rotation lifecycle against +# examples/blog, over a real HTTP request cycle. Always invoked as a brand +# new `ruby` process by blog_session_secret_rotation_demo.rb (never required +# directly) - Application#enable_sessions reads PRISM_BLOG_SESSION_SECRET/ +# PRISM_BLOG_SESSION_PREVIOUS_SECRETS once at boot, so rotating them for real +# needs a fresh process, not just a different in-process config call. +# +# Mirrors blog_rotation_worker.rb's three-phase shape (provision -> rotate +# -> retire), but for Prism::Middleware::Session/Prism::CookieJar's +# `previous_secrets:` instead of Prism::Orm::Encryption's `previous_keys:`. +# The key difference this phase split deliberately surfaces: an encrypted +# attribute migrates onto the new key the moment its row is next SAVED, +# while a session cookie only gets re-signed onto the new secret when the +# session itself is next MUTATED - a page view alone (no session write) +# leaves the cookie signed under whatever secret was current when it was +# last written. User A creates a post during the transition window (a real +# session write, via the flash notice it sets) and survives full cutover; +# user B never does anything else after signing up and is correctly logged +# out once the secret they were signed in under is fully retired. +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'prism' +require 'net/http' +require 'fileutils' +require_relative 'examples/blog/app' + +COOKIE_A_V1_PATH = File.join(DB_DIR, 'cookie_a_v1.txt') +COOKIE_B_V1_PATH = File.join(DB_DIR, 'cookie_b_v1.txt') +COOKIE_A_V2_PATH = File.join(DB_DIR, 'cookie_a_v2.txt') + +EMAIL_A = 'rotation-a@example.com'.freeze +EMAIL_B = 'rotation-b@example.com'.freeze +PASSWORD = 'correct horse battery staple v2'.freeze + +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 + +def sign_up(http, email) + home = http.get('/') + cookie = cookie_from(home) + token = csrf_token_from(home.body) + body = URI.encode_www_form( + 'user[email]' => email, 'user[password]' => PASSWORD, 'user[password_confirmation]' => PASSWORD, + 'authenticity_token' => token + ) + response = http.post('/users', body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected sign-up for #{email} to redirect, got #{response.code}" unless response.code == '302' + cookie_from(response) +end + +phase = ARGV[0] + +server = Prism::Server.new(Blog, port: 0).start +port = server.port + +case phase +when 'signup' + Net::HTTP.start('127.0.0.1', port) do |http| + File.write(COOKIE_A_V1_PATH, sign_up(http, EMAIL_A)) + puts 'signup: user A signed up and signed in under v1 - will write to their session during the rotation window' + + File.write(COOKIE_B_V1_PATH, sign_up(http, EMAIL_B)) + puts 'signup: user B signed up and signed in under v1 too - deliberately left untouched from here on, stranded on v1' + end +when 'rotate' + Net::HTTP.start('127.0.0.1', port) do |http| + cookie_a = File.read(COOKIE_A_V1_PATH).strip + cookie_b = File.read(COOKIE_B_V1_PATH).strip + + # The app is now running with v2 as the CURRENT session secret and v1 + # only as a previous_secrets fallback - both users' cookies were never + # touched since 'signup' wrote them under v1, so both still working here + # is the real proof that Middleware::Session's rotation fallback holds + # over a genuine request cycle, not just a unit-test fixture. + edit_a = http.get('/account/edit', 'Cookie' => cookie_a) + raise "expected user A's v1-signed session to still authenticate via previous_secrets, got #{edit_a.code}" unless edit_a.code == '200' + + edit_b = http.get('/account/edit', 'Cookie' => cookie_b) + raise "expected user B's v1-signed session to still authenticate via previous_secrets, got #{edit_b.code}" unless edit_b.code == '200' + puts 'rotate: both v1-signed sessions still authenticate under v2-current/v1-previous' + + # Creating a post is a real session WRITE (it sets flash[:notice] before + # redirecting) - the natural way an ordinary user's session actually + # gets re-signed onto the current secret, exactly as + # Prism::Session.decode's rotation comment describes ("the next time it + # changes at all"). Unlike Orm::Encryption there is no explicit "resave" + # step to call; any mutating request does it for free. + edit_token = csrf_token_from(edit_a.body) + create_body = URI.encode_www_form( + 'post[title]' => 'Written during the secret rotation window', + 'post[body]' => 'This post exists to force a session write while v2 is current.', + 'authenticity_token' => edit_token + ) + created = http.post('/posts', create_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie_a) + raise "expected creating a post to redirect, got #{created.code}" unless created.code == '302' + + cookie_a_v2 = cookie_from(created) + raise 'expected the flash-notice write to actually re-sign the session cookie' if cookie_a_v2.empty? + raise 'expected the re-signed cookie to differ from the original v1-signed one' if cookie_a_v2 == cookie_a + File.write(COOKIE_A_V2_PATH, cookie_a_v2) + puts 'rotate: user A wrote to their session (created a post) and got re-signed onto v2 in the same response' + + # Deliberately NOT touched again: user B's cookie stays exactly as + # 'signup' left it, still signed under v1 only. + puts 'rotate: user B deliberately left untouched (still v1-signed on the wire)' + end +when 'post_rotation_final' + Net::HTTP.start('127.0.0.1', port) do |http| + cookie_a_v2 = File.read(COOKIE_A_V2_PATH).strip + cookie_b_v1 = File.read(COOKIE_B_V1_PATH).strip + + # v1 has been fully retired (PRISM_BLOG_SESSION_PREVIOUS_SECRETS is + # unset for this phase) - user A's session, re-signed onto v2 during the + # 'rotate' phase, must still authenticate with nothing but v2 configured. + edit_a = http.get('/account/edit', 'Cookie' => cookie_a_v2) + raise "expected user A's rotated (v2-signed) session to survive full cutover, got #{edit_a.code}" unless edit_a.code == '200' + puts 'post_rotation_final: user A (session written during the transition window) still authenticated with v1 fully retired' + + # The risk this whole scenario documents: a session nobody ever wrote to + # again during the transition window is still signed under the secret + # that just got fully retired - Prism::Session.decode has nothing left + # that verifies it, so it silently comes back as an empty session (not + # a crash), and the very next protected page redirects to the login + # screen exactly as it would for any other signed-out visitor. + edit_b = http.get('/account/edit', 'Cookie' => cookie_b_v1) + raise "expected user B's never-rewritten v1-only session to be logged out after full cutover, got #{edit_b.code}" unless edit_b.code == '302' + raise 'expected the logged-out redirect to point at the login page' unless edit_b['Location'].to_s.end_with?('/session/new') + puts 'post_rotation_final: user B (never wrote to their session again) correctly logged out after v1 was fully retired' + end +else + raise "unknown phase #{phase.inspect} (expected signup, rotate, or post_rotation_final)" +end + +server.stop