rack-session 1.0.2 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2022-2023, by Samuel Williams.
5
+ # Copyright, 2022, by Philip Arndt.
6
+
7
+ require 'base64'
8
+ require 'json'
9
+ require 'openssl'
10
+ require 'securerandom'
11
+
12
+ require 'rack/utils'
13
+
14
+ module Rack
15
+ module Session
16
+ class Encryptor
17
+ class Error < StandardError
18
+ end
19
+
20
+ class InvalidSignature < Error
21
+ end
22
+
23
+ class InvalidMessage < Error
24
+ end
25
+
26
+ module Serializable
27
+ private
28
+
29
+ # Returns a serialized payload of the message. If a :pad_size is supplied,
30
+ # the message will be padded. The first 2 bytes of the returned string will
31
+ # indicating the amount of padding.
32
+ def serialize_payload(message)
33
+ serialized_data = serializer.dump(message)
34
+
35
+ return "#{[0].pack('v')}#{serialized_data.force_encoding(Encoding::BINARY)}" if @options[:pad_size].nil?
36
+
37
+ padding_bytes = @options[:pad_size] - (2 + serialized_data.size) % @options[:pad_size]
38
+ padding_data = SecureRandom.random_bytes(padding_bytes)
39
+
40
+ "#{[padding_bytes].pack('v')}#{padding_data}#{serialized_data.force_encoding(Encoding::BINARY)}"
41
+ end
42
+
43
+ # Return the deserialized message. The first 2 bytes will be read as the
44
+ # amount of padding.
45
+ def deserialized_message(data)
46
+ # Read the first 2 bytes as the padding_bytes size
47
+ padding_bytes, = data.unpack('v')
48
+
49
+ # Slice out the serialized_data and deserialize it
50
+ serialized_data = data.slice(2 + padding_bytes, data.bytesize)
51
+ serializer.load serialized_data
52
+ end
53
+
54
+ def serializer
55
+ @serializer ||= @options[:serialize_json] ? JSON : Marshal
56
+ end
57
+ end
58
+
59
+ class V1
60
+ include Serializable
61
+
62
+ # The secret String must be at least 64 bytes in size. The first 32 bytes
63
+ # will be used for the encryption cipher key. The remainder will be used
64
+ # for an HMAC key.
65
+ #
66
+ # Options may include:
67
+ # * :serialize_json
68
+ # Use JSON for message serialization instead of Marshal. This can be
69
+ # viewed as a security enhancement.
70
+ # * :pad_size
71
+ # Pad encrypted message data, to a multiple of this many bytes
72
+ # (default: 32). This can be between 2-4096 bytes, or +nil+ to disable
73
+ # padding.
74
+ # * :purpose
75
+ # Limit messages to a specific purpose. This can be viewed as a
76
+ # security enhancement to prevent message reuse from different contexts
77
+ # if keys are reused.
78
+ #
79
+ # Cryptography and Output Format:
80
+ #
81
+ # urlsafe_encode64(version + random_data + IV + encrypted data + HMAC)
82
+ #
83
+ # Where:
84
+ # * version - 1 byte with value 0x01
85
+ # * random_data - 32 bytes used for generating the per-message secret
86
+ # * IV - 16 bytes random initialization vector
87
+ # * HMAC - 32 bytes HMAC-SHA-256 of all preceding data, plus the purpose
88
+ # value
89
+ def initialize(secret, opts = {})
90
+ raise ArgumentError, 'secret must be a String' unless secret.is_a?(String)
91
+ raise ArgumentError, "invalid secret: #{secret.bytesize}, must be >=64" unless secret.bytesize >= 64
92
+
93
+ case opts[:pad_size]
94
+ when nil
95
+ # padding is disabled
96
+ when Integer
97
+ raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}" unless (2..4096).include? opts[:pad_size]
98
+ else
99
+ raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}; must be Integer or nil"
100
+ end
101
+
102
+ @options = {
103
+ serialize_json: false, pad_size: 32, purpose: nil
104
+ }.update(opts)
105
+
106
+ @hmac_secret = secret.dup.force_encoding(Encoding::BINARY)
107
+ @cipher_secret = @hmac_secret.slice!(0, 32)
108
+
109
+ @hmac_secret.freeze
110
+ @cipher_secret.freeze
111
+ end
112
+
113
+ def decrypt(base64_data)
114
+ data = Base64.urlsafe_decode64(base64_data)
115
+
116
+ signature = data.slice!(-32..-1)
117
+ verify_authenticity!(data, signature)
118
+
119
+ version = data.slice!(0, 1)
120
+ raise InvalidMessage, 'wrong version' unless version == "\1"
121
+
122
+ message_secret = data.slice!(0, 32)
123
+ cipher_iv = data.slice!(0, 16)
124
+
125
+ cipher = new_cipher
126
+ cipher.decrypt
127
+
128
+ set_cipher_key(cipher, cipher_secret_from_message_secret(message_secret))
129
+
130
+ cipher.iv = cipher_iv
131
+ data = cipher.update(data) << cipher.final
132
+
133
+ deserialized_message data
134
+ rescue ArgumentError
135
+ raise InvalidSignature, 'Message invalid'
136
+ end
137
+
138
+ def encrypt(message)
139
+ version = "\1"
140
+
141
+ serialized_payload = serialize_payload(message)
142
+ message_secret, cipher_secret = new_message_and_cipher_secret
143
+
144
+ cipher = new_cipher
145
+ cipher.encrypt
146
+
147
+ set_cipher_key(cipher, cipher_secret)
148
+
149
+ cipher_iv = cipher.random_iv
150
+
151
+ encrypted_data = cipher.update(serialized_payload) << cipher.final
152
+
153
+ data = String.new
154
+ data << version
155
+ data << message_secret
156
+ data << cipher_iv
157
+ data << encrypted_data
158
+ data << compute_signature(data)
159
+
160
+ Base64.urlsafe_encode64(data)
161
+ end
162
+
163
+ private
164
+
165
+ def new_cipher
166
+ OpenSSL::Cipher.new('aes-256-ctr')
167
+ end
168
+
169
+ def new_message_and_cipher_secret
170
+ message_secret = SecureRandom.random_bytes(32)
171
+
172
+ [message_secret, cipher_secret_from_message_secret(message_secret)]
173
+ end
174
+
175
+ def cipher_secret_from_message_secret(message_secret)
176
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @cipher_secret, message_secret)
177
+ end
178
+
179
+ def set_cipher_key(cipher, key)
180
+ cipher.key = key
181
+ end
182
+
183
+ def compute_signature(data)
184
+ signing_data = data
185
+ signing_data += @options[:purpose] if @options[:purpose]
186
+
187
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @hmac_secret, signing_data)
188
+ end
189
+
190
+ def verify_authenticity!(data, signature)
191
+ raise InvalidMessage, 'Message is invalid' if data.nil? || signature.nil?
192
+
193
+ unless Rack::Utils.secure_compare(signature, compute_signature(data))
194
+ raise InvalidSignature, 'HMAC is invalid'
195
+ end
196
+ end
197
+ end
198
+
199
+ class V2
200
+ include Serializable
201
+
202
+ # The secret String must be at least 32 bytes in size.
203
+ #
204
+ # Options may include:
205
+ # * :pad_size
206
+ # Pad encrypted message data, to a multiple of this many bytes
207
+ # (default: 32). This can be between 2-4096 bytes, or +nil+ to disable
208
+ # padding.
209
+ # * :purpose
210
+ # Limit messages to a specific purpose. This can be viewed as a
211
+ # security enhancement to prevent message reuse from different contexts
212
+ # if keys are reused.
213
+ #
214
+ # Cryptography and Output Format:
215
+ #
216
+ # strict_encode64(version + salt + IV + authentication tag + ciphertext)
217
+ #
218
+ # Where:
219
+ # * version - 1 byte with value 0x02
220
+ # * salt - 32 bytes used for generating the per-message secret
221
+ # * IV - 12 bytes random initialization vector
222
+ # * authentication tag - 16 bytes authentication tag generated by the GCM mode, covering version and salt
223
+ #
224
+ # Considerations about V2:
225
+ #
226
+ # 1) It uses non URL-safe Base64 encoding as it's faster than its
227
+ # URL-safe counterpart - as of Ruby 3.2, Base64.urlsafe_encode64 is
228
+ # roughly equivalent to
229
+ #
230
+ # Base64.strict_encode64(data).tr("-_", "+/")
231
+ #
232
+ # - and cookie values don't need to be URL-safe.
233
+ def initialize(secret, opts = {})
234
+ raise ArgumentError, 'secret must be a String' unless secret.is_a?(String)
235
+
236
+ unless secret.bytesize >= 32
237
+ raise ArgumentError, "invalid secret: it's #{secret.bytesize}-byte long, must be >=32"
238
+ end
239
+
240
+ case opts[:pad_size]
241
+ when nil
242
+ # padding is disabled
243
+ when Integer
244
+ raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}" unless (2..4096).include? opts[:pad_size]
245
+ else
246
+ raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}; must be Integer or nil"
247
+ end
248
+
249
+ @options = {
250
+ serialize_json: false, pad_size: 32, purpose: nil
251
+ }.update(opts)
252
+
253
+ @cipher_secret = secret.dup.force_encoding(Encoding::BINARY).slice!(0, 32)
254
+ @cipher_secret.freeze
255
+ end
256
+
257
+ def decrypt(base64_data)
258
+ data = Base64.strict_decode64(base64_data)
259
+ if data.bytesize <= 61 # version + salt + iv + auth_tag = 61 byte (and we also need some ciphertext :)
260
+ raise InvalidMessage, 'invalid message'
261
+ end
262
+
263
+ version = data[0]
264
+ raise InvalidMessage, 'invalid message' unless version == "\2"
265
+
266
+ ciphertext = data.slice!(61..-1)
267
+ auth_tag = data.slice!(45, 16)
268
+ cipher_iv = data.slice!(33, 12)
269
+
270
+ cipher = new_cipher
271
+ cipher.decrypt
272
+ salt = data.slice(1, 32)
273
+ set_cipher_key(cipher, message_secret_from_salt(salt))
274
+ cipher.iv = cipher_iv
275
+ cipher.auth_tag = auth_tag
276
+ cipher.auth_data = (purpose = @options[:purpose]) ? data + purpose : data
277
+
278
+ plaintext = cipher.update(ciphertext) << cipher.final
279
+
280
+ deserialized_message plaintext
281
+ rescue ArgumentError, OpenSSL::Cipher::CipherError
282
+ raise InvalidSignature, 'invalid message'
283
+ end
284
+
285
+ def encrypt(message)
286
+ version = "\2"
287
+
288
+ serialized_payload = serialize_payload(message)
289
+
290
+ cipher = new_cipher
291
+ cipher.encrypt
292
+ salt, message_secret = new_salt_and_message_secret
293
+ set_cipher_key(cipher, message_secret)
294
+ cipher.iv_len = 12
295
+ cipher_iv = cipher.random_iv
296
+
297
+ data = String.new
298
+ data << version
299
+ data << salt
300
+
301
+ cipher.auth_data = (purpose = @options[:purpose]) ? data + purpose : data
302
+ encrypted_data = cipher.update(serialized_payload) << cipher.final
303
+
304
+ data << cipher_iv
305
+ data << auth_tag_from(cipher)
306
+ data << encrypted_data
307
+
308
+ Base64.strict_encode64(data)
309
+ end
310
+
311
+ private
312
+
313
+ def new_cipher
314
+ OpenSSL::Cipher.new('aes-256-gcm')
315
+ end
316
+
317
+ def new_salt_and_message_secret
318
+ salt = SecureRandom.random_bytes(32)
319
+
320
+ [salt, message_secret_from_salt(salt)]
321
+ end
322
+
323
+ def message_secret_from_salt(salt)
324
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @cipher_secret, salt)
325
+ end
326
+
327
+ def set_cipher_key(cipher, key)
328
+ cipher.key = key
329
+ end
330
+
331
+ if RUBY_ENGINE == 'jruby'
332
+ # JRuby's OpenSSL implementation doesn't currently support passing
333
+ # an argument to #auth_tag. Here we work around that.
334
+ def auth_tag_from(cipher)
335
+ tag = cipher.auth_tag
336
+ raise Error, 'the auth tag must be 16 bytes long' if tag.bytesize != 16
337
+
338
+ tag
339
+ end
340
+ else
341
+ def auth_tag_from(cipher)
342
+ cipher.auth_tag(16)
343
+ end
344
+ end
345
+ end
346
+
347
+ def initialize(secret, opts = {})
348
+ opts = opts.dup
349
+
350
+ @mode = opts.delete(:mode)&.to_sym || :guess_version
351
+ case @mode
352
+ when :v1
353
+ @v1 = V1.new(secret, opts)
354
+ when :v2
355
+ @v2 = V2.new(secret, opts)
356
+ else
357
+ @v1 = V1.new(secret, opts)
358
+ @v2 = V2.new(secret, opts)
359
+ end
360
+ end
361
+
362
+ def decrypt(base64_data)
363
+ decryptor =
364
+ case @mode
365
+ when :v2
366
+ v2
367
+ when :v1
368
+ v1
369
+ else
370
+ guess_decryptor(base64_data)
371
+ end
372
+
373
+ decryptor.decrypt(base64_data)
374
+ end
375
+
376
+ def encrypt(message)
377
+ encryptor =
378
+ case @mode
379
+ when :v1
380
+ v1
381
+ else
382
+ v2
383
+ end
384
+
385
+ encryptor.encrypt(message)
386
+ end
387
+
388
+ private
389
+
390
+ attr_reader :v1, :v2
391
+
392
+ def guess_decryptor(base64_data)
393
+ raise InvalidMessage, 'invalid message' if base64_data.nil? || base64_data.bytesize < 4
394
+
395
+ first_encoded_4_bytes = base64_data.slice(0, 4)
396
+ # Transform the 4 bytes into non-URL-safe base64-encoded data. Nothing
397
+ # happens if the data is already non-URL-safe base64.
398
+ first_encoded_4_bytes.tr!('-_', '+/')
399
+ first_decoded_3_bytes = Base64.strict_decode64(first_encoded_4_bytes)
400
+
401
+ version = first_decoded_3_bytes[0]
402
+ case version
403
+ when "\2"
404
+ v2
405
+ when "\1"
406
+ v1
407
+ else
408
+ raise InvalidMessage, 'invalid message'
409
+ end
410
+ rescue ArgumentError
411
+ raise InvalidMessage, 'invalid message'
412
+ end
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2022-2023, by Samuel Williams.
5
+
6
+ require_relative 'abstract/id'
7
+
8
+ module Rack
9
+ module Session
10
+ # Rack::Session::Pool provides simple cookie based session management.
11
+ # Session data is stored in a hash held by @pool.
12
+ # In the context of a multithreaded environment, sessions being
13
+ # committed to the pool is done in a merging manner.
14
+ #
15
+ # The :drop option is available in rack.session.options if you wish to
16
+ # explicitly remove the session from the session cache.
17
+ #
18
+ # Example:
19
+ # myapp = MyRackApp.new
20
+ # sessioned = Rack::Session::Pool.new(myapp,
21
+ # :domain => 'foo.com',
22
+ # :expire_after => 2592000
23
+ # )
24
+ # Rack::Handler::WEBrick.run sessioned
25
+
26
+ class Pool < Abstract::PersistedSecure
27
+ attr_reader :mutex, :pool
28
+ DEFAULT_OPTIONS = Abstract::ID::DEFAULT_OPTIONS.merge(drop: false, allow_fallback: true)
29
+
30
+ def initialize(app, options = {})
31
+ super
32
+ @pool = Hash.new
33
+ @mutex = Mutex.new
34
+ @allow_fallback = @default_options.delete(:allow_fallback)
35
+ end
36
+
37
+ def generate_sid(*args, use_mutex: true)
38
+ loop do
39
+ sid = super(*args)
40
+ break sid unless use_mutex ? @mutex.synchronize { @pool.key? sid.private_id } : @pool.key?(sid.private_id)
41
+ end
42
+ end
43
+
44
+ def find_session(req, sid)
45
+ @mutex.synchronize do
46
+ unless sid and session = get_session_with_fallback(sid)
47
+ sid, session = generate_sid(use_mutex: false), {}
48
+ @pool.store sid.private_id, session
49
+ end
50
+ [sid, session]
51
+ end
52
+ end
53
+
54
+ def write_session(req, session_id, new_session, options)
55
+ @mutex.synchronize do
56
+ return false unless get_session_with_fallback(session_id)
57
+ @pool.store session_id.private_id, new_session
58
+ session_id
59
+ end
60
+ end
61
+
62
+ def delete_session(req, session_id, options)
63
+ @mutex.synchronize do
64
+ @pool.delete(session_id.public_id)
65
+ @pool.delete(session_id.private_id)
66
+
67
+ unless options[:drop]
68
+ sid = generate_sid(use_mutex: false)
69
+ @pool.store(sid.private_id, {})
70
+ sid
71
+ end
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def get_session_with_fallback(sid)
78
+ @pool[sid.private_id] || (@pool[sid.public_id] if @allow_fallback)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Rack
7
7
  module Session
