Skip to content

Commit a2e201f

Browse files
authored
Merge pull request rails#44283 from simbasdad/csrf_token_storage
Allow CSRF tokens to be stored outside of session.
2 parents cbb4669 + f2c66ce commit a2e201f

File tree

10 files changed

+456
-37
lines changed

10 files changed

+456
-37
lines changed

actionpack/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
* Add the ability to use custom logic for storing and retrieving CSRF tokens.
2+
3+
By default, the token will be stored in the session. Custom classes can be
4+
defined to specify arbitrary behaviour, but the ability to store them in
5+
encrypted cookies is built in.
6+
7+
*Andrew Kowpak*
8+
19
* Make ActionController::Parameters#values cast nested hashes into parameters.
210

311
*Gannon McGibbon*

actionpack/lib/action_controller/metal/request_forgery_protection.rb

Lines changed: 125 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ class InvalidCrossOriginRequest < ActionControllerError # :nodoc:
5555
# Learn more about CSRF attacks and securing your application in the
5656
# {Ruby on Rails Security Guide}[https://guides.rubyonrails.org/security.html].
5757
module RequestForgeryProtection
58+
CSRF_TOKEN = "action_controller.csrf_token"
59+
5860
extend ActiveSupport::Concern
5961

6062
include AbstractController::Helpers
@@ -90,6 +92,10 @@ module RequestForgeryProtection
9092
config_accessor :default_protect_from_forgery
9193
self.default_protect_from_forgery = false
9294

95+
# The strategy to use for storing and retrieving CSRF tokens.
96+
config_accessor :csrf_token_storage_strategy
97+
self.csrf_token_storage_strategy = SessionStore.new
98+
9399
helper_method :form_authenticity_token
94100
helper_method :protect_against_forgery?
95101
end
@@ -140,11 +146,39 @@ module ClassMethods
140146
# class ApplicationController < ActionController:x:Base
141147
# protect_from_forgery with: CustomStrategy
142148
# end
149+
# * <tt>:store</tt> - Set the strategy to store and retrieve CSRF tokens.
150+
#
151+
# Built-in session token strategies are:
152+
# * <tt>:session</tt> - Store the CSRF token in the session. Used as default if <tt>:store</tt> option is not specified.
153+
# * <tt>:cookie</tt> - Store the CSRF token in an encrypted cookie.
154+
#
155+
# You can also implement custom strategy classes for CSRF token storage:
156+
#
157+
# class CustomStore
158+
# def fetch(request)
159+
# # Return the token from a custom location
160+
# end
161+
#
162+
# def store(request, csrf_token)
163+
# # Store the token in a custom location
164+
# end
165+
#
166+
# def reset(request)
167+
# # Delete the stored session token
168+
# end
169+
# end
170+
#
171+
# class ApplicationController < ActionController:x:Base
172+
# protect_from_forgery store: CustomStore.new
173+
# end
143174
def protect_from_forgery(options = {})
144175
options = options.reverse_merge(prepend: false)
145176

146177
self.forgery_protection_strategy = protection_method_class(options[:with] || :null_session)
147178
self.request_forgery_protection_token ||= :authenticity_token
179+
180+
self.csrf_token_storage_strategy = storage_strategy(options[:store] || SessionStore.new)
181+
148182
before_action :verify_authenticity_token, options
149183
append_after_action :verify_same_origin_request
150184
end
@@ -173,6 +207,22 @@ def protection_method_class(name)
173207
raise ArgumentError, "Invalid request forgery protection method, use :null_session, :exception, :reset_session, or a custom forgery protection class."
174208
end
175209
end
210+
211+
def storage_strategy(name)
212+
case name
213+
when :session
214+
SessionStore.new
215+
when :cookie
216+
CookieStore.new(:csrf_token)
217+
else
218+
return name if is_storage_strategy?(name)
219+
raise ArgumentError, "Invalid CSRF token storage strategy, use :session, :cookie, or a custom CSRF token storage class."
220+
end
221+
end
222+
223+
def is_storage_strategy?(object)
224+
object.respond_to?(:fetch) && object.respond_to?(:store)
225+
end
176226
end
177227

178228
module ProtectionMethods
@@ -240,6 +290,63 @@ def handle_unverified_request
240290
end
241291
end
242292

