mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-08 15:50:08 +09:00
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:
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user