diff --git a/rack-protection/lib/rack/protection/authenticity_token.rb b/rack-protection/lib/rack/protection/authenticity_token.rb index f101168c..9af26aec 100644 --- a/rack-protection/lib/rack/protection/authenticity_token.rb +++ b/rack-protection/lib/rack/protection/authenticity_token.rb @@ -12,65 +12,28 @@ module Rack # Only accepts unsafe HTTP requests if a given access token matches the token # included in the session. # - # Compatible with Rails and rack-csrf. + # Compatible with rack-csrf. # # Options: # # authenticity_param: Defines the param's name that should contain the token on a request. - # class AuthenticityToken < Base + TOKEN_LENGTH = 32 + default_options :authenticity_param => 'authenticity_token', - :authenticity_token_length => 32, :allow_if => nil - class << self - def token(session) - mask_token(session[:csrf]) - end + def self.token(session) + self.new(nil).mask_authenticity_token(session) + end - def random_token(length = 32) - SecureRandom.base64(length) - end - - # Creates a masked version of the authenticity token that varies - # 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 - encode_token masked_token - end - - # Essentially the inverse of +mask_token+. - def unmask_decoded_token(masked_token) - # Split the token into the one-time pad and the encrypted - # value and decrypt it - token_length = masked_token.length / 2 - one_time_pad = masked_token[0...token_length] - encrypted_token = masked_token[token_length..-1] - xor_byte_strings(one_time_pad, encrypted_token) - end - - def encode_token(token) - Base64.strict_encode64(token) - end - - def decode_token(token) - Base64.strict_decode64(token) - end - - private - - def xor_byte_strings(s1, s2) - s1.bytes.zip(s2.bytes).map { |(c1,c2)| c1 ^ c2 }.pack('c*') - end + def self.random_token + SecureRandom.base64(TOKEN_LENGTH) end def accepts?(env) session = session env - session[:csrf] ||= self.class.random_token(token_length) + set_token(session) safe?(env) || valid_token?(session, env['HTTP_X_CSRF_TOKEN']) || @@ -78,10 +41,15 @@ module Rack ( options[:allow_if] && options[:allow_if].call(env) ) end + def mask_authenticity_token(session) + token = set_token(session) + mask_token(token) + end + private - def token_length - options[:authenticity_token_length] + def set_token(session) + session[:csrf] ||= self.class.random_token end # Checks the client's masked token to see if it matches the @@ -90,7 +58,7 @@ module Rack return false if token.nil? || token.empty? begin - token = self.class.decode_token(token) + token = decode_token(token) rescue ArgumentError # encoded_masked_token is invalid Base64 return false end @@ -102,7 +70,7 @@ module Rack compare_with_real_token token, session elsif masked_token?(token) - token = self.class.unmask_decoded_token(token) + token = unmask_token(token) compare_with_real_token token, session @@ -111,12 +79,33 @@ module Rack end end + # Creates a masked version of the authenticity token that varies + # 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 + encode_token(masked_token) + end + + # Essentially the inverse of +mask_token+. + def unmask_token(masked_token) + # Split the token into the one-time pad and the encrypted + # value and decrypt it + token_length = masked_token.length / 2 + one_time_pad = masked_token[0...token_length] + encrypted_token = masked_token[token_length..-1] + xor_byte_strings(one_time_pad, encrypted_token) + end + def unmasked_token?(token) - token.length == token_length + token.length == TOKEN_LENGTH end def masked_token?(token) - token.length == token_length * 2 + token.length == TOKEN_LENGTH * 2 end def compare_with_real_token(token, session) @@ -124,7 +113,19 @@ module Rack end def real_token(session) - self.class.decode_token(session[:csrf]) + decode_token(session[:csrf]) + end + + def encode_token(token) + Base64.strict_encode64(token) + end + + def decode_token(token) + Base64.strict_decode64(token) + end + + def xor_byte_strings(s1, s2) + s1.bytes.zip(s2.bytes).map { |(c1,c2)| c1 ^ c2 }.pack('c*') end end end diff --git a/rack-protection/lib/rack/protection/form_token.rb b/rack-protection/lib/rack/protection/form_token.rb index eb12af75..994740f8 100644 --- a/rack-protection/lib/rack/protection/form_token.rb +++ b/rack-protection/lib/rack/protection/form_token.rb @@ -13,7 +13,7 @@ module Rack # This middleware is not used when using the Rack::Protection collection, # since it might be a security issue, depending on your application # - # Compatible with Rails and rack-csrf. + # Compatible with rack-csrf. class FormToken < AuthenticityToken def accepts?(env) env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest" or super diff --git a/rack-protection/lib/rack/protection/remote_token.rb b/rack-protection/lib/rack/protection/remote_token.rb index ed25e593..f3922c87 100644 --- a/rack-protection/lib/rack/protection/remote_token.rb +++ b/rack-protection/lib/rack/protection/remote_token.rb @@ -10,7 +10,7 @@ module Rack # Only accepts unsafe HTTP requests if a given access token matches the token # included in the session *or* the request comes from the same origin. # - # Compatible with Rails and rack-csrf. + # Compatible with rack-csrf. class RemoteToken < AuthenticityToken default_reaction :deny 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 1f08bb47..75081f6c 100644 --- a/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb +++ b/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb @@ -1,6 +1,7 @@ describe Rack::Protection::AuthenticityToken do let(:token) { described_class.random_token } - let(:bad_token) { described_class.random_token } + let(:masked_token) { described_class.token(session) } + let(:bad_token) { Base64.strict_encode64("badtoken") } let(:session) { {:csrf => token} } it_behaves_like "any rack application" @@ -15,7 +16,7 @@ describe Rack::Protection::AuthenticityToken do end it "accepts post requests with masked X-CSRF-Token header" do - post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => Rack::Protection::AuthenticityToken.token(session)) + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => masked_token) expect(last_response).to be_ok end @@ -30,7 +31,7 @@ describe Rack::Protection::AuthenticityToken do end it "accepts post form requests with masked authenticity_token field" do - post('/', {"authenticity_token" => Rack::Protection::AuthenticityToken.token(session)}, 'rack.session' => session) + post('/', {"authenticity_token" => masked_token}, 'rack.session' => session) expect(last_response).to be_ok end @@ -58,30 +59,23 @@ describe Rack::Protection::AuthenticityToken do expect(env['rack.session'][:csrf]).not_to be_nil end + describe ".token" do + it "returns a unique masked version of the authenticity token" do + expect(Rack::Protection::AuthenticityToken.token(session)).not_to eq(masked_token) + end + + it "sets a session authenticity token if one does not exist" do + session = {} + allow(Rack::Protection::AuthenticityToken).to receive(:random_token).and_return(token) + allow_any_instance_of(Rack::Protection::AuthenticityToken).to receive(:mask_token).and_return(masked_token) + Rack::Protection::AuthenticityToken.token(session) + expect(session[:csrf]).to eq(token) + end + end + describe ".random_token" do - it "outputs a base64 encoded 32 character string by default" do + it "generates a base64 encoded 32 character string" do expect(Base64.strict_decode64(token).length).to eq(32) end - - it "outputs a base64 encoded string of the specified length" do - token = described_class.random_token(64) - expect(Base64.strict_decode64(token).length).to eq(64) - end - end - - describe ".mask_token" do - it "generates unique masked values for a token" do - first_masked_token = described_class.mask_token(token) - second_masked_token = described_class.mask_token(token) - expect(first_masked_token).not_to eq(second_masked_token) - end - end - - describe ".unmask_decoded_token" do - it "unmasks a token to its original decoded value" do - masked_token = described_class.decode_token(described_class.mask_token(token)) - unmasked_token = described_class.unmask_decoded_token(masked_token) - expect(unmasked_token).to eq(described_class.decode_token(token)) - end end end diff --git a/rack-protection/spec/lib/rack/protection/form_token_spec.rb b/rack-protection/spec/lib/rack/protection/form_token_spec.rb index 31083720..e799ed8e 100644 --- a/rack-protection/spec/lib/rack/protection/form_token_spec.rb +++ b/rack-protection/spec/lib/rack/protection/form_token_spec.rb @@ -1,6 +1,7 @@ describe Rack::Protection::FormToken do let(:token) { described_class.random_token } - let(:bad_token) { described_class.random_token } + let(:masked_token) { described_class.token(session) } + let(:bad_token) { Base64.strict_encode64("badtoken") } let(:session) { {:csrf => token} } it_behaves_like "any rack application" @@ -15,7 +16,7 @@ describe Rack::Protection::FormToken do end it "accepts post requests with masked X-CSRF-Token header" do - post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => Rack::Protection::FormToken.token(session)) + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => masked_token) expect(last_response).to be_ok end @@ -30,7 +31,7 @@ describe Rack::Protection::FormToken do end it "accepts post form requests with masked authenticity_token field" do - post('/', {"authenticity_token" => Rack::Protection::FormToken.token(session)}, 'rack.session' => session) + post('/', {"authenticity_token" => masked_token}, 'rack.session' => session) expect(last_response).to be_ok end diff --git a/rack-protection/spec/lib/rack/protection/remote_token_spec.rb b/rack-protection/spec/lib/rack/protection/remote_token_spec.rb index 89622691..9ef4a03b 100644 --- a/rack-protection/spec/lib/rack/protection/remote_token_spec.rb +++ b/rack-protection/spec/lib/rack/protection/remote_token_spec.rb @@ -1,6 +1,7 @@ describe Rack::Protection::RemoteToken do let(:token) { described_class.random_token } - let(:bad_token) { described_class.random_token } + let(:masked_token) { described_class.token(session) } + let(:bad_token) { Base64.strict_encode64("badtoken") } let(:session) { {:csrf => token} } it_behaves_like "any rack application" @@ -26,7 +27,7 @@ describe Rack::Protection::RemoteToken do it "accepts post requests with a remote referrer and masked X-CSRF-Token header" do post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org', - 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => Rack::Protection::RemoteToken.token(session)) + 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => masked_token) expect(last_response).to be_ok end @@ -43,7 +44,7 @@ describe Rack::Protection::RemoteToken do end it "accepts post form requests with a remote referrer and masked authenticity_token field" do - post('/', {"authenticity_token" => Rack::Protection::RemoteToken.token(session)}, 'HTTP_REFERER' => 'http://example.com/foo', + post('/', {"authenticity_token" => masked_token}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org', 'rack.session' => session) expect(last_response).to be_ok end