293+
class SessionStore
294+
def fetch(request)
295+
request.session[:_csrf_token]
296+
end
297+
298+
def store(request, csrf_token)
299+
request.session[:_csrf_token] = csrf_token
300+
end
301+
302+
def reset(request)
303+
request.session.delete(:_csrf_token)
304+
end
305+
end
306+
307+
class CookieStore
308+
def initialize(cookie = :csrf_token)
309+
@cookie_name = cookie
310+
end
311+
312+
def fetch(request)
313+
contents = request.cookie_jar.encrypted[@cookie_name]
314+
return nil if contents.nil?
315+
316+
value = JSON.parse(contents)
317+
return nil unless value.dig("session_id", "public_id") == request.session.id_was&.public_id
318+
319+
value["token"]
320+
rescue JSON::ParserError
321+
nil
322+
end
323+
324+
def store(request, csrf_token)
325+
request.cookie_jar.encrypted.permanent[@cookie_name] = {
326+
value: {
327+
token: csrf_token,
328+
session_id: request.session.id,
329+
}.to_json,
330+
httponly: true,
331+
same_site: :lax,
332+
}
333+
end
334+
335+
def reset(request)
336+
request.cookie_jar.delete(@cookie_name)
337+
end
338+
end
339+
340+
def reset_csrf_token(request) # :doc:
341+
request.env.delete(CSRF_TOKEN)
342+
csrf_token_storage_strategy.reset(request)
343+
end
344+
345+
def commit_csrf_token(request) # :doc:
346+
csrf_token = request.env[CSRF_TOKEN]
347+
csrf_token_storage_strategy.store(request, csrf_token) unless csrf_token.nil?
348+
end
349+
243350
private
244351
# The actual before_action that is used to verify the CSRF token.
245352
# Don't override this directly. Provide your own forgery protection
@@ -341,20 +448,20 @@ def request_authenticity_tokens # :doc:
341448

342449
# Creates the authenticity token for the current request.
343450
def form_authenticity_token(form_options: {}) # :doc:
344-
masked_authenticity_token(session, form_options: form_options)
451+
masked_authenticity_token(form_options: form_options)
345452
end
346453

347454
# Creates a masked version of the authenticity token that varies
348455
# on each request. The masking is used to mitigate SSL attacks
349456
# like BREACH.
350-
def masked_authenticity_token(session, form_options: {})
457+
def masked_authenticity_token(form_options: {})
351458
action, method = form_options.values_at(:action, :method)
352459

353460
raw_token = if per_form_csrf_tokens && action && method
354461
action_path = normalize_action_path(action)
355-
per_form_csrf_token(session, action_path, method)
462+
per_form_csrf_token(nil, action_path, method)
356463
else
357-
global_csrf_token(session)
464+
global_csrf_token
358465
end
359466

360467
mask_token(raw_token)
@@ -382,14 +489,14 @@ def valid_authenticity_token?(session, encoded_masked_token) # :doc:
382489
# This is actually an unmasked token. This is expected if
383490
# you have just upgraded to masked tokens, but should stop
384491
# happening shortly after installing this gem.
385-
compare_with_real_token masked_token, session
492+
compare_with_real_token masked_token
386493

387494
elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
388495
csrf_token = unmask_token(masked_token)
389496

390-
compare_with_global_token(csrf_token, session) ||
391-
compare_with_real_token(csrf_token, session) ||
392-
valid_per_form_csrf_token?(csrf_token, session)
497+
compare_with_global_token(csrf_token) ||
498+
compare_with_real_token(csrf_token) ||
499+
valid_per_form_csrf_token?(csrf_token)
393500
else
394501
false # Token is malformed.
395502
end
@@ -410,15 +517,15 @@ def mask_token(raw_token) # :doc:
410517
encode_csrf_token(masked_token)
411518
end
412519

413-
def compare_with_real_token(token, session) # :doc:
520+
def compare_with_real_token(token, session = nil) # :doc:
414521
ActiveSupport::SecurityUtils.fixed_length_secure_compare(token, real_csrf_token(session))
415522
end
416523

417-
def compare_with_global_token(token, session) # :doc:
524+
def compare_with_global_token(token, session = nil) # :doc:
418525
ActiveSupport::SecurityUtils.fixed_length_secure_compare(token, global_csrf_token(session))
419526
end
420527

