From ac73267b072b67e4c7389acf20222e457737520f Mon Sep 17 00:00:00 2001 From: GPT on behalf of DHH <2741+dhh@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:02:20 +0200 Subject: [PATCH] Reuse authorized read pages without stale presentation or CSRF masks Transfer the C completed-response cache lesson into Rails, keeping authentication, room checks and cookies per request. A persistent read-only SQLite observer detects local and foreign commits and rejects racing admission. Whole-page misses render fresh to avoid stale nested fragments; message ETags reflect token-neutral presentation. --- README.md | 4 + app/controllers/application_controller.rb | 2 +- app/controllers/concerns/cached_responses.rb | 82 +++++++++++ app/controllers/messages_controller.rb | 7 +- app/controllers/rooms_controller.rb | 1 + app/controllers/searches_controller.rb | 1 + app/controllers/users/sidebars_controller.rb | 1 + app/models/response_cache.rb | 89 ++++++++++++ test/controllers/cached_responses_test.rb | 139 +++++++++++++++++++ test/models/response_cache_test.rb | 57 ++++++++ 10 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 app/controllers/concerns/cached_responses.rb create mode 100644 app/models/response_cache.rb create mode 100644 test/controllers/cached_responses_test.rb create mode 100644 test/models/response_cache_test.rb diff --git a/README.md b/README.md index 4eba778..eb049a2 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ If you'd rather run the Docker image yourself, you can read more about that in t > that people have someone to contact if they need help with their account. If that bothers you, put in any > email address you want and create yourself a new admin account. +Authenticated room, message, sidebar and search pages use a bounded 64 MiB cache per worker. +Set `CAMPFIRE_RESPONSE_CACHE_MB=0` to disable it. Every request still checks authentication +and room access; commits from any SQLite writer invalidate pages, and CSRF masks stay fresh. + ## Other implementations Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C: diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5e60f1e..9ad6c7c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,4 @@ class ApplicationController < ActionController::Base - include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders + include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName end diff --git a/app/controllers/concerns/cached_responses.rb b/app/controllers/concerns/cached_responses.rb new file mode 100644 index 0000000..6a2e612 --- /dev/null +++ b/app/controllers/concerns/cached_responses.rb @@ -0,0 +1,82 @@ +module CachedResponses + extend ActiveSupport::Concern + + CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze + CSRF_TAG = /]*\bname="csrf-token"[^>]*>|]*\bname="authenticity_token"[^>]*>/ + + included do + prepend_before_action :capture_response_cache_version + end + + def perform_caching + super && !@rendering_uncached_response + end + + private + def capture_response_cache_version + if request.get? && ResponseCache.instance.budget.positive? + @response_cache_version = ResponseCache.instance.version + end + end + + # Register after room authorization, but before presentation queries. + def cache_read_response + if cacheable_read_request? + token = form_authenticity_token + key = response_cache_key + original_session = session.to_hash.deep_dup + + if key.bytesize <= ResponseCache::MAX_KEY_BYTES && (entry = ResponseCache.instance.read(key, @response_cache_version)) + response.headers.merge!(entry[:headers]) + self.response_body = entry[:body].gsub(entry[:marker], token) + else + render_fresh_response { yield } + if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session + marker = "campfire-csrf-#{SecureRandom.hex(32)}" + # Replace only framework token attributes, never a matching token in + # message text. Postprocessing also leaves fragment caches untouched. + body = csrf_neutral_body(response.body, marker) + headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze + ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze) + end + end + else + render_fresh_response { yield } + end + end + + def cacheable_read_request? + @response_cache_version && request.get? && request.format.html? && Current.session && + !authenticated_by.bot_key? && flash.empty? && + !request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] && + !Rails.application.config.content_security_policy_nonce_generator && + !ActiveRecord::Base.connection.transaction_open? + end + + def response_cache_key + 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"], + cookies.to_h.except("_campfire_session", "session_token") + ]) + end + + def render_fresh_response + # Foreign SQL can change content without bumping fragment timestamps. + # A whole-page miss renders fresh instead of creating unbounded Redis + # fragment namespaces for every database commit. + @rendering_uncached_response = true + yield + ensure + @rendering_uncached_response = false + end + + def csrf_neutral_body(body, marker) + body.gsub(CSRF_TAG) do |tag| + attribute = tag.start_with?(" MAX_KEY_BYTES || size > [ budget, MAX_ENTRY_BYTES ].min + + @mutex.synchronize do + return unless current_version == version + return if @entries.key?(key) + + while @entries.any? && @bytes + size > budget + _, (_, removed_size) = @entries.shift + @bytes -= removed_size + end + @entries[key] = [ entry, size ] + @bytes += size + end + rescue SQLite3::Exception + clear + end + + def clear + @mutex.synchronize do + @entries.clear + @bytes = 0 + @observer&.close + @observer = @database = @version = nil + end + end + + private + def current_version + database = File.expand_path(ActiveRecord::Base.connection_db_config.database) + if @database != database || !@observer + @entries.clear + @bytes = 0 + @observer&.close + @observer = nil + @observer = SQLite3::Database.new(database, readonly: true) + @database = database + @namespace = SecureRandom.hex(16) + @version = nil + end + + version = @observer.get_first_value("PRAGMA data_version") + if @version != version + @entries.clear + @bytes = 0 + @version = version + end + [ @database, @namespace, @version ] + end +end diff --git a/test/controllers/cached_responses_test.rb b/test/controllers/cached_responses_test.rb new file mode 100644 index 0000000..20c1262 --- /dev/null +++ b/test/controllers/cached_responses_test.rb @@ -0,0 +1,139 @@ +require "test_helper" + +class CachedResponsesTest < ActionDispatch::IntegrationTest + self.use_transactional_tests = false + + setup do + host! "once.campfire.test" + sign_in :david + @previous_forgery = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + @previous_cache = Rails.cache + @previous_caching = ActionController::Base.perform_caching + Rails.cache = ActiveSupport::Cache::MemoryStore.new + ActionController::Base.perform_caching = true + @room = rooms(:watercooler) + # Establish last-room and CSRF cookies before checking reuse. + 2.times { get room_url(@room) } + ResponseCache.instance.clear + end + + teardown do + ResponseCache.instance.clear + ActionController::Base.allow_forgery_protection = @previous_forgery + Rails.cache = @previous_cache + ActionController::Base.perform_caching = @previous_caching + end + + test "all four read actions reuse completed pages" do + urls = [ room_url(@room), room_messages_url(@room), user_sidebar_url(:me), searches_url(q: "hello") ] + urls.each do |url| + get url + assert_response :success + end + ResponseCache.instance.expects(:write).never + urls.each do |url| + get url + assert_response :success + end + 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"] + @room.messages.create!(creator: users(:david), body: "literal #{literal}") + get room_url(@room) + first = css_select('meta[name="csrf-token"]').first["content"] + get room_url(@room) + second = css_select('meta[name="csrf-token"]').first["content"] + assert_not_equal first, second + assert_includes response.body, "literal #{literal}" + assert_no_match /campfire-csrf-/, response.body + + post room_messages_url(@room, format: :turbo_stream), params: { + authenticity_token: second, message: { body: "cached token works", client_message_id: "cache-token" } } + assert_response :success + end + + test "local and foreign commits invalidate pages and nested fragments" do + message = @room.messages.ordered.last + get room_url(@room) + @room.messages.create!(creator: users(:david), body: "local commit") + get room_url(@room) + assert_includes response.body, "local commit" + + foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign creator", message.creator_id) + get room_url(@room) + assert_includes response.body, "Foreign creator" + foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign message body", message.id) + get room_url(@room) + assert_includes response.body, "foreign message body" + end + + test "cached room access and sessions are checked afresh" do + get room_url(@room) + foreign_write("DELETE FROM memberships WHERE room_id = ? AND user_id = ?", @room.id, users(:david).id) + get room_url(@room) + assert_redirected_to root_url + get user_sidebar_url(:me) + foreign_write("DELETE FROM sessions WHERE user_id = ?", users(:david).id) + get user_sidebar_url(:me) + assert_redirected_to new_session_url + end + + test "conditional requests keep native validators" do + get room_messages_url(@room) + etag = response.headers["ETag"] + assert etag.present? + get room_messages_url(@room), headers: { "If-None-Match" => etag } + assert_response :not_modified + end + + test "foreign presentation changes cannot return a false not-modified response" do + get room_messages_url(@room) + etag = response.headers["ETag"] + message = @room.messages.ordered.last + foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "changed presentation", message.id) + get room_messages_url(@room), headers: { "If-None-Match" => etag } + assert_response :success + assert_includes response.body, "changed presentation" + assert_not_equal etag, response.headers["ETag"] + end + + test "origins and frame variants have separate entries" do + get room_url(@room) + ResponseCache.instance.expects(:write).twice + get room_url(@room), headers: { "Turbo-Frame" => "different-frame" } + host! "once.campfire.test:8081" + get room_path(@room) + assert_response :success + end + + test "cache can be disabled" do + ResponseCache.instance.stubs(:budget).returns(0) + ResponseCache.instance.expects(:read).never + 2.times { get room_url(@room) } + assert_response :success + end + + test "a commit after authentication cannot admit captured user fields" do + Users::SidebarsController.any_instance.stubs(:set_version_headers).with do + foreign_write("UPDATE users SET name = ? WHERE id = ?", "During authentication", users(:david).id) + true + end + get user_sidebar_url(:me) + Users::SidebarsController.any_instance.unstub(:set_version_headers) + ResponseCache.instance.expects(:write).once + get user_sidebar_url(:me) + assert_includes response.body, "During authentication" + end + + private + def foreign_write(sql, *bindings) + SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database| + database.execute(sql, bindings) + end + # Fixtures share this thread's query cache outside the request executor. + ActiveRecord::Base.clear_query_caches_for_current_thread + end +end diff --git a/test/models/response_cache_test.rb b/test/models/response_cache_test.rb new file mode 100644 index 0000000..8dc6e43 --- /dev/null +++ b/test/models/response_cache_test.rb @@ -0,0 +1,57 @@ +require "test_helper" +require "tmpdir" + +class ResponseCacheTest < ActiveSupport::TestCase + setup do + @directory = Dir.mktmpdir + @database = File.join(@directory, "cache.sqlite3") + SQLite3::Database.new(@database) do |database| + database.execute("PRAGMA journal_mode=WAL") + database.execute("CREATE TABLE values_for_test (value TEXT)") + end + ActiveRecord::Base.stubs(:connection_db_config).returns(Struct.new(:database).new(@database)) + @cache = ResponseCache.new + @cache.stubs(:budget).returns(1024) + end + + teardown do + @cache.clear + FileUtils.remove_entry(@directory) + end + + test "foreign commits expire entries and reject old render admission" do + version = @cache.version + @cache.write("page", version, entry("first")) + assert_equal "first", @cache.read("page", version)[:body] + SQLite3::Database.new(@database) { |database| database.execute("INSERT INTO values_for_test VALUES ('committed')") } + assert_nil @cache.read("page", version) + @cache.write("page", version, entry("old in-flight render")) + assert_nil @cache.read("page", @cache.version) + @cache.write("page", @cache.version, entry("fresh")) + assert_equal "fresh", @cache.read("page", @cache.version)[:body] + end + + test "byte budget evicts oldest and bypasses oversized entries and keys" do + version = @cache.version + @cache.stubs(:budget).returns(512) + @cache.write("first", version, entry("a" * 100)) + @cache.write("second", version, entry("b" * 100)) + assert_nil @cache.read("first", version) + assert_equal "b" * 100, @cache.read("second", version)[:body] + @cache.write("oversized", version, entry("c" * 1000)) + assert_nil @cache.read("oversized", version) + @cache.write("k" * 2049, version, entry("small")) + assert_nil @cache.read("k" * 2049, version) + end + + test "observer namespaces cannot reuse old shared fragment keys after restart" do + version = @cache.version + @cache.clear + assert_not_equal version, @cache.version + end + + private + def entry(body) + { body: body, marker: "unexposed-marker", headers: {} } + end +end