diff options
Diffstat (limited to 'activesupport')
18 files changed, 328 insertions, 37 deletions
diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index facd723bc5..f5542c6d25 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,7 @@ +* Make the order of `Hash#reverse_merge!` consistent with `HashWithIndifferentAccess`. + + *Erol Fornoles* + * Add `freeze_time` helper which freezes time to `Time.now` in tests. *Prathamesh Sonpatki* diff --git a/activesupport/lib/active_support/cache.rb b/activesupport/lib/active_support/cache.rb index 865210e5f5..49d8965cb1 100644 --- a/activesupport/lib/active_support/cache.rb +++ b/activesupport/lib/active_support/cache.rb @@ -101,7 +101,9 @@ module ActiveSupport # Obtains the specified cache store class, given the name of the +store+. # Raises an error when the store class cannot be found. def retrieve_store_class(store) - require_relative "cache/#{store}" + # require_relative cannot be used here because the class might be + # provided by another gem, like redis-activesupport for example. + require "active_support/cache/#{store}" rescue LoadError => e raise "Could not find cache store adapter for #{store} (#{e})" else diff --git a/activesupport/lib/active_support/core_ext/class/attribute.rb b/activesupport/lib/active_support/core_ext/class/attribute.rb index a72dbc7bf0..e5a52db36a 100644 --- a/activesupport/lib/active_support/core_ext/class/attribute.rb +++ b/activesupport/lib/active_support/core_ext/class/attribute.rb @@ -8,6 +8,16 @@ class Class # Declare a class-level attribute whose value is inheritable by subclasses. # Subclasses can change their own value and it will not impact parent class. # + # ==== Options + # + # * <tt>:instance_reader</tt> - Sets the instance reader method (defaults to true). + # * <tt>:instance_writer</tt> - Sets the instance writer method (defaults to true). + # * <tt>:instance_accessor</tt> - Sets both instance methods (defaults to true). + # * <tt>:instance_predicate</tt> - Sets a predicate method (defaults to true). + # * <tt>:default</tt> - Sets a default value for the attribute (defaults to nil). + # + # ==== Examples + # # class Base # class_attribute :setting # end diff --git a/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb b/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb index da53c29aa0..ef8d592829 100644 --- a/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb +++ b/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb @@ -18,8 +18,7 @@ class Hash # Destructive +reverse_merge+. def reverse_merge!(other_hash) - # right wins if there is no left - merge!(other_hash) { |key, left, right| left } + replace(reverse_merge(other_hash)) end alias_method :reverse_update, :reverse_merge! alias_method :with_defaults!, :reverse_merge! diff --git a/activesupport/lib/active_support/hash_with_indifferent_access.rb b/activesupport/lib/active_support/hash_with_indifferent_access.rb index 7792b59abf..44e95f58a1 100644 --- a/activesupport/lib/active_support/hash_with_indifferent_access.rb +++ b/activesupport/lib/active_support/hash_with_indifferent_access.rb @@ -76,16 +76,6 @@ module ActiveSupport end end - def default(*args) - arg_key = args.first - - if include?(key = convert_key(arg_key)) - self[key] - else - super - end - end - def self.[](*args) new.merge!(Hash[*args]) end @@ -187,6 +177,36 @@ module ActiveSupport super(convert_key(key), *extras) end + if Hash.new.respond_to?(:dig) + # Same as <tt>Hash#dig</tt> where the key passed as argument can be + # either a string or a symbol: + # + # counters = ActiveSupport::HashWithIndifferentAccess.new + # counters[:foo] = { bar: 1 } + # + # counters.dig('foo', 'bar') # => 1 + # counters.dig(:foo, :bar) # => 1 + # counters.dig(:zoo) # => nil + def dig(*args) + args[0] = convert_key(args[0]) if args.size > 0 + super(*args) + end + end + + # Same as <tt>Hash#default</tt> where the key passed as argument can be + # either a string or a symbol: + # + # hash = ActiveSupport::HashWithIndifferentAccess.new(1) + # hash.default # => 1 + # + # hash = ActiveSupport::HashWithIndifferentAccess.new { |hash, key| key } + # hash.default # => nil + # hash.default('foo') # => 'foo' + # hash.default(:foo) # => 'foo' + def default(*args) + super(*args.map { |arg| convert_key(arg) }) + end + # Returns an array of the values at the specified indices: # # hash = ActiveSupport::HashWithIndifferentAccess.new @@ -244,7 +264,7 @@ module ActiveSupport # Same semantics as +reverse_merge+ but modifies the receiver in-place. def reverse_merge!(other_hash) - replace(reverse_merge(other_hash)) + super(self.class.new(other_hash)) end alias_method :with_defaults!, :reverse_merge! diff --git a/activesupport/lib/active_support/inflector/transliterate.rb b/activesupport/lib/active_support/inflector/transliterate.rb index 246fe7a916..aa7b21734e 100644 --- a/activesupport/lib/active_support/inflector/transliterate.rb +++ b/activesupport/lib/active_support/inflector/transliterate.rb @@ -61,9 +61,10 @@ module ActiveSupport def transliterate(string, replacement = "?".freeze) raise ArgumentError, "Can only transliterate strings. Received #{string.class.name}" unless string.is_a?(String) - I18n.transliterate(ActiveSupport::Multibyte::Unicode.normalize( - ActiveSupport::Multibyte::Unicode.tidy_bytes(string), :c), - replacement: replacement) + I18n.transliterate( + ActiveSupport::Multibyte::Unicode.normalize( + ActiveSupport::Multibyte::Unicode.tidy_bytes(string), :c), + replacement: replacement) end # Replaces special characters in a string so that it may be used as part of diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb index d5db2920b9..9ceb3a3a7f 100644 --- a/activesupport/lib/active_support/message_encryptor.rb +++ b/activesupport/lib/active_support/message_encryptor.rb @@ -4,6 +4,7 @@ require "openssl" require "base64" require_relative "core_ext/array/extract_options" require_relative "message_verifier" +require_relative "messages/metadata" module ActiveSupport # MessageEncryptor is a simple way to encrypt values which get stored @@ -87,14 +88,15 @@ module ActiveSupport # Encrypt and sign a message. We need to sign the message in order to avoid # padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks. - def encrypt_and_sign(value) - verifier.generate(_encrypt(value)) + def encrypt_and_sign(value, expires_at: nil, expires_in: nil, purpose: nil) + data = Messages::Metadata.wrap(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose) + verifier.generate(_encrypt(data)) end # Decrypt and verify a message. We need to verify the message in order to # avoid padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks. - def decrypt_and_verify(value) - _decrypt(verifier.verify(value)) + def decrypt_and_verify(data, purpose: nil) + Messages::Metadata.verify(_decrypt(verifier.verify(data)), purpose) end # Given a cipher, returns the key length of the cipher to help generate the key of desired size @@ -103,7 +105,6 @@ module ActiveSupport end private - def _encrypt(value) cipher = new_cipher cipher.encrypt diff --git a/activesupport/lib/active_support/message_verifier.rb b/activesupport/lib/active_support/message_verifier.rb index b889f31f7a..76b3865bf2 100644 --- a/activesupport/lib/active_support/message_verifier.rb +++ b/activesupport/lib/active_support/message_verifier.rb @@ -3,6 +3,7 @@ require "base64" require_relative "core_ext/object/blank" require_relative "security_utils" +require_relative "messages/metadata" module ActiveSupport # +MessageVerifier+ makes it easy to generate and verify messages which are @@ -79,11 +80,11 @@ module ActiveSupport # # incompatible_message = "test--dad7b06c94abba8d46a15fafaef56c327665d5ff" # verifier.verified(incompatible_message) # => TypeError: incompatible marshal file format - def verified(signed_message) + def verified(signed_message, purpose: nil) if valid_message?(signed_message) begin data = signed_message.split("--".freeze)[0] - @serializer.load(decode(data)) + Messages::Metadata.verify(@serializer.load(decode(data)), purpose) rescue ArgumentError => argument_error return if argument_error.message.include?("invalid base64") raise @@ -103,8 +104,8 @@ module ActiveSupport # # other_verifier = ActiveSupport::MessageVerifier.new 'd1ff3r3nt-s3Krit' # other_verifier.verify(signed_message) # => ActiveSupport::MessageVerifier::InvalidSignature - def verify(signed_message) - verified(signed_message) || raise(InvalidSignature) + def verify(signed_message, purpose: nil) + verified(signed_message, purpose: purpose) || raise(InvalidSignature) end # Generates a signed message for the provided value. @@ -114,8 +115,8 @@ module ActiveSupport # # verifier = ActiveSupport::MessageVerifier.new 's3Krit' # verifier.generate 'a private message' # => "BAhJIhRwcml2YXRlLW1lc3NhZ2UGOgZFVA==--e2d724331ebdee96a10fb99b089508d1c72bd772" - def generate(value) - data = encode(@serializer.dump(value)) + def generate(value, expires_at: nil, expires_in: nil, purpose: nil) + data = encode(@serializer.dump(Messages::Metadata.wrap(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose))) "#{data}--#{generate_digest(data)}" end diff --git a/activesupport/lib/active_support/messages/metadata.rb b/activesupport/lib/active_support/messages/metadata.rb new file mode 100644 index 0000000000..db14ac0b1c --- /dev/null +++ b/activesupport/lib/active_support/messages/metadata.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require "time" + +module ActiveSupport + module Messages #:nodoc: + class Metadata #:nodoc: + def initialize(expires_at, purpose) + @expires_at, @purpose = expires_at, purpose.to_s + end + + class << self + def wrap(message, expires_at: nil, expires_in: nil, purpose: nil) + if expires_at || expires_in || purpose + { "value" => message, "_rails" => { "exp" => pick_expiry(expires_at, expires_in), "pur" => purpose } } + else + message + end + end + + def verify(message, purpose) + metadata = extract_metadata(message) + + if metadata.nil? + message if purpose.nil? + elsif metadata.match?(purpose) && metadata.fresh? + message["value"] + end + end + + private + def pick_expiry(expires_at, expires_in) + if expires_at + expires_at.utc.iso8601(3) + elsif expires_in + Time.now.utc.advance(seconds: expires_in).iso8601(3) + end + end + + def extract_metadata(message) + if message.is_a?(Hash) && message.key?("_rails") + new(message["_rails"]["exp"], message["_rails"]["pur"]) + end + end + end + + def match?(purpose) + @purpose == purpose.to_s + end + + def fresh? + @expires_at.nil? || Time.now.utc < Time.iso8601(@expires_at) + end + end + end +end diff --git a/activesupport/lib/active_support/testing/time_helpers.rb b/activesupport/lib/active_support/testing/time_helpers.rb index 072103e771..fa5f46736c 100644 --- a/activesupport/lib/active_support/testing/time_helpers.rb +++ b/activesupport/lib/active_support/testing/time_helpers.rb @@ -158,7 +158,7 @@ module ActiveSupport end # Returns the current time back to its original state, by removing the stubs added by - # `travel` and `travel_to`. + # +travel+ and +travel_to+. # # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 # travel_to Time.zone.local(2004, 11, 24, 01, 04, 44) @@ -169,7 +169,7 @@ module ActiveSupport simple_stubs.unstub_all! end - # Calls `travel_to` with `Time.now`. + # Calls +travel_to+ with +Time.now+. # # Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00 # freeze_time diff --git a/activesupport/test/clean_backtrace_test.rb b/activesupport/test/clean_backtrace_test.rb index e9361ecd26..1b44c7c9bf 100644 --- a/activesupport/test/clean_backtrace_test.rb +++ b/activesupport/test/clean_backtrace_test.rb @@ -10,8 +10,8 @@ class BacktraceCleanerFilterTest < ActiveSupport::TestCase test "backtrace should filter all lines in a backtrace, removing prefixes" do assert_equal \ - ["/my/class.rb", "/my/module.rb"], - @bc.clean(["/my/prefix/my/class.rb", "/my/prefix/my/module.rb"]) + ["/my/class.rb", "/my/module.rb"], + @bc.clean(["/my/prefix/my/class.rb", "/my/prefix/my/module.rb"]) end test "backtrace cleaner should allow removing filters" do diff --git a/activesupport/test/core_ext/hash_ext_test.rb b/activesupport/test/core_ext/hash_ext_test.rb index c537fb86fe..746d7ad416 100644 --- a/activesupport/test/core_ext/hash_ext_test.rb +++ b/activesupport/test/core_ext/hash_ext_test.rb @@ -302,9 +302,9 @@ class HashExtTest < ActiveSupport::TestCase end def test_reverse_merge - defaults = { a: "x", b: "y", c: 10 }.freeze + defaults = { d: 0, a: "x", b: "y", c: 10 }.freeze options = { a: 1, b: 2 } - expected = { a: 1, b: 2, c: 10 } + expected = { d: 0, a: 1, b: 2, c: 10 } # Should merge defaults into options, creating a new hash. assert_equal expected, options.reverse_merge(defaults) @@ -315,6 +315,9 @@ class HashExtTest < ActiveSupport::TestCase assert_equal expected, merged.reverse_merge!(defaults) assert_equal expected, merged + # Make the order consistent with the non-overwriting reverse merge. + assert_equal expected.keys, merged.keys + # Should be an alias for reverse_merge! merged = options.dup assert_equal expected, merged.reverse_update(defaults) diff --git a/activesupport/test/hash_with_indifferent_access_test.rb b/activesupport/test/hash_with_indifferent_access_test.rb index 4acdd10de6..77fc039459 100644 --- a/activesupport/test/hash_with_indifferent_access_test.rb +++ b/activesupport/test/hash_with_indifferent_access_test.rb @@ -537,6 +537,32 @@ class HashWithIndifferentAccessTest < ActiveSupport::TestCase assert_equal 1234, data.dig(:this, :views) end + def test_argless_default_with_existing_nil_key + h = Hash.new(:default).merge(nil => "defined").with_indifferent_access + + assert_equal :default, h.default + end + + def test_default_with_argument + h = Hash.new { 5 }.merge(1 => 2).with_indifferent_access + + assert_equal 5, h.default(1) + end + + def test_default_proc + h = ActiveSupport::HashWithIndifferentAccess.new { |hash, key| key } + + assert_nil h.default + assert_equal "foo", h.default("foo") + assert_equal "foo", h.default(:foo) + end + + def test_double_conversion_with_nil_key + h = { nil => "defined" }.with_indifferent_access.with_indifferent_access + + assert_equal nil, h[:undefined_key] + end + def test_assorted_keys_not_stringified original = { Object.new => 2, 1 => 2, [] => true } indiff = original.with_indifferent_access diff --git a/activesupport/test/message_encryptor_test.rb b/activesupport/test/message_encryptor_test.rb index 832841597f..1fbe655642 100644 --- a/activesupport/test/message_encryptor_test.rb +++ b/activesupport/test/message_encryptor_test.rb @@ -4,6 +4,7 @@ require "abstract_unit" require "openssl" require "active_support/time" require "active_support/json" +require_relative "metadata/shared_metadata_tests" class MessageEncryptorTest < ActiveSupport::TestCase class JSONSerializer @@ -106,8 +107,15 @@ class MessageEncryptorTest < ActiveSupport::TestCase assert_aead_not_decrypted(encryptor, [text, iv, auth_tag[0..-2]] * "--") end - private + def test_backwards_compatibility_decrypt_previously_encrypted_messages_without_metadata + secret = "\xB7\xF0\xBCW\xB1\x18`\xAB\xF0\x81\x10\xA4$\xF44\xEC\xA1\xDC\xC1\xDDD\xAF\xA9\xB8\x14\xCD\x18\x9A\x99 \x80)" + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm") + encrypted_message = "9cVnFs2O3lL9SPvIJuxBOLS51nDiBMw=--YNI5HAfHEmZ7VDpl--ddFJ6tXA0iH+XGcCgMINYQ==" + + assert_equal "Ruby on Rails", encryptor.decrypt_and_verify(encrypted_message) + end + private def assert_aead_not_decrypted(encryptor, value) assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do encryptor.decrypt_and_verify(value) @@ -132,3 +140,37 @@ class MessageEncryptorTest < ActiveSupport::TestCase ::Base64.strict_encode64(bits) end end + +class MessageEncryptorMetadataTest < ActiveSupport::TestCase + include SharedMessageMetadataTests + + setup do + @secret = SecureRandom.random_bytes(32) + @encryptor = ActiveSupport::MessageEncryptor.new(@secret, encryptor_options) + end + + private + def generate(message, **options) + @encryptor.encrypt_and_sign(message, options) + end + + def parse(data, **options) + @encryptor.decrypt_and_verify(data, options) + end + + def encryptor_options; end +end + +class MessageEncryptorMetadataMarshalTest < MessageEncryptorMetadataTest + private + def encryptor_options + { serializer: Marshal } + end +end + +class MessageEncryptorMetadataJSONTest < MessageEncryptorMetadataTest + private + def encryptor_options + { serializer: MessageEncryptorTest::JSONSerializer.new } + end +end diff --git a/activesupport/test/message_verifier_test.rb b/activesupport/test/message_verifier_test.rb index ba886f9fb4..d4a8ce762a 100644 --- a/activesupport/test/message_verifier_test.rb +++ b/activesupport/test/message_verifier_test.rb @@ -4,6 +4,7 @@ require "abstract_unit" require "openssl" require "active_support/time" require "active_support/json" +require_relative "metadata/shared_metadata_tests" class MessageVerifierTest < ActiveSupport::TestCase class JSONSerializer @@ -84,4 +85,44 @@ class MessageVerifierTest < ActiveSupport::TestCase end assert_equal "Secret should not be nil.", exception.message end + + def test_backward_compatibility_messages_signed_without_metadata + signed_message = "BAh7BzoJc29tZUkiCWRhdGEGOgZFVDoIbm93SXU6CVRpbWUNIIAbgAAAAAAHOgtvZmZzZXRpADoJem9uZUkiCFVUQwY7BkY=--d03c52c91dfe4ccc5159417c660461bcce005e96" + assert_equal @data, @verifier.verify(signed_message) + end +end + +class MessageVerifierMetadataTest < ActiveSupport::TestCase + include SharedMessageMetadataTests + + setup do + @verifier = ActiveSupport::MessageVerifier.new("Hey, I'm a secret!", verifier_options) + end + + private + def generate(message, **options) + @verifier.generate(message, options) + end + + def parse(message, **options) + @verifier.verified(message, options) + end + + def verifier_options + Hash.new + end +end + +class MessageVerifierMetadataMarshalTest < MessageVerifierMetadataTest + private + def verifier_options + { serializer: Marshal } + end +end + +class MessageVerifierMetadataJSONTest < MessageVerifierMetadataTest + private + def verifier_options + { serializer: MessageVerifierTest::JSONSerializer.new } + end end diff --git a/activesupport/test/metadata/shared_metadata_tests.rb b/activesupport/test/metadata/shared_metadata_tests.rb new file mode 100644 index 0000000000..7d88e255c7 --- /dev/null +++ b/activesupport/test/metadata/shared_metadata_tests.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module SharedMessageMetadataTests + def setup + @message = { "credit_card_no" => "5012-6784-9087-5678", "card_holder" => { "name" => "Donald" } } + + super + end + + def teardown + travel_back + + super + end + + def test_encryption_and_decryption_with_same_purpose + assert_equal @message, parse(generate(@message, purpose: "checkout"), purpose: "checkout") + assert_equal @message, parse(generate(@message)) + + string_message = "address: #23, main street" + assert_equal string_message, parse(generate(string_message, purpose: "shipping"), purpose: "shipping") + + array_message = ["credit_card_no: 5012-6748-9087-5678", { "card_holder" => "Donald", "issued_on" => Time.local(2017) }, 12345] + assert_equal array_message, parse(generate(array_message, purpose: "registration"), purpose: "registration") + end + + def test_encryption_and_decryption_with_different_purposes_returns_nil + assert_nil parse(generate(@message, purpose: "payment"), purpose: "sign up") + assert_nil parse(generate(@message, purpose: "payment")) + assert_nil parse(generate(@message), purpose: "sign up") + assert_nil parse(generate(@message), purpose: "") + end + + def test_purpose_using_symbols + assert_equal @message, parse(generate(@message, purpose: :checkout), purpose: :checkout) + assert_equal @message, parse(generate(@message, purpose: :checkout), purpose: "checkout") + assert_equal @message, parse(generate(@message, purpose: "checkout"), purpose: :checkout) + end + + def test_passing_expires_at_sets_expiration_date + encrypted_message = generate(@message, expires_at: 1.hour.from_now) + + travel 59.minutes + assert_equal @message, parse(encrypted_message) + + travel 2.minutes + assert_nil parse(encrypted_message) + end + + def test_set_relative_expiration_date_by_passing_expires_in + encrypted_message = generate(@message, expires_in: 2.hours) + + travel 1.hour + assert_equal @message, parse(encrypted_message) + + travel 1.hour + 1.second + assert_nil parse(encrypted_message) + end + + def test_passing_expires_in_less_than_a_second_is_not_expired + freeze_time do + encrypted_message = generate(@message, expires_in: 1.second) + + travel 0.5.seconds + assert_equal @message, parse(encrypted_message) + + travel 1.second + assert_nil parse(encrypted_message) + end + end + + def test_favor_expires_at_over_expires_in + payment_related_message = generate(@message, purpose: "payment", expires_at: 2.year.from_now, expires_in: 1.second) + + travel 1.year + assert_equal @message, parse(payment_related_message, purpose: :payment) + + travel 1.year + 1.day + assert_nil parse(payment_related_message, purpose: "payment") + end + + def test_skip_expires_at_and_expires_in_to_disable_expiration_check + payment_related_message = generate(@message, purpose: "payment") + + travel 100.years + assert_equal @message, parse(payment_related_message, purpose: "payment") + end +end diff --git a/activesupport/test/multibyte_grapheme_break_conformance_test.rb b/activesupport/test/multibyte_grapheme_break_conformance_test.rb index 855626e779..fac74cd80f 100644 --- a/activesupport/test/multibyte_grapheme_break_conformance_test.rb +++ b/activesupport/test/multibyte_grapheme_break_conformance_test.rb @@ -1,4 +1,3 @@ -# encoding: utf-8 # frozen_string_literal: true require "abstract_unit" diff --git a/activesupport/test/multibyte_normalization_conformance_test.rb b/activesupport/test/multibyte_normalization_conformance_test.rb index deb94a7aa3..1173a94e81 100644 --- a/activesupport/test/multibyte_normalization_conformance_test.rb +++ b/activesupport/test/multibyte_normalization_conformance_test.rb @@ -1,4 +1,3 @@ -# encoding: utf-8 # frozen_string_literal: true require "abstract_unit" |