From f8f04e5fcebd26e044c79fadbd4859a01f331c43 Mon Sep 17 00:00:00 2001 From: Jordan Owens Date: Fri, 9 Oct 2020 19:49:04 -0400 Subject: [PATCH 1/2] Add support for per form csrf tokens Model the implementation after Rails to provide cross compatibility. --- .../lib/rack/protection/authenticity_token.rb | 73 ++++++++++++++----- .../protection/authenticity_token_spec.rb | 14 +++- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/rack-protection/lib/rack/protection/authenticity_token.rb b/rack-protection/lib/rack/protection/authenticity_token.rb index 13496a7d72..69985a3ec3 100644 --- a/rack-protection/lib/rack/protection/authenticity_token.rb +++ b/rack-protection/lib/rack/protection/authenticity_token.rb @@ -1,5 +1,6 @@ require 'rack/protection' require 'securerandom' +require 'openssl' require 'base64' module Rack @@ -95,40 +96,53 @@ class AuthenticityToken < Base :key => :csrf, :allow_if => nil - def self.token(session) - self.new(nil).mask_authenticity_token(session) + def self.token(session, path: nil, method: :post) + self.new(nil).mask_authenticity_token(session, path, method) end def self.random_token - SecureRandom.base64(TOKEN_LENGTH) + SecureRandom.urlsafe_base64(TOKEN_LENGTH, padding: false) end def accepts?(env) - session = session env + session = session(env) set_token(session) safe?(env) || - valid_token?(session, env['HTTP_X_CSRF_TOKEN']) || - valid_token?(session, Request.new(env).params[options[:authenticity_param]]) || + valid_token?(env, env['HTTP_X_CSRF_TOKEN']) || + valid_token?(env, Request.new(env).params[options[:authenticity_param]]) || ( options[:allow_if] && options[:allow_if].call(env) ) end - def mask_authenticity_token(session) - token = set_token(session) + def mask_authenticity_token(session, path, method) + set_token(session) + + token = if path && method + per_form_token(session, path, method) + else + global_token(session) + end + mask_token(token) end + GLOBAL_TOKEN_IDENTIFIER = '!real_csrf_token' + private_constant :GLOBAL_TOKEN_IDENTIFIER + private def set_token(session) - session[options[:key]] ||= self.class.random_token + token = session[options[:key]] ||= self.class.random_token + decode_token(token) end # Checks the client's masked token to see if it matches the # session token. - def valid_token?(session, token) + def valid_token?(env, token) return false if token.nil? || token.empty? + session = session(env) + begin token = decode_token(token) rescue ArgumentError # encoded_masked_token is invalid Base64 @@ -139,13 +153,13 @@ def valid_token?(session, token) # to handle any unmasked tokens that we've issued without error. if unmasked_token?(token) - compare_with_real_token token, session - + compare_with_real_token(token, session) elsif masked_token?(token) token = unmask_token(token) - compare_with_real_token token, session - + compare_with_global_token(token, session) || + compare_with_real_token(token, session) || + compare_with_per_form_token(token, session, Request.new(env)) else false # Token is malformed end @@ -155,7 +169,6 @@ def valid_token?(session, token) # on each request. The masking is used to mitigate SSL attacks # like BREACH. def mask_token(token) - token = decode_token(token) one_time_pad = SecureRandom.random_bytes(token.length) encrypted_token = xor_byte_strings(one_time_pad, token) masked_token = one_time_pad + encrypted_token @@ -184,16 +197,42 @@ def compare_with_real_token(token, session) secure_compare(token, real_token(session)) end + def compare_with_global_token(token, session) + secure_compare(token, global_token(session)) + end + + def compare_with_per_form_token(token, session, request) + secure_compare(token, + per_form_token(session, request.path.chomp('/'), request.request_method) + ) + end + def real_token(session) decode_token(session[options[:key]]) end + def global_token(session) + token_hmac(session, GLOBAL_TOKEN_IDENTIFIER) + end + + def per_form_token(session, path, method) + token_hmac(session, "#{path}##{method.downcase}") + end + def encode_token(token) - Base64.strict_encode64(token) + Base64.urlsafe_encode64(token) end def decode_token(token) - Base64.strict_decode64(token) + Base64.urlsafe_decode64(token) + end + + def token_hmac(session, identifier) + OpenSSL::HMAC.digest( + OpenSSL::Digest::SHA256.new, + real_token(session), + identifier + ) end def xor_byte_strings(s1, s2) diff --git a/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb b/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb index 40eb6b0e66..c14b8851ca 100644 --- a/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb +++ b/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb @@ -40,6 +40,18 @@ expect(last_response).not_to be_ok end + it "accepts post form requests with a valid per form token" do + token = Rack::Protection::AuthenticityToken.token(session, path: '/foo') + post('/foo', {"authenticity_token" => token}, 'rack.session' => session) + expect(last_response).to be_ok + end + + it "denies post form requests with an invalid per form token" do + token = Rack::Protection::AuthenticityToken.token(session, path: '/foo') + post('/bar', {"authenticity_token" => token}, 'rack.session' => session) + expect(last_response).not_to be_ok + end + it "prevents ajax requests without a valid token" do expect(post('/', {}, "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest")).not_to be_ok end @@ -86,7 +98,7 @@ describe ".random_token" do it "generates a base64 encoded 32 character string" do - expect(Base64.strict_decode64(token).length).to eq(32) + expect(Base64.urlsafe_decode64(token).length).to eq(32) end end end From 88235a9f5805fd3a0e62c97c71ea0cf893da88c6 Mon Sep 17 00:00:00 2001 From: Jordan Owens Date: Thu, 7 Jan 2021 11:20:41 -0500 Subject: [PATCH 2/2] Remove breaking change --- rack-protection/lib/rack/protection/authenticity_token.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rack-protection/lib/rack/protection/authenticity_token.rb b/rack-protection/lib/rack/protection/authenticity_token.rb index 69985a3ec3..2f5634e051 100644 --- a/rack-protection/lib/rack/protection/authenticity_token.rb +++ b/rack-protection/lib/rack/protection/authenticity_token.rb @@ -97,7 +97,7 @@ class AuthenticityToken < Base :allow_if => nil def self.token(session, path: nil, method: :post) - self.new(nil).mask_authenticity_token(session, path, method) + self.new(nil).mask_authenticity_token(session, path: path, method: method) end def self.random_token @@ -114,7 +114,7 @@ def accepts?(env) ( options[:allow_if] && options[:allow_if].call(env) ) end - def mask_authenticity_token(session, path, method) + def mask_authenticity_token(session, path: nil, method: :post) set_token(session) token = if path && method @@ -132,8 +132,7 @@ def mask_authenticity_token(session, path, method) private def set_token(session) - token = session[options[:key]] ||= self.class.random_token - decode_token(token) + session[options[:key]] ||= self.class.random_token end # Checks the client's masked token to see if it matches the