diff --git a/rack-protection/lib/rack/protection/authenticity_token.rb b/rack-protection/lib/rack/protection/authenticity_token.rb index 13496a7d..69985a3e 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 @@ module Rack :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 @@ module Rack # 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 @@ module Rack # 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 @@ module Rack 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 40eb6b0e..c14b8851 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 @@ describe Rack::Protection::AuthenticityToken do 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 Rack::Protection::AuthenticityToken do 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