Skip to content

Commit 81ded4d

Browse files
Merge pull request rails#46848 from jonathanhefner/messages-use_message_serializer_for_metadata
Avoid double serialization of message data
2 parents 63f7439 + 91bb5da commit 81ded4d

File tree

10 files changed

+184
-70
lines changed

10 files changed

+184
-70
lines changed

activesupport/lib/active_support/message_encryptor.rb

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ module ActiveSupport
8585
#
8686
# crypt.rotate old_secret, cipher: "aes-256-cbc"
8787
class MessageEncryptor
88+
include Messages::Metadata
8889
prepend Messages::Rotator::Encryptor
8990

9091
cattr_accessor :use_authenticated_message_encryption, instance_accessor: false, default: false
@@ -221,13 +222,7 @@ def self.key_len(cipher = default_cipher)
221222
end
222223

223224
private
224-
def serialize(value)
225-
@serializer.dump(value)
226-
end
227-
228-
def deserialize(value)
229-
@serializer.load(value)
230-
end
225+
attr_reader :serializer
231226

232227
def encode(data)
233228
@url_safe ? ::Base64.urlsafe_encode64(data, padding: false) : ::Base64.strict_encode64(data)
@@ -246,7 +241,7 @@ def _encrypt(value, **metadata_options)
246241
iv = cipher.random_iv
247242
cipher.auth_data = "" if aead_mode?
248243

249-
encrypted_data = cipher.update(Messages::Metadata.wrap(serialize(value), **metadata_options))
244+
encrypted_data = cipher.update(serialize_with_metadata(value, **metadata_options))
250245
encrypted_data << cipher.final
251246

252247
parts = [encrypted_data, iv]
@@ -275,8 +270,7 @@ def _decrypt(encrypted_message, purpose)
275270
decrypted_data = cipher.update(encrypted_data)
276271
decrypted_data << cipher.final
277272

278-
message = Messages::Metadata.verify(decrypted_data, purpose)
279-
deserialize(message) if message
273+
deserialize_with_metadata(decrypted_data, purpose: purpose)
280274
rescue OpenSSLCipherError, TypeError, ArgumentError, ::JSON::ParserError
281275
raise InvalidMessage
282276
end

activesupport/lib/active_support/message_verifier.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ module ActiveSupport
119119
# @verifier = ActiveSupport::MessageVerifier.new("secret", url_safe: true)
120120
# @verifier.generate("signed message") #=> URL-safe string
121121
class MessageVerifier
122+
include Messages::Metadata
122123
prepend Messages::Rotator::Verifier
123124

124125
class InvalidSignature < StandardError; end
@@ -198,8 +199,7 @@ def verified(signed_message, purpose: nil, **)
198199
data, digest = get_data_and_digest_from(signed_message)
199200
if digest_matches_data?(digest, data)
200201
begin
201-
message = Messages::Metadata.verify(decode(data), purpose)
202-
@serializer.load(message) if message
202+
deserialize_with_metadata(decode(data), purpose: purpose)
203203
rescue ArgumentError => argument_error
204204
return if argument_error.message.include?("invalid base64")
205205
raise
@@ -274,11 +274,14 @@ def verify(*args, **options)
274274
# specified when verifying the message; otherwise, verification will fail.
275275
# (See #verified and #verify.)
276276
def generate(value, expires_at: nil, expires_in: nil, purpose: nil)
277-
data = encode(Messages::Metadata.wrap(@serializer.dump(value), expires_at: expires_at, expires_in: expires_in, purpose: purpose))
278-
"#{data}#{SEPARATOR}#{generate_digest(data)}"
277+
data = encode(serialize_with_metadata(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose))
278+
digest = generate_digest(data)
279+
data << SEPARATOR << digest
279280
end
280281