421-
def valid_per_form_csrf_token?(token, session) # :doc:
528+
def valid_per_form_csrf_token?(token, session = nil) # :doc:
422529
if per_form_csrf_tokens
423530
correct_token = per_form_csrf_token(
424531
session,
@@ -432,9 +539,12 @@ def valid_per_form_csrf_token?(token, session) # :doc:
432539
end
433540
end
434541

435-
def real_csrf_token(session) # :doc:
436-
session[:_csrf_token] ||= generate_csrf_token
437-
decode_csrf_token(session[:_csrf_token])
542+
def real_csrf_token(_session = nil) # :doc:
543+
csrf_token = request.env.fetch(CSRF_TOKEN) do
544+
request.env[CSRF_TOKEN] = csrf_token_storage_strategy.fetch(request) || generate_csrf_token
545+
end
546+
547+
decode_csrf_token(csrf_token)
438548
end
439549

440550
def per_form_csrf_token(session, action_path, method) # :doc:
@@ -444,7 +554,7 @@ def per_form_csrf_token(session, action_path, method) # :doc:
444554
GLOBAL_CSRF_TOKEN_IDENTIFIER = "!real_csrf_token"
445555
private_constant :GLOBAL_CSRF_TOKEN_IDENTIFIER
446556

447-
def global_csrf_token(session) # :doc:
557+
def global_csrf_token(session = nil) # :doc:
448558
csrf_token_hmac(session, GLOBAL_CSRF_TOKEN_IDENTIFIER)
449559
end
450560

actionpack/lib/action_controller/test_case.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,12 @@ class LiveTestResponse < Live::Response
182182
class TestSession < Rack::Session::Abstract::PersistedSecure::SecureSessionHash # :nodoc:
183183
DEFAULT_OPTIONS = Rack::Session::Abstract::Persisted::DEFAULT_OPTIONS
184184

185-
def initialize(session = {})
185+
def initialize(session = {}, id = Rack::Session::SessionId.new(SecureRandom.hex(16)))
186186
super(nil, nil)
187-
@id = Rack::Session::SessionId.new(SecureRandom.hex(16))
187+
@id = id
188188
@data = stringify_keys(session)
189189
@loaded = true
190+
@initially_empty = @data.empty?
190191
end
191192

192193
def exists?
@@ -218,6 +219,10 @@ def enabled?
218219
true
219220
end
220221

222+
def id_was
223+
@id
224+
end
225+
221226
private
222227
def load!
223228
@id

actionpack/lib/action_dispatch/http/request.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ def body_stream # :nodoc:
358358

359359
def reset_session
360360
session.destroy
361+
controller_instance.reset_csrf_token(self) if controller_instance.respond_to?(:reset_csrf_token)
361362
end
362363

363364
def session=(session) # :nodoc:
@@ -429,6 +430,10 @@ def inspect # :nodoc:
429430
"#<#{self.class.name} #{method} #{original_url.dump} for #{remote_ip}>"
430431
end
431432

433+
def commit_csrf_token
434+
controller_instance.commit_csrf_token(self) if controller_instance.respond_to?(:commit_csrf_token)
435+
end
436+
432437
private
433438
def check_method(name)
434439
HTTP_METHOD_LOOKUP[name] || raise(ActionController::UnknownHttpMethod, "#{name}, accepted HTTP methods are #{HTTP_METHODS[0...-1].join(', ')}, and #{HTTP_METHODS[-1]}")

actionpack/lib/action_dispatch/middleware/session/abstract_store.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def stale_session_check!
6767
end
6868

6969
module SessionObject # :nodoc:
70+
def commit_session(req, res)
71+
req.commit_csrf_token
72+
super(req, res)
73+
end
74+
7075
def prepare_session(req)
7176
Request::Session.create(self, req, @default_options)
7277
end

actionpack/lib/action_dispatch/request/session.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ def initialize(by, req, enabled: true)
7878
@loaded = false
7979
@exists = nil # We haven't checked yet.
8080
@enabled = enabled
81+
@id_was = nil
82+
@id_was_initialized = false
8183
end
8284

8385
def id
@@ -241,6 +243,11 @@ def each(&block)
241243
to_hash.each(&block)
242244
end
243245

246+
def id_was
247+
load_for_read!
248+
@id_was
249+
end
250+
244251
private
245252
def load_for_read!
246253
load! if !loaded? && exists?
@@ -260,10 +267,13 @@ def load_for_delete!
260267

261268
def load!
262269
if enabled?
270+
@id_was_initialized = true unless exists?
263271
id, session = @by.load_session @req
264272
options[:id] = id
265273
@delegate.replace(session.stringify_keys)
274+
@id_was = id unless @id_was_initialized
266275
end
276+
@id_was_initialized = true
267277
@loaded = true
268278
end
269279
end

0 commit comments

Comments
 (0)