mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-09 08:10:08 +09:00
Use header-only forgery protection and cache complete responses
This commit is contained in:
@@ -80,3 +80,13 @@ Please see our [development guide](docs/development.md) for how to get Campfire
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
||||
|
||||
## Request protection
|
||||
|
||||
Browser writes use Rails' `Sec-Fetch-Site` header-only forgery protection and its
|
||||
`Origin` check. HTTPS requires modern browser metadata; plain HTTP retains the
|
||||
missing-header fallback with the existing `SameSite=Lax` cookies. Authenticated
|
||||
bot APIs and signed disk-upload capabilities keep their existing exemptions.
|
||||
Forms contain no CSRF tokens. Authenticated page caches reuse complete HTML and
|
||||
gzip bodies while checking current sessions, permissions and SQLite changes.
|
||||
Existing installation cookies remain valid, including old token-bearing cookies.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,8 +14,7 @@ class MessagesController < ApplicationController
|
||||
if @messages.any?
|
||||
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
|
||||
fresh_when etag: Digest::SHA256.hexdigest(body), template: false
|
||||
unless performed?
|
||||
response.content_type = "text/html"
|
||||
self.response_body = body
|
||||
|
||||
@@ -36,6 +36,11 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
private
|
||||
# Header-only forgery protection needs no secret in forms or cached HTML.
|
||||
def token_tag(*)
|
||||
""
|
||||
end
|
||||
|
||||
def admin_body_class
|
||||
"admin" if Current.user&.can_administer?
|
||||
end
|
||||
|
||||
@@ -13,7 +13,6 @@ export default class FileUploader {
|
||||
|
||||
const req = new XMLHttpRequest()
|
||||
req.open("POST", this.url)
|
||||
req.setRequestHeader("X-CSRF-Token", document.querySelector("meta[name=csrf-token]").content)
|
||||
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
|
||||
|
||||
const result = new Promise((resolve, reject) => {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
<%= current_user_meta_tags %>
|
||||
<%= script_aware_action_cable_meta_tag %>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# can read the public login page. Require a valid Campfire session before an
|
||||
# anonymous caller can allocate a Blob or persist bytes to disk.
|
||||
Rails.application.config.to_prepare do
|
||||
ActiveStorage::BaseController.forgery_protection_verification_strategy = :header_only
|
||||
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
|
||||
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Rails.application.config.session_store :cookie_store,
|
||||
key: "_campfire_session",
|
||||
# Persist session cookie as permament so re-opened browser windows maintain a CSRF token
|
||||
# Preserve the existing installation's persistent browser-session cookie.
|
||||
expire_after: 20.years
|
||||
|
||||
@@ -14,7 +14,7 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
ActionController::Base.perform_caching = true
|
||||
@room = rooms(:watercooler)
|
||||
# Establish last-room and CSRF cookies before checking reuse.
|
||||
# Establish last-room cookies before checking reuse.
|
||||
2.times { get room_url(@room) }
|
||||
ResponseCache.instance.clear
|
||||
end
|
||||
@@ -48,37 +48,54 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "clients without a persisted CSRF session still reuse token-neutral HTML" do
|
||||
test "clients without token state reuse complete HTML and post without tokens" do
|
||||
cookies["_campfire_session"] = @login_cookie
|
||||
get room_url(@room)
|
||||
first = css_select('meta[name="csrf-token"]').first["content"]
|
||||
first = response.body
|
||||
assert_select 'meta[name="csrf-token"]', count: 0
|
||||
assert_select 'input[name="authenticity_token"]', count: 0
|
||||
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
|
||||
assert_equal first, 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" } }
|
||||
message: { body: "tokenless replay works", client_message_id: "cache-replay" } },
|
||||
headers: { "Sec-Fetch-Site" => "same-origin", "Origin" => "http://once.campfire.test" }
|
||||
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"]
|
||||
test "literal token-like text survives complete page reuse" do
|
||||
literal = "campfire-csrf-literal authenticity_token csrf-token"
|
||||
@room.messages.create!(creator: users(:david), body: "literal #{literal}")
|
||||
get room_url(@room)
|
||||
first = css_select('meta[name="csrf-token"]').first["content"]
|
||||
first = response.body
|
||||
get room_url(@room)
|
||||
second = css_select('meta[name="csrf-token"]').first["content"]
|
||||
assert_not_equal first, second
|
||||
assert_equal first, response.body
|
||||
assert_includes response.body, "literal #{literal}"
|
||||
assert_no_match /campfire-csrf-/, response.body
|
||||
end
|
||||
|
||||
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
|
||||
test "gzip bytes are reused and identity negotiation stays separate" do
|
||||
require "stringio"
|
||||
get room_url(@room), headers: { "Accept-Encoding" => "gzip" }
|
||||
assert_equal "gzip", response.headers["Content-Encoding"]
|
||||
encoded = response.body
|
||||
decoded = Zlib::GzipReader.new(StringIO.new(encoded)).read
|
||||
assert_includes decoded, "<html"
|
||||
assert_includes response.headers["Vary"], "Accept-Encoding"
|
||||
get room_url(@room)
|
||||
assert_equal decoded, response.body
|
||||
ResponseCache.instance.expects(:write).never
|
||||
get room_url(@room), headers: { "Accept-Encoding" => "gzip" }
|
||||
assert_equal encoded, response.body
|
||||
get room_url(@room)
|
||||
assert_nil response.headers["Content-Encoding"]
|
||||
assert_equal decoded, response.body
|
||||
get room_url(@room), headers: { "Accept-Encoding" => "gzip;q=0, identity;q=1" }
|
||||
assert_nil response.headers["Content-Encoding"]
|
||||
assert_equal decoded, response.body
|
||||
get room_url(@room), headers: { "Accept-Encoding" => "*;q=0" }
|
||||
assert_nil response.headers["Content-Encoding"]
|
||||
end
|
||||
|
||||
test "local and foreign commits invalidate pages and nested fragments" do
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
require "test_helper"
|
||||
|
||||
class FetchMetadataTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
host! "once.campfire.test"
|
||||
@previous_exceptions = Rails.application.env_config["action_dispatch.show_exceptions"]
|
||||
Rails.application.env_config["action_dispatch.show_exceptions"] = :rescuable
|
||||
@previous_forgery = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = true
|
||||
end
|
||||
|
||||
teardown do
|
||||
ActionController::Base.allow_forgery_protection = @previous_forgery
|
||||
Rails.application.env_config["action_dispatch.show_exceptions"] = @previous_exceptions
|
||||
end
|
||||
|
||||
test "login and authenticated forms have no token fields" do
|
||||
get new_session_url
|
||||
assert_response :success
|
||||
assert_select 'meta[name="csrf-token"]', count: 0
|
||||
assert_select 'input[name="authenticity_token"]', count: 0
|
||||
sign_in :david
|
||||
get room_url(rooms(:watercooler))
|
||||
assert_response :success
|
||||
assert_select 'meta[name="csrf-token"]', count: 0
|
||||
assert_select 'input[name="authenticity_token"]', count: 0
|
||||
end
|
||||
|
||||
test "HTTPS login accepts browser metadata without a token" do
|
||||
https!
|
||||
%w[ same-origin same-site ].each do |site|
|
||||
reset!
|
||||
host! "once.campfire.test"
|
||||
https!
|
||||
post session_url, params: credentials, headers: {
|
||||
"Sec-Fetch-Site" => site, "Origin" => "https://once.campfire.test" }
|
||||
assert_response :redirect
|
||||
assert cookies["session_token"].present?
|
||||
end
|
||||
end
|
||||
|
||||
test "HTTPS writes reject missing metadata even with a legacy token parameter" do
|
||||
https!
|
||||
assert_no_difference "Session.count" do
|
||||
post session_url, params: credentials.merge(authenticity_token: "old token"), headers: { "Origin" => "https://once.campfire.test" }
|
||||
end
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "cross-site none and malformed metadata cannot sign in over HTTP or HTTPS" do
|
||||
[ false, true ].each do |tls|
|
||||
https! tls
|
||||
%w[ cross-site none garbage ].each do |site|
|
||||
assert_no_difference "Session.count" do
|
||||
post session_url, params: credentials, headers: { "Sec-Fetch-Site" => site }
|
||||
end
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "provided foreign and null origins fail even with same-site metadata" do
|
||||
%w[ https://foreign.example null ].each do |origin|
|
||||
assert_no_difference "Session.count" do
|
||||
post session_url, params: credentials, headers: { "Sec-Fetch-Site" => "same-site", "Origin" => origin }
|
||||
end
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
test "plain HTTP retains the missing-header fallback" do
|
||||
post session_url, params: credentials, headers: { "Origin" => "http://once.campfire.test" }
|
||||
assert_response :redirect
|
||||
assert cookies["session_token"].present?
|
||||
end
|
||||
|
||||
test "cross-site reads remain available and method-overridden writes stay protected" do
|
||||
get new_session_url, headers: { "Sec-Fetch-Site" => "cross-site" }
|
||||
assert_response :success
|
||||
sign_in :david
|
||||
assert_no_difference "Room.count" do
|
||||
post room_path(rooms(:watercooler)), params: { _method: "delete" }, headers: { "Sec-Fetch-Site" => "cross-site" }
|
||||
end
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
def credentials
|
||||
{ email_address: users(:david).email_address, password: "secret123456" }
|
||||
end
|
||||
end
|
||||
@@ -87,18 +87,16 @@ export function sockets() {
|
||||
export function messages() {
|
||||
const cookie = `session_token=${dummyCookies[0][0]}`;
|
||||
|
||||
const response = http.get(`http://${host}${port}/rooms/1`, { headers: { "Cookie": cookie }, responseType: "text" });
|
||||
const csrfToken = response.body.match(/<meta name="csrf-token" content="([^"]*)"/i)[1];
|
||||
|
||||
const postHeaders = {
|
||||
"Cookie": cookie,
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Origin": `http://${host}${port}`,
|
||||
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
|
||||
}
|
||||
|
||||
const payload = {
|
||||
"message[body]": "Hello from k6",
|
||||
"message[client_message_id]": Math.random().toString(36),
|
||||
"authenticity_token": csrfToken
|
||||
"message[client_message_id]": Math.random().toString(36)
|
||||
};
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
|
||||
Reference in New Issue
Block a user