8
- VERSION = "1.0.2"
8
+ VERSION = "2.1.2"
9
9
  end
10
10
  end
data/lib/rack/session.rb CHANGED
@@ -1,5 +1,12 @@
1
- # Intentional NO-OP require target!
2
- #
3
- # See:
4
- # - https://github.com/rack/rack-session/issues/15
5
- # - https://github.com/rack/rack-session/issues/26
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2022-2023, by Samuel Williams.
5
+ # Copyright, 2022, by Jeremy Evans.
6
+
7
+ module Rack
8
+ module Session
9
+ autoload :Cookie, "rack/session/cookie"
10
+ autoload :Pool, "rack/session/pool"
11
+ end
12
+ end
data/license.md CHANGED
@@ -1,7 +1,55 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2022-2023, by Samuel Williams.
4
- Copyright, 2022, by Jeremy Evans.
3
+ Copyright, 2007-2008, by Leah Neukirchen.
4
+ Copyright, 2007-2009, by Scytrin dai Kinthra.
5
+ Copyright, 2008, by Daniel Roethlisberger.
6
+ Copyright, 2009, by Joshua Peek.
7
+ Copyright, 2009, by Mickaël Riga.
8
+ Copyright, 2010, by Simon Chiang.
9
+ Copyright, 2010-2011, by José Valim.
10
+ Copyright, 2010-2013, by James Tucker.
11
+ Copyright, 2010-2019, by Aaron Patterson.
12
+ Copyright, 2011, by Max Cantor.
13
+ Copyright, 2011-2012, by Konstantin Haase.
14
+ Copyright, 2011, by Will Leinweber.
15
+ Copyright, 2011, by John Manoogian III.
16
+ Copyright, 2012, by Yun Huang Yong.
17
+ Copyright, 2012, by Ravil Bayramgalin.
18
+ Copyright, 2012, by Timothy Elliott.
19
+ Copyright, 2012, by Jamie Macey.
20
+ Copyright, 2012-2015, by Santiago Pastorino.
21
+ Copyright, 2013, by Andrew Cole.
22
+ Copyright, 2013, by Postmodern.
23
+ Copyright, 2013, by Vipul A M.
24
+ Copyright, 2013, by Charles Hornberger.
25
+ Copyright, 2014, by Michal Bryxí.
26
+ Copyright, 2015, by deepj.
27
+ Copyright, 2015, by Doug McInnes.
28
+ Copyright, 2015, by David Runger.
29
+ Copyright, 2015, by Francesco Rodríguez.
30
+ Copyright, 2015, by Yuichiro Kaneko.
31
+ Copyright, 2015, by Michael Sauter.
32
+ Copyright, 2016, by Kir Shatrov.
33
+ Copyright, 2016, by Yann Vanhalewyn.
34
+ Copyright, 2016, by Jian Weihang.
35
+ Copyright, 2017, by Jordan Raine.
36
+ Copyright, 2018, by Dillon Welch.
37
+ Copyright, 2018, by Yoshiyuki Hirano.
38
+ Copyright, 2019, by Krzysztof Rybka.
39
+ Copyright, 2019, by Frederick Cheung.
40
+ Copyright, 2019, by Adrian Setyadi.
41
+ Copyright, 2019, by Rafael Mendonça França.
42
+ Copyright, 2019-2020, by Pavel Rosicky.
43
+ Copyright, 2019, by Dima Fatko.
44
+ Copyright, 2019, by Oleh Demianiuk.
45
+ Copyright, 2020-2023, by Samuel Williams.
46
+ Copyright, 2020-2022, by Jeremy Evans.
47
+ Copyright, 2020, by Alex Speller.
48
+ Copyright, 2020, by Ryuta Kamizono.
49
+ Copyright, 2020, by Yudai Suzuki.
50
+ Copyright, 2020, by Bart de Water.
51
+ Copyright, 2020, by Alec Clarke.
52
+ Copyright, 2021, by Michael Coyne.
5
53
  Copyright, 2022, by Philip Arndt.
