diff --git a/rack-protection/lib/rack/protection/authenticity_token.rb b/rack-protection/lib/rack/protection/authenticity_token.rb index 74fc43bde8..f101168ce6 100644 --- a/rack-protection/lib/rack/protection/authenticity_token.rb +++ b/rack-protection/lib/rack/protection/authenticity_token.rb @@ -1,4 +1,6 @@ require 'rack/protection' +require 'securerandom' +require 'base64' module Rack module Protection @@ -18,17 +20,112 @@ module Protection # class AuthenticityToken < Base default_options :authenticity_param => 'authenticity_token', + :authenticity_token_length => 32, :allow_if => nil + class << self + def token(session) + mask_token(session[:csrf]) + 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 + end + def accepts?(env) session = session env - token = session[:csrf] ||= session['_csrf_token'] || random_string + session[:csrf] ||= self.class.random_token(token_length) safe?(env) || - secure_compare(env['HTTP_X_CSRF_TOKEN'], token) || - secure_compare(Request.new(env).params[options[:authenticity_param]], token) || + valid_token?(session, env['HTTP_X_CSRF_TOKEN']) || + valid_token?(session, Request.new(env).params[options[:authenticity_param]]) || ( options[:allow_if] && options[:allow_if].call(env) ) end + + private + + def token_length + options[:authenticity_token_length] + end + + # Checks the client's masked token to see if it matches the + # session token. + def valid_token?(session, token) + return false if token.nil? || token.empty? + + begin + token = self.class.decode_token(token) + rescue ArgumentError # encoded_masked_token is invalid Base64 + return false + end + + # See if it's actually a masked token or not. We should be able + # to handle any unmasked tokens that we've issued without error. + + if unmasked_token?(token) + compare_with_real_token token, session + + elsif masked_token?(token) + token = self.class.unmask_decoded_token(token) + + compare_with_real_token token, session + + else + false # Token is malformed + end + end + + def unmasked_token?(token) + token.length == token_length + end + + def masked_token?(token) + token.length == token_length * 2 + end + + def compare_with_real_token(token, session) + secure_compare(token, real_token(session)) + end + + def real_token(session) + self.class.decode_token(session[:csrf]) + end end end end 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 1734fcb75a..1f08bb47c0 100644 --- a/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb +++ b/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb @@ -1,4 +1,8 @@ describe Rack::Protection::AuthenticityToken do + let(:token) { described_class.random_token } + let(:bad_token) { described_class.random_token } + let(:session) { {:csrf => token} } + it_behaves_like "any rack application" it "denies post requests without any token" do @@ -6,22 +10,32 @@ end it "accepts post requests with correct X-CSRF-Token header" do - post('/', {}, 'rack.session' => {:csrf => "a"}, 'HTTP_X_CSRF_TOKEN' => "a") + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => token) + expect(last_response).to be_ok + 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)) expect(last_response).to be_ok end it "denies post requests with wrong X-CSRF-Token header" do - post('/', {}, 'rack.session' => {:csrf => "a"}, 'HTTP_X_CSRF_TOKEN' => "b") + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => bad_token) expect(last_response).not_to be_ok end it "accepts post form requests with correct authenticity_token field" do - post('/', {"authenticity_token" => "a"}, 'rack.session' => {:csrf => "a"}) + post('/', {"authenticity_token" => token}, 'rack.session' => session) + expect(last_response).to be_ok + end + + it "accepts post form requests with masked authenticity_token field" do + post('/', {"authenticity_token" => Rack::Protection::AuthenticityToken.token(session)}, 'rack.session' => session) expect(last_response).to be_ok end it "denies post form requests with wrong authenticity_token field" do - post('/', {"authenticity_token" => "a"}, 'rack.session' => {:csrf => "b"}) + post('/', {"authenticity_token" => bad_token}, 'rack.session' => session) expect(last_response).not_to be_ok end @@ -35,7 +49,7 @@ run proc { |e| [200, {'Content-Type' => 'text/plain'}, ['hi']] } end - post('/', {"csrf_param" => "a"}, 'rack.session' => {:csrf => "a"}) + post('/', {"csrf_param" => token}, 'rack.session' => {:csrf => token}) expect(last_response).to be_ok end @@ -43,4 +57,31 @@ get('/', {}, {}) expect(env['rack.session'][:csrf]).not_to be_nil end + + describe ".random_token" do + it "outputs a base64 encoded 32 character string by default" 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 5fd4d1f2fc..3108372019 100644 --- a/rack-protection/spec/lib/rack/protection/form_token_spec.rb +++ b/rack-protection/spec/lib/rack/protection/form_token_spec.rb @@ -1,4 +1,8 @@ describe Rack::Protection::FormToken do + let(:token) { described_class.random_token } + let(:bad_token) { described_class.random_token } + let(:session) { {:csrf => token} } + it_behaves_like "any rack application" it "denies post requests without any token" do @@ -6,22 +10,32 @@ end it "accepts post requests with correct X-CSRF-Token header" do - post('/', {}, 'rack.session' => {:csrf => "a"}, 'HTTP_X_CSRF_TOKEN' => "a") + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => token) + expect(last_response).to be_ok + 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)) expect(last_response).to be_ok end it "denies post requests with wrong X-CSRF-Token header" do - post('/', {}, 'rack.session' => {:csrf => "a"}, 'HTTP_X_CSRF_TOKEN' => "b") + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => bad_token) expect(last_response).not_to be_ok end it "accepts post form requests with correct authenticity_token field" do - post('/', {"authenticity_token" => "a"}, 'rack.session' => {:csrf => "a"}) + post('/', {"authenticity_token" => token}, 'rack.session' => session) + expect(last_response).to be_ok + end + + it "accepts post form requests with masked authenticity_token field" do + post('/', {"authenticity_token" => Rack::Protection::FormToken.token(session)}, 'rack.session' => session) expect(last_response).to be_ok end it "denies post form requests with wrong authenticity_token field" do - post('/', {"authenticity_token" => "a"}, 'rack.session' => {:csrf => "b"}) + post('/', {"authenticity_token" => bad_token}, 'rack.session' => session) expect(last_response).not_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 7fa0de0966..8962269187 100644 --- a/rack-protection/spec/lib/rack/protection/remote_token_spec.rb +++ b/rack-protection/spec/lib/rack/protection/remote_token_spec.rb @@ -1,4 +1,8 @@ describe Rack::Protection::RemoteToken do + let(:token) { described_class.random_token } + let(:bad_token) { described_class.random_token } + let(:session) { {:csrf => token} } + it_behaves_like "any rack application" it "accepts post requests with no referrer" do @@ -16,25 +20,37 @@ it "accepts post requests with a remote referrer and correct X-CSRF-Token header" do post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org', - 'rack.session' => {:csrf => "a"}, 'HTTP_X_CSRF_TOKEN' => "a") + 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => token) + expect(last_response).to be_ok + end + + 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)) expect(last_response).to be_ok end it "denies post requests with a remote referrer and wrong X-CSRF-Token header" do post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org', - 'rack.session' => {:csrf => "a"}, 'HTTP_X_CSRF_TOKEN' => "b") + 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => bad_token) expect(last_response).not_to be_ok end it "accepts post form requests with a remote referrer and correct authenticity_token field" do - post('/', {"authenticity_token" => "a"}, 'HTTP_REFERER' => 'http://example.com/foo', - 'HTTP_HOST' => 'example.org', 'rack.session' => {:csrf => "a"}) + post('/', {"authenticity_token" => token}, 'HTTP_REFERER' => 'http://example.com/foo', + 'HTTP_HOST' => 'example.org', 'rack.session' => session) + expect(last_response).to be_ok + 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', + 'HTTP_HOST' => 'example.org', 'rack.session' => session) expect(last_response).to be_ok end it "denies post form requests with a remote referrer and wrong authenticity_token field" do - post('/', {"authenticity_token" => "a"}, 'HTTP_REFERER' => 'http://example.com/foo', - 'HTTP_HOST' => 'example.org', 'rack.session' => {:csrf => "b"}) + post('/', {"authenticity_token" => bad_token}, 'HTTP_REFERER' => 'http://example.com/foo', + 'HTTP_HOST' => 'example.org', 'rack.session' => session) expect(last_response).not_to be_ok end end