281282
private
283+
attr_reader :serializer
284+
282285
def encode(data)
283286
@url_safe ? Base64.urlsafe_encode64(data, padding: false) : Base64.strict_encode64(data)
284287
end
Lines changed: 70 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,101 @@
11
# frozen_string_literal: true
22

33
require "time"
4+
require "active_support/json"
45

56
module ActiveSupport
67
module Messages # :nodoc:
7-
class Metadata # :nodoc:
8-
def initialize(message, expires_at = nil, purpose = nil)
9-
@message, @purpose = message, purpose
10-
@expires_at = expires_at.is_a?(String) ? parse_expires_at(expires_at) : expires_at
11-
end
8+
module Metadata # :nodoc:
9+
singleton_class.attr_accessor :use_message_serializer_for_metadata
1210

13-
def as_json(options = {})
14-
{ _rails: { message: @message, exp: @expires_at, pur: @purpose } }
15-
end
11+
ENVELOPE_SERIALIZERS = [
12+
::JSON,
13+
ActiveSupport::JSON,
14+
ActiveSupport::JsonWithMarshalFallback,
15+
Marshal,
16+
]
1617

17-
class << self
18-
def wrap(message, expires_at: nil, expires_in: nil, purpose: nil)
19-
if expires_at || expires_in || purpose
20-
JSON.encode new(encode(message), pick_expiry(expires_at, expires_in), purpose)
18+
private
19+
def serialize_with_metadata(data, **metadata)
20+
has_metadata = metadata.any? { |k, v| v }
21+
22+
if has_metadata && !use_message_serializer_for_metadata?
23+
data_string = serialize_to_json_safe_string(data)
24+
envelope = wrap_in_metadata_envelope({ "message" => data_string }, **metadata)
25+
ActiveSupport::JSON.encode(envelope)
2126
else
22-
message
27+
data = wrap_in_metadata_envelope({ "data" => data }, **metadata) if has_metadata
28+
serializer.dump(data)
2329
end
2430
end
2531

26-
def verify(message, purpose)
27-
extract_metadata(message).verify(purpose)
28-
end
29-
30-
private
31-
def pick_expiry(expires_at, expires_in)
32-
if expires_at
33-
expires_at.utc.iso8601(3)
34-
elsif expires_in
35-
Time.now.utc.advance(seconds: expires_in).iso8601(3)
36-
end
37-
end
38-
39-
def extract_metadata(message)
40-
begin
41-
data = JSON.decode(message) if message.start_with?('{"_rails":')
42-
rescue ::JSON::JSONError
43-
end
44-
45-
if data
46-
new(decode(data["_rails"]["message"]), data["_rails"]["exp"], data["_rails"]["pur"])
32+
def deserialize_with_metadata(message, **expected_metadata)
33+
if dual_serialized_metadata_envelope_json?(message)
34+
envelope = ActiveSupport::JSON.decode(message)
35+
extracted = extract_from_metadata_envelope(envelope, **expected_metadata)
36+
deserialize_from_json_safe_string(extracted["message"]) if extracted
37+
else
38+
deserialized = serializer.load(message)
39+
if metadata_envelope?(deserialized)
40+
extracted = extract_from_metadata_envelope(deserialized, **expected_metadata)
41+
extracted["data"] if extracted
4742
else
48-
new(message)
43+
deserialized if expected_metadata.none? { |k, v| v }
4944
end
5045
end
46+
end
5147

52-
def encode(message)
53-
::Base64.strict_encode64(message)
54-
end
48+
def use_message_serializer_for_metadata?
49+
Metadata.use_message_serializer_for_metadata && Metadata::ENVELOPE_SERIALIZERS.include?(serializer)
50+
end
5551

56-
def decode(message)
57-
::Base64.strict_decode64(message)
58-
end
59-
end
52+
def wrap_in_metadata_envelope(hash, expires_at: nil, expires_in: nil, purpose: nil)
53+
expiry = pick_expiry(expires_at, expires_in)
54+
hash["exp"] = expiry if expiry
55+
hash["pur"] = purpose.to_s if purpose
56+
{ "_rails" => hash }
57+
end
6058

