Skip to content

Commit 91bb5da

Browse files
Avoid double serialization of message data
Prior to this commit, messages with metadata were always serialized in the following way: ```ruby Base64.strict_encode64( ActiveSupport::JSON.encode({ "_rails" => { "message" => Base64.strict_encode64( serializer.dump(data) ), "pur" => "the purpose", "exp" => "the expiration" }, }) ) ``` in which the message data is serialized and URL-encoded twice. This commit changes message serialization such that, when possible, the data is serialized and URL-encoded only once: ```ruby Base64.strict_encode64( serializer.dump({ "_rails" => { "data" => data, "pur" => "the purpose", "exp" => "the expiration" }, }) ) ``` This improves performance in proportion to the size of the data: **Benchmark** ```ruby # frozen_string_literal: true require "benchmark/ips" require "active_support/all" verifier = ActiveSupport::MessageVerifier.new("secret", serializer: JSON) payloads = [ { "content" => "x" * 100 }, { "content" => "x" * 2000 }, { "content" => "x" * 1_000_000 }, ] if ActiveSupport::Messages::Metadata.respond_to?(:use_message_serializer_for_metadata) ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata = true end Benchmark.ips do |x| payloads.each do |payload| x.report("generate ~#{payload["content"].size}B") do $generated_message = verifier.generate(payload, purpose: "x") end x.report("verify ~#{payload["content"].size}B") do verifier.verify($generated_message, purpose: "x") end end end puts puts "Message size:" payloads.each do |payload| puts " ~#{payload["content"].size} bytes of data => " \ "#{verifier.generate(payload, purpose: "x").size} byte message" end ``` **Before** ``` Warming up -------------------------------------- generate ~100B 1.578k i/100ms verify ~100B 2.506k i/100ms generate ~2000B 447.000 i/100ms verify ~2000B 1.409k i/100ms generate ~1000000B 1.000 i/100ms verify ~1000000B 6.000 i/100ms Calculating ------------------------------------- generate ~100B 15.807k (± 1.8%) i/s - 80.478k in 5.093161s verify ~100B 25.240k (± 2.1%) i/s - 127.806k in 5.066096s generate ~2000B 4.530k (± 2.4%) i/s - 22.797k in 5.035398s verify ~2000B 14.136k (± 2.3%) i/s - 71.859k in 5.086267s generate ~1000000B 11.673 (± 0.0%) i/s - 59.000 in 5.060598s verify ~1000000B 64.372 (± 6.2%) i/s - 324.000 in 5.053304s Message size: ~100 bytes of data => 306 byte message ~2000 bytes of data => 3690 byte message ~1000000 bytes of data => 1777906 byte message ``` **After** ``` Warming up -------------------------------------- generate ~100B 4.689k i/100ms verify ~100B 3.183k i/100ms generate ~2000B 2.722k i/100ms verify ~2000B 2.066k i/100ms generate ~1000000B 12.000 i/100ms verify ~1000000B 11.000 i/100ms Calculating ------------------------------------- generate ~100B 46.984k (± 1.2%) i/s - 239.139k in 5.090540s verify ~100B 32.043k (± 1.2%) i/s - 162.333k in 5.066903s generate ~2000B 27.163k (± 1.2%) i/s - 136.100k in 5.011254s verify ~2000B 20.726k (± 1.7%) i/s - 105.366k in 5.085442s generate ~1000000B 125.600 (± 1.6%) i/s - 636.000 in 5.064607s verify ~1000000B 122.039 (± 4.1%) i/s - 616.000 in 5.058386s Message size: ~100 bytes of data => 234 byte message ~2000 bytes of data => 2770 byte message ~1000000 bytes of data => 1333434 byte message ``` This optimization is only applied for recognized serializers that are capable of serializing a `Hash`. Additionally, because the optimization changes the message format, a `config.active_support.use_message_serializer_for_metadata` option has been added to disable it. The optimization is disabled by default, but enabled with `config.load_defaults 7.1`. Regardless of whether the optimization is enabled, messages using either format can still be read. In the case of a rolling deploy of a Rails upgrade, wherein servers that have not yet been upgraded must be able to read messages from upgraded servers, the optimization can be disabled on first deploy, then safely enabled on a subsequent deploy.
1 parent ebc3b66 commit 91bb5da

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)