6
54
  Copyright, 2022, by Jon Dufresne.
7
55
 
data/releases.md ADDED
@@ -0,0 +1,31 @@
1
+ # Releases
2
+
3
+ ## v2.1.2
4
+
5
+ - [CVE-2026-39324](https://github.com/advisories/GHSA-33qg-7wpp-89cq) Don't fall back to unencrypted coder if encryptors are present.
6
+
7
+ ## v2.1.1
8
+
9
+ - Prevent `Rack::Session::Pool` from recreating deleted sessions [CVE-2025-46336](https://github.com/rack/rack-session/security/advisories/GHSA-9j94-67jr-4cqj).
10
+
11
+ ## v2.1.0
12
+
13
+ - Improved compatibility with Ruby 3.3+ and Rack 3+.
14
+ - Add support for cookie option `partitioned`.
15
+ - Introduce `assume_ssl` option to allow secure session cookies through insecure proxy.
16
+
17
+ ## v2.0.0
18
+
19
+ - Initial migration of code from Rack 2, for Rack 3 release.
20
+
21
+ ## v1.0.2
22
+
23
+ - Fix missing `rack/session.rb` file.
24
+
25
+ ## v1.0.1
26
+
27
+ - Pin to `rack < 3`.
28
+
29
+ ## v1.0.0
30
+
31
+ - Empty shim release for Rack 2.