diff --git a/app/controllers/concerns/cached_responses.rb b/app/controllers/concerns/cached_responses.rb index 6a2e612..911b235 100644 --- a/app/controllers/concerns/cached_responses.rb +++ b/app/controllers/concerns/cached_responses.rb @@ -57,8 +57,9 @@ module CachedResponses ActiveSupport::JSON.encode([ controller_path, request.fullpath, request.base_url, request.user_agent, request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale, - Current.user.id, Current.session.token, session.to_hash, - request.env["action_controller.csrf_token"], + # Tokens are hydrated per request, including clients that replay an old + # cookie. Their raw CSRF secret does not select a presentation variant. + Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"), cookies.to_h.except("_campfire_session", "session_token") ]) end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 4cd81a0..9bca40b 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -16,7 +16,10 @@ class MessagesController < ApplicationController # Creator, body and boost edits can change HTML without touching messages. # Masked CSRF tokens remain fresh while the presentation validator stays stable. fresh_when etag: Digest::SHA256.hexdigest(csrf_neutral_body(body, "")), template: false - self.response_body = body unless performed? + unless performed? + response.content_type = "text/html" + self.response_body = body + end else head :no_content end diff --git a/test/controllers/cached_responses_test.rb b/test/controllers/cached_responses_test.rb index 20c1262..b481454 100644 --- a/test/controllers/cached_responses_test.rb +++ b/test/controllers/cached_responses_test.rb @@ -6,6 +6,7 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest setup do host! "once.campfire.test" sign_in :david + @login_cookie = cookies["_campfire_session"] @previous_forgery = ActionController::Base.allow_forgery_protection ActionController::Base.allow_forgery_protection = true @previous_cache = Rails.cache @@ -38,6 +39,31 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest end end + test "pagination caches the completed HTML rather than silently bypassing admission" do + get room_messages_url(@room) + assert_response :success + assert_equal "text/html", response.media_type + MessagesController.any_instance.expects(:find_paged_messages).never + get room_messages_url(@room) + assert_response :success + end + + test "clients without a persisted CSRF session still reuse token-neutral HTML" do + cookies["_campfire_session"] = @login_cookie + get room_url(@room) + first = css_select('meta[name="csrf-token"]').first["content"] + cookies["_campfire_session"] = @login_cookie + ResponseCache.instance.expects(:write).never + get room_url(@room) + second = css_select('meta[name="csrf-token"]').first["content"] + assert_response :success + assert_not_equal first, second + assert_no_match /campfire-csrf-/, response.body + post room_messages_url(@room, format: :turbo_stream), params: { + authenticity_token: second, message: { body: "fresh replay token works", client_message_id: "cache-replay-token" } } + assert_response :success + end + test "cached tokens stay fresh and literal token-like text survives" do get room_url(@room) literal = css_select('meta[name="csrf-token"]').first["content"]