61-
def verify(purpose)
62-
@message if match?(purpose) && fresh?
63-
end
59+
def extract_from_metadata_envelope(envelope, purpose: nil)
60+
hash = envelope["_rails"]
61+
return if hash["exp"] && Time.now.utc >= parse_expiry(hash["exp"])
62+
return if hash["pur"] != purpose&.to_s
63+
hash
64+
end
6465

65-
private
66-
def match?(purpose)
67-
@purpose.to_s == purpose.to_s
66+
def metadata_envelope?(object)
67+
object.is_a?(Hash) && object.key?("_rails")
68+
end
69+
70+
def dual_serialized_metadata_envelope_json?(string)
71+
string.start_with?('{"_rails":{"message":')
6872
end
6973

70-
def fresh?
71-
@expires_at.nil? || Time.now.utc < @expires_at
74+
def pick_expiry(expires_at, expires_in)
75+
if expires_at
76+
expires_at.utc.iso8601(3)
77+
elsif expires_in
78+
Time.now.utc.advance(seconds: expires_in).iso8601(3)
79+
end
7280
end
7381

74-
def parse_expires_at(expires_at)
75-
if ActiveSupport.use_standard_json_time_format
82+
def parse_expiry(expires_at)
83+
if !expires_at.is_a?(String)
84+
expires_at
85+
elsif ActiveSupport.use_standard_json_time_format
7686
Time.iso8601(expires_at)
7787
else
7888
Time.parse(expires_at)
7989
end
8090
end
91+
92+
def serialize_to_json_safe_string(data)
93+
::Base64.strict_encode64(serializer.dump(data))
94+
end
95+
96+
def deserialize_from_json_safe_string(string)
97+
serializer.load(::Base64.strict_decode64(string))
98+
end
8199
end
82100
end
83101
end

activesupport/lib/active_support/railtie.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,12 @@ class Railtie < Rails::Railtie # :nodoc:
192192
end
193193
end
194194
end
195+
196+
initializer "active_support.set_use_message_serializer_for_metadata" do |app|
197+
config.after_initialize do
198+
ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata =
199+
app.config.active_support.use_message_serializer_for_metadata
200+
end
201+
end
195202
end
196203
end

activesupport/test/messages/message_metadata_tests.rb

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "active_support/json"
44
require "active_support/time"
5+
require "active_support/messages/metadata"
56

67
module MessageMetadataTests
78
extend ActiveSupport::Concern
@@ -89,6 +90,17 @@ module MessageMetadataTests
8990
codec = make_codec(serializer: ActiveSupport::MessageEncryptor::NullSerializer)
9091
assert_roundtrip "a string", codec, { purpose: "x", expires_in: 1.year }, { purpose: "x" }
9192
end
93+
94+
test "messages are readable regardless of use_message_serializer_for_metadata" do
95+
each_scenario do |data, codec|
96+
message = encode(data, codec, purpose: "x")
97+
message_setting = ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata
98+
99+
using_message_serializer_for_metadata(!message_setting) do
100+
assert_equal data, decode(message, codec, purpose: "x")
101+
end
102+
end
103+
end
92104
end
93105

94106
private
@@ -116,11 +128,23 @@ def self.load(value)
116128
["a string", 123, Time.local(2004), { "key" => "value" }],
117129
]
118130

131+
def using_message_serializer_for_metadata(value = true)
132+
original = ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata
133+
ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata = value
134+
yield
135+
ensure
136+
ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata = original
137+
end
138+
119139
def each_scenario
120-
SERIALIZERS.each do |serializer|
121-
codec = make_codec(serializer: serializer)
122-
DATA.each do |data|
123-
yield data, codec
140+
[false, true].each do |use_message_serializer_for_metadata|
141+
using_message_serializer_for_metadata(use_message_serializer_for_metadata) do
142+
SERIALIZERS.each do |serializer|
143+
codec = make_codec(serializer: serializer)
144+
DATA.each do |data|
145+
yield data, codec
146+
end
147+
end
124148
end
125149
end
126150
end

