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.
This commit is contained in:
GPT on behalf of DHH
2026-10-07 20:02:20 +02:00
parent ee5fe3717a
commit ac73267b07
10 changed files with 381 additions and 2 deletions
+4
View File
@@ -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:
+1 -1
View File
@@ -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
@@ -0,0 +1,82 @@
module CachedResponses
extend ActiveSupport::Concern
CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze
CSRF_TAG = /<meta\b[^>]*\bname="csrf-token"[^>]*>|<input\b[^>]*\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?("<meta") ? "content" : "value"
tag.sub(/\b#{attribute}="[^"]*"/, %(#{attribute}="#{marker}"))
end
end
end
+6 -1
View File
@@ -4,6 +4,7 @@ class MessagesController < ApplicationController
before_action :set_room, except: :create
before_action :set_message, only: %i[ show edit update destroy ]
before_action :ensure_can_administer, only: %i[ edit update destroy ]
around_action :cache_read_response, only: :index
layout false, only: :index
@@ -11,7 +12,11 @@ class MessagesController < ApplicationController
@messages = find_paged_messages
if @messages.any?
fresh_when @messages
body = render_to_string(:index)
# 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?
else
head :no_content
end
+1
View File
@@ -2,6 +2,7 @@ class RoomsController < ApplicationController
before_action :set_room, only: %i[ show destroy ]
before_action :ensure_can_administer, only: %i[ destroy ]
before_action :remember_last_room_visited, only: :show
around_action :cache_read_response, only: :show
def index
redirect_to room_url(Current.user.rooms.last)
+1
View File
@@ -1,4 +1,5 @@
class SearchesController < ApplicationController
around_action :cache_read_response, only: :index
before_action :set_messages
def index
@@ -1,5 +1,6 @@
class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
around_action :cache_read_response, only: :show
def show
visible_memberships = Current.user.memberships.visible
+89
View File
@@ -0,0 +1,89 @@
require "sqlite3"
# A read-only observer sees commits from every writer, including other runtimes.
# PRAGMA data_version can only be compared on the same persistent connection.
class ResponseCache
MAX_ENTRY_BYTES = 1.megabyte
MAX_KEY_BYTES = 2.kilobytes
def self.instance
@instance ||= new
end
def initialize
@mutex = Mutex.new
@entries = {}
@bytes = 0
end
def budget
[ ENV.fetch("CAMPFIRE_RESPONSE_CACHE_MB", "64").to_i, 0 ].max.megabytes
end
def version
@mutex.synchronize { current_version }
rescue SQLite3::Exception
clear
nil
end
def read(key, version)
@mutex.synchronize do
@entries[key]&.first if current_version == version
end
rescue SQLite3::Exception
clear
nil
end
def write(key, version, entry)
size = key.bytesize + entry[:body].bytesize + entry[:headers].sum { |name, value| name.bytesize + value.bytesize } + 256
return if key.bytesize > 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
+139
View File
@@ -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
+57
View File
@@ -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