mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-10 00:30:13 +09:00
Use header-only forgery protection and cache complete responses
This commit is contained in:
@@ -7,7 +7,7 @@ module Authentication
|
||||
before_action :deny_bots
|
||||
helper_method :signed_in?
|
||||
|
||||
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
|
||||
protect_from_forgery using: :header_only, with: :exception, unless: -> { authenticated_by.bot_key? }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
require "zlib"
|
||||
|
||||
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"[^>]*>/
|
||||
CACHE_HEADERS = %w[ content-type content-encoding cache-control etag last-modified vary ].freeze
|
||||
|
||||
included do
|
||||
prepend_before_action :capture_response_cache_version
|
||||
@@ -27,8 +28,7 @@ module CachedResponses
|
||||
def combined_fragment_cache_key(key)
|
||||
@fragment_cache_namespace ||= [
|
||||
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
|
||||
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session),
|
||||
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
|
||||
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session)
|
||||
].freeze
|
||||
super([ @fragment_cache_namespace, key ])
|
||||
end
|
||||
@@ -43,8 +43,10 @@ module CachedResponses
|
||||
# Register after room authorization, but before presentation queries.
|
||||
def cache_read_response
|
||||
if cacheable_read_request?
|
||||
token = form_authenticity_token
|
||||
key = response_cache_key
|
||||
encoding = Rack::Utils.select_best_encoding(%w[ gzip identity ], Rack::Utils.q_values(request.headers["Accept-Encoding"]))
|
||||
return yield unless encoding
|
||||
|
||||
key = response_cache_key(encoding)
|
||||
original_session = session.to_hash.deep_dup
|
||||
|
||||
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
|
||||
@@ -57,14 +59,15 @@ module CachedResponses
|
||||
if !entry && ResponseCache.instance.version == @response_cache_version
|
||||
yield
|
||||
rendered = true
|
||||
cache_completed_response(key, original_session)
|
||||
entry = cache_completed_response(key, original_session, encoding)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if entry
|
||||
response.headers.merge!(entry[:headers])
|
||||
self.response_body = entry[:body].gsub(entry[:marker], token)
|
||||
response.headers.delete("Content-Length")
|
||||
self.response_body = entry[:body]
|
||||
elsif !rendered
|
||||
# A queued request retains its pre-auth snapshot. If it has expired,
|
||||
# render outside the stripe instead of blocking the next generation.
|
||||
@@ -75,14 +78,15 @@ module CachedResponses
|
||||
end
|
||||
end
|
||||
|
||||
def cache_completed_response(key, original_session)
|
||||
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)
|
||||
def cache_completed_response(key, original_session, encoding)
|
||||
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session && !response.headers["Content-Encoding"]
|
||||
body = encoding == "gzip" ? Zlib.gzip(response.body) : response.body
|
||||
response.headers["Content-Encoding"] = "gzip" if encoding == "gzip"
|
||||
response.headers["Vary"] = (response.headers["Vary"].to_s.split(/,\s*/) | [ "Accept-Encoding" ]).join(", ")
|
||||
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)
|
||||
entry = { body: body.freeze, headers: headers }.freeze
|
||||
ResponseCache.instance.write(key, @response_cache_version, entry)
|
||||
entry
|
||||
end
|
||||
end
|
||||
|
||||
@@ -94,21 +98,13 @@ module CachedResponses
|
||||
!ActiveRecord::Base.connection.transaction_open?
|
||||
end
|
||||
|
||||
def response_cache_key
|
||||
def response_cache_key(encoding)
|
||||
ActiveSupport::JSON.encode([
|
||||
controller_path, request.fullpath, request.base_url, request.user_agent,
|
||||
controller_path, request.fullpath, request.base_url, request.user_agent, encoding,
|
||||
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
|
||||
# Tokens are hydrated per request, including clients that replay an old
|
||||
# cookie. Their raw CSRF secret does not select a presentation variant.
|
||||
# Old token-bearing installation cookies remain valid without affecting HTML.
|
||||
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
|
||||
cookies.to_h.except("_campfire_session", "session_token")
|
||||
])
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user