activesupport/test/messages/message_verifier_metadata_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ class MessageVerifierMetadataTest < ActiveSupport::TestCase
5656
end
5757
end
5858

59+
test "messages are readable by legacy versions when use_message_serializer_for_metadata = false" do
60+
# Message generated by Rails 7.0 using:
61+
#
62+
# verifier = ActiveSupport::MessageVerifier.new("secret", serializer: JSON)
63+
# legacy_message = verifier.generate("legacy", purpose: "test", expires_at: Time.utc(3000))
64+
#
65+
legacy_message = "eyJfcmFpbHMiOnsibWVzc2FnZSI6IklteGxaMkZqZVNJPSIsImV4cCI6IjMwMDAtMDEtMDFUMDA6MDA6MDAuMDAwWiIsInB1ciI6InRlc3QifX0=--81b11c317dba91cedd86ab79b7d7e68de8d290b3"
66+
67+
verifier = ActiveSupport::MessageVerifier.new("secret", serializer: JSON)
68+
69+
using_message_serializer_for_metadata(false) do
70+
assert_equal legacy_message, verifier.generate("legacy", purpose: "test", expires_at: Time.utc(3000))
71+
end
72+
end
73+
5974
private
6075
def make_codec(**options)
6176
ActiveSupport::MessageVerifier.new("secret", **options)

guides/source/configuring.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2195,6 +2195,21 @@ The default value depends on the `config.load_defaults` target version:
21952195
| (original) | `false` |
21962196
| 5.2 | `true` |
21972197

2198+
#### `config.active_support.use_message_serializer_for_metadata`
2199+
2200+
When `true`, enables a performance optimization that serializes message data and
2201+
metadata together. This changes the message format, so messages serialized this
2202+
way cannot be read by older (< 7.1) versions of Rails. However, messages that
2203+
use the old format can still be read, regardless of whether this optimization is
2204+
enabled.
2205+
2206+
The default value depends on the `config.load_defaults` target version:
2207+
2208+
| Starting with version | The default value is |
2209+
| --------------------- | -------------------- |
2210+
| (original) | `false` |
2211+
| 7.1 | `true` |
2212+
21982213
#### `config.active_support.cache_format_version`
21992214

22002215
Specifies which version of the cache serializer to use. Possible values are `6.1` and `7.0`.

railties/lib/rails/application/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ def load_defaults(target_version)
306306
if respond_to?(:active_support)
307307
active_support.default_message_encryptor_serializer = :json
308308
active_support.default_message_verifier_serializer = :json
309+
active_support.use_message_serializer_for_metadata = true
309310
active_support.raise_on_invalid_cache_expiration_time = true
310311
end
311312

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_1.rb.tt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@
9999
#
100100
# For detailed migration steps, check out https://guides.rubyonrails.org/v7.1/upgrading_ruby_on_rails.html#new-activesupport-messageverifier-default-serializer
101101

102+
# Enable a performance optimization that serializes message data and metadata
103+
# together. This changes the message format, so messages serialized this way
104+
# cannot be read by older versions of Rails. However, messages that use the old
105+
# format can still be read, regardless of whether this optimization is enabled.
106+
#
107+
# To perform a rolling deploy of a Rails 7.1 upgrade, wherein servers that have
108+
# not yet been upgraded must be able to read messages from upgraded servers,
109+
# leave this optimization off on the first deploy, then enable it on a
110+
# subsequent deploy.
111+
# Rails.application.config.active_support.use_message_serializer_for_metadata = true
112+
102113
# Set the maximum size for Rails log files.
103114
#
104115
# `config.load_defaults 7.1` does not set this value for environments other than

0 commit comments

Comments
 (0)