roda 3.9.0 → 3.10.0

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,451 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'openssl'
4
+
5
+ begin
6
+ OpenSSL::Cipher.new("aes-256-ctr")
7
+ rescue OpenSSL::Cipher::CipherError
8
+ # :nocov:
9
+ raise LoadError, "Roda sessions plugin requires the aes-256-ctr cipher"
10
+ # :nocov:
11
+ end
12
+
13
+ require 'base64'
14
+ require 'json'
15
+ require 'securerandom'
16
+ require 'zlib'
17
+
18
+ class Roda
19
+ module RodaPlugins
20
+ # The sessions plugin adds support for sessions using cookies. It is the recommended
21
+ # way to support sessions in Roda applications.
22
+ #
23
+ # The session cookies are encrypted with AES-256-CTR and then signed with HMAC-SHA-256.
24
+ # By default, session data over a certain size is compressed to reduced space, and
25
+ # is padded to reduce information leaked based on the session size.
26
+ #
27
+ # Sessions are serialized via JSON, so session information should only store data that
28
+ # allows roundtrips via JSON (String, Integer, Float, Array, Hash, true, false, and nil).
29
+ # In particular, note that Symbol does not round trip via JSON, so symbols should not be
30
+ # used in sessions when this plugin is used. This plugin sets the
31
+ # +:sessions_convert_symbols+ application option to +true+ if it hasn't been set yet,
32
+ # for better integration with plugins that can use either symbol or string session or
33
+ # flash keys. Unlike Rack::Session::Cookie, the session is stored as a plain ruby hash,
34
+ # and does not convert all keys to strings.
35
+ #
36
+ # All sessions are timestamped and session expiration is enabled by default, with sessions
37
+ # being valid for 30 days maximum and 7 days since last use. Session creation time is
38
+ # reset whenever the session is empty when serialized and also whenever +clear_session+
39
+ # is called while processing the request.
40
+ #
41
+ # Session secrets can be rotated, and if so both the cipher and HMAC secrets should be
42
+ # rotated at the same time. See options below.
43
+ #
44
+ # The sessions plugin can transparently upgrade sessions from Rack::Session::Cookie
45
+ # if the default Rack::Session::Cookie coder and HMAC are used, see options below.
46
+ # It is recommended to only enable transparent upgrades for a brief transition period,
47
+ # and remove support for them once old sessions have converted or timed out.
48
+ #
49
+ # While session data will be compressed by default for sessions over a certain size,
50
+ # if the final cookie is too large (>=4096 bytes), a Roda::RodaPlugins::Sessions::CookieTooLarge
51
+ # exception will be raised.
52
+ #
53
+ # If the flash plugin is used, the sessions plugin should be loaded after the flash
54
+ # plugin, so that the flash plugin rotates the flash in the session before the sessions
55
+ # plugin serializes the session.
56
+ #
57
+ # = Required Options
58
+ #
59
+ # The session cookies this plugin uses are both encrypted and signed, so two separate
60
+ # secrets are used internally. However, for ease of use, these secrets are combined into
61
+ # a single +:secret+ option. The +:secret+ option must be a string of at least 64 bytes
62
+ # and should be randomly generated. The first 32 bytes are used as the secret for the
63
+ # cipher, any remaining bytes are used for the secret for the HMAC.
64
+ #
65
+ # = Other Options
66
+ #
67
+ # :cookie_options :: Any cookie options to set on the session cookie. By default, uses
68
+ # <tt>httponly: true, path: '/', same_site: :lax</tt> so that the cookie is not accessible
69
+ # to javascript, allowed for all paths, and will not be used for cross-site non-GET requests
70
+ # that. If the +:secure+ option is not present in the hash, then
71
+ # <tt>secure: true</tt> is also set if the request is made over HTTPS. If this option is
72
+ # given, it will be merged into the default cookie options.
73
+ # :gzip_over :: For session data over this many bytes, compress it with the deflate algorithm (default: 128).
74
+ # :key :: The cookie name to use (default: <tt>'roda.session'</tt>)
75
+ # :max_seconds :: The maximum number of seconds to allow for total session lifetime, starting with when
76
+ # the session was originally created. Default is <tt>86400*30</tt> (30 days). Can be set to
77
+ # +nil+ to disable session lifetime checks.
78
+ # :max_idle_sessions :: The maximum number of seconds to allow since the session was last updated.
79
+ # Default is <tt>86400*7</tt> (7 days). Can be set to nil to disable session idleness
80
+ # checks.
81
+ # :old_secret :: The previous secret to use, allowing for secret rotation. Must be a string of at least 64
82
+ # bytes if given.
83
+ # :pad_size :: Pad session data (after possible compression, before encryption), to a multiple of this
84
+ # many bytes (default: 32). This can be between 2-4096 bytes, or +nil+ to disable padding.
85
+ # :parser :: The parser for the serialized session data (default: <tt>JSON.method(:parse)</tt>).
86
+ # :serializer :: The serializer for the session data (default +:to_json.to_proc+).
87
+ # :skip_within :: If the last update time for the session cookie is less than this number of seconds from the
88
+ # current time, and the session has not been modified, do not set a new session cookie
89
+ # (default: 3600).
90
+ # :upgrade_from_rack_session_cookie_key :: The cookie name to use for transparently upgrading from
91
+ # Rack::Session:Cookie (defaults to <tt>'rack.session'</tt>).
92
+ # :upgrade_from_rack_session_cookie_secret :: The secret for the HMAC-SHA1 signature when allowing
93
+ # transparent upgrades from Rack::Session::Cookie. Using this
94
+ # option is only recommended during a short transition period,
95
+ # and is not enabled by default as it lowers security.
96
+ # :upgrade_from_rack_session_cookie_options :: Options to pass when deleting the cookie used by
97
+ # Rack::Session::Cookie after converting it to use the session
98
+ # cookies used by this plugin.
99
+ #
100
+ # = Not a Rack Middleware
101
+ #
102
+ # Unlike some other approaches to sessions, the sessions plugin does not use
103
+ # a rack middleware, so session information is not available to other rack middleware,
104
+ # only to the application itself, with the session not being loaded from the cookie
105
+ # until the +session+ method is called.
106
+ #
107
+ # If you need rack middleware to access the session information, then
108
+ # <tt>require 'roda/session_middleware'</tt> and <tt>use RodaSessionMiddleware</tt>.
109
+ # <tt>RodaSessionMiddleware</tt> passes the options given to this plugin.
110
+ #
111
+ # = Session Cookie Cryptography/Format
112
+ #
113
+ # Session cookies created by this plugin use the following format:
114
+ #
115
+ # urlsafe_base64(version + IV + auth tag + encrypted session data + HMAC)
116
+ #
117
+ # where:
118
+ #
119
+ # version :: 1 byte, currently must be 0, other values reserved for future expansion.
120
+ # IV :: 16 bytes, initialization vector for AES-256-CTR cipher.
121
+ # encrypted session data :: >=12 bytes of data encrypted with AES-256-CTR cipher, see below.
122
+ # HMAC :: 32 bytes, HMAC-SHA-256 of all preceding data plus cookie key (so that a cookie value
123
+ # for a different key cannot be used even if the secret is the same).
124
+ #
125
+ # The encrypted session data uses the following format:
126
+ #
127
+ # bitmap + creation time + update time + padding + serialized data
128
+ #
129
+ # where:
130
+ #
131
+ # bitmap :: 2 bytes in little endian format, lower 12 bits storing number of padding
132
+ # bytes, 13th bit storing whether serialized data is compressed with deflate.
133
+ # Bits 14-16 reserved for future expansion.
134
+ # creation time :: 4 byte integer in unsigned little endian format, storing unix timestamp
135
+ # since session initially created.
136
+ # update time :: 4 byte integer in unsigned little endian format, storing unix timestamp
137
+ # since session last updated.
138
+ # padding :: >=0 padding bytes specified in bitmap, filled with random data, can be ignored.
139
+ # serialized data :: >=2 bytes of serialized data in JSON format. If the bitmap indicates
140
+ # deflate compression, this contains the deflate compressed data.
141
+ module Sessions
142
+ DEFAULT_COOKIE_OPTIONS = {:httponly=>true, :path=>'/'.freeze, :same_site=>:lax}.freeze
143
+ DEFAULT_OPTIONS = {:key => 'roda.session'.freeze, :max_seconds=>86400*30, :max_idle_seconds=>86400*7, :pad_size=>32, :gzip_over=>128, :skip_within=>3600}.freeze
144
+ DEFLATE_BIT = 0x1000
145
+ PADDING_MASK = 0x0fff
146
+ SESSION_CREATED_AT = 'roda.session.created_at'.freeze
147
+ SESSION_UPDATED_AT = 'roda.session.updated_at'.freeze
148
+ SESSION_SERIALIZED = 'roda.session.serialized'.freeze
149
+ SESSION_DELETE_RACK_COOKIE = 'roda.session.delete_rack_session_cookie'.freeze
150
+
151
+ # Exception class used when creating a session cookie that would exceed the
152
+ # allowable cookie size limit.
153
+ class CookieTooLarge < RodaError
154
+ end
155
+
156
+ # Split given secret into a cipher secret and an hmac secret.
157
+ def self.split_secret(name, secret)
158
+ raise RodaError, "sessions plugin :#{name} option must be a String" unless secret.is_a?(String)
159
+ raise RodaError, "invalid sessions plugin :#{name} option length: #{secret.bytesize}, must be >=64" unless secret.bytesize >= 64
160
+ hmac_secret = secret = secret.dup.force_encoding('BINARY')
161
+ cipher_secret = secret.slice!(0, 32)
162
+ [cipher_secret.freeze, hmac_secret.freeze]
163
+ end
164
+
165
+ # Configure the plugin, see Sessions for details on options.
166
+ def self.configure(app, opts=OPTS)
167
+ plugin_opts = opts
168
+ opts = (app.opts[:sessions] || DEFAULT_OPTIONS).merge(opts)
169
+ co = opts[:cookie_options] = DEFAULT_COOKIE_OPTIONS.merge(opts[:cookie_options] || OPTS).freeze
170
+ opts[:parser] ||= app.opts[:json_parser] || JSON.method(:parse)
171
+ opts[:serializer] ||= app.opts[:json_serializer] || :to_json.to_proc
172
+
173
+ if opts[:upgrade_from_rack_session_cookie_secret]
174
+ opts[:upgrade_from_rack_session_cookie_key] ||= 'rack.session'
175
+ rsco = opts[:upgrade_from_rack_session_cookie_options] = Hash[opts[:upgrade_from_rack_session_cookie_options] || OPTS]
176
+ rsco[:path] ||= co[:path]
177
+ rsco[:domain] ||= co[:domain]
178
+ end
179
+
180
+ opts[:cipher_secret], opts[:hmac_secret] = split_secret(:secret, opts[:secret])
181
+ opts[:old_cipher_secret], opts[:old_hmac_secret] = (split_secret(:old_secret, opts[:old_secret]) if opts[:old_secret])
182
+
183
+ case opts[:pad_size]
184
+ when nil
185
+ # no changes
186
+ when Integer
187
+ raise RodaError, "invalid :pad_size: #{opts[:pad_size]}, must be >=2, < 4096" unless opts[:pad_size] >= 2 && opts[:pad_size] < 4096
188
+ else
189
+ raise RodaError, "invalid :pad_size option: #{opts[:pad_size].inspect}, must be Integer or nil"
190
+ end
191
+
192
+ app.opts[:sessions] = opts.freeze
193
+ app.opts[:sessions_convert_symbols] = true unless app.opts.has_key?(:sessions_convert_symbols)
194
+ end
195
+
196
+ module InstanceMethods
197
+ # If session information has been set in the request environment,
198
+ # update the rack response headers to set the session cookie in
199
+ # the response.
200
+ def call
201
+ res = super
202
+
203
+ if session = env['rack.session']
204
+ @_request.persist_session(res[1], session)
205
+ end
206
+
207
+ res
208
+ end
209
+
210
+ # Clear data from the session, and update the request environment
211
+ # so that the session cookie will use a new creation timestamp
212
+ # instead of the previous creation timestamp.
213
+ def clear_session
214
+ session.clear
215
+ env.delete(SESSION_CREATED_AT)
216
+ env.delete(SESSION_UPDATED_AT)
217
+ nil
218
+ end
219
+ end
220
+
221
+ module RequestMethods
222
+ # Load the session information from the cookie. With the sessions
223
+ # plugin, you must call this method to get the session, instead of
224
+ # trying to access the session directly through the request environment.
225
+ # For maximum compatibility with other software that uses rack sessions,
226
+ # this method stores the session in 'rack.session' in the request environment,
227
+ # but that does not happen until this method is called.
228
+ def session
229
+ @env['rack.session'] ||= _load_session
230
+ end
231
+
232
+ # Persist the session data as a cookie. If transparently upgrading from
233
+ # Rack::Session::Cookie, mark the related cookie for expiration so it isn't
234
+ # sent in the future.
235
+ def persist_session(headers, session)
236
+ opts = roda_class.opts[:sessions]
237
+
238
+ if session.empty?
239
+ if env[SESSION_SERIALIZED]
240
+ # If session was submitted and is now empty, remove the cookie
241
+ Rack::Utils.delete_cookie_header!(headers, opts[:key])
242
+ # else
243
+ # If no session was submitted, and the session is empty
244
+ # then there is no need to do anything
245
+ end
246
+ elsif cookie_value = _serialize_session(session)
247
+ cookie = Hash[opts[:cookie_options]]
248
+ cookie[:value] = cookie_value
249
+ cookie[:secure] = true if !cookie.has_key?(:secure) && ssl?
250
+ Rack::Utils.set_cookie_header!(headers, opts[:key], cookie)
251
+ end
252
+
253
+ if env[SESSION_DELETE_RACK_COOKIE]
254
+ Rack::Utils.delete_cookie_header!(headers, opts[:upgrade_from_rack_session_cookie_key], opts[:upgrade_from_rack_session_cookie_options])
255
+ end
256
+
257
+ nil
258
+ end
259
+
260
+ private
261
+
262
+ # Load the session by looking for the appropriate cookie, or falling
263
+ # back to the rack session cookie if configured.
264
+ def _load_session
265
+ opts = roda_class.opts[:sessions]
266
+ cs = cookies
267
+
268
+ if data = cs[opts[:key]]
269
+ _deserialize_session(data)
270
+ elsif (key = opts[:upgrade_from_rack_session_cookie_key]) && (data = cs[key])
271
+ _deserialize_rack_session(data)
272
+ end || {}
273
+ end
274
+
275
+ # If 'rack.errors' is set, write the error message to it.
276
+ # This is used for errors that shouldn't be raised as exceptions,
277
+ # such as improper session cookies.
278
+ def _session_serialization_error(msg)
279
+ return unless error_stream = @env['rack.errors']
280
+ error_stream.puts(msg)
281
+ nil
282
+ end
283
+
284
+ # Interpret given cookie data as a Rack::Session::Cookie
285
+ # serialized session using the default Rack::Session::Cookie
286
+ # hmac and coder.
287
+ def _deserialize_rack_session(data)
288
+ opts = roda_class.opts[:sessions]
289
+ key = opts[:upgrade_from_rack_session_cookie_key]
290
+ secret = opts[:upgrade_from_rack_session_cookie_secret]
291
+ data, digest = data.split("--", 2)
292
+ unless digest
293
+ return _session_serialization_error("Not decoding Rack::Session::Cookie session: invalid format")
294
+ end
295
+ unless Rack::Utils.secure_compare(digest, OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, opts[:upgrade_from_rack_session_cookie_secret], data))
296
+ return _session_serialization_error("Not decoding Rack::Session::Cookie session: HMAC invalid")
297
+ end
298
+
299
+ begin
300
+ session = Marshal.load(data.unpack('m').first)
301
+ rescue
302
+ return _session_serialization_error("Error decoding Rack::Session::Cookie session: not base64 encoded marshal dump")
303
+ end
304
+
305
+ # Mark rack session cookie for deletion on success
306
+ env[SESSION_DELETE_RACK_COOKIE] = true
307
+
308
+ # Convert the rack session by roundtripping it through
309
+ # the parser and serializer, so that you would get the
310
+ # same result as you would if the session was handled
311
+ # by this plugin.
312
+ env[SESSION_SERIALIZED] = data = opts[:serializer].call(session)
313
+ env[SESSION_CREATED_AT] = Time.now.to_i
314
+ opts[:parser].call(data)
315
+ end
316
+
317
+ # Interpret given cookie data as a Rack::Session::Cookie
318
+ def _deserialize_session(data)
319
+ opts = roda_class.opts[:sessions]
320
+
321
+ begin
322
+ data = Base64.urlsafe_decode64(data)
323
+ rescue ArgumentError
324
+ return _session_serialization_error("Unable to decode session: invalid base64")
325
+ end
326
+ length = data.bytesize
327
+ if data.length < 61
328
+ # minimum length (1+16+12+32) (version+cipher_iv+minimum session+hmac)
329
+ # 1 : version
330
+ # 16 : cipher_iv
331
+ # 12 : minimum_session
332
+ # 2 : bitmap for gzip + padding info
333
+ # 4 : creation time
334
+ # 4 : update time
335
+ # 2 : data
336
+ # 32 : HMAC-SHA-256
337
+ return _session_serialization_error("Unable to decode session: data too short")
338
+ end
339
+
340
+ unless data.getbyte(0) == 0
341
+ # version marker
342
+ return _session_serialization_error("Unable to decode session: version marker unsupported")
343
+ end
344
+
345
+ encrypted_data = data.slice!(0, length-32)
346
+ unless Rack::Utils.secure_compare(data, OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:hmac_secret], encrypted_data+opts[:key]))
347
+ if opts[:old_hmac_secret] && Rack::Utils.secure_compare(data, OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:old_hmac_secret], encrypted_data+opts[:key]))
348
+ use_old_cipher_secret = true
349
+ else
350
+ return _session_serialization_error("Not decoding session: HMAC invalid")
351
+ end
352
+ end
353
+
354
+ encrypted_data.slice!(0)
355
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
356
+
357
+ # Not rescuing cipher errors. If there is an error in the decryption, that's
358
+ # either a bug in the plugin that needs to be fixed, or an attacker is already
359
+ # able to forge a valid HMAC, in which case the error should be raised to
360
+ # alert the application owner about the problem.
361
+ cipher.decrypt
362
+ cipher.key = opts[use_old_cipher_secret ? :old_cipher_secret : :cipher_secret]
363
+ cipher_iv = cipher.iv = encrypted_data.slice!(0, 16)
364
+ data = cipher.update(encrypted_data) << cipher.final
365
+
366
+ bitmap, created_at, updated_at = data.unpack('vVV')
367
+ padding_bytes = bitmap & PADDING_MASK
368
+ if (max = opts[:max_seconds]) && Time.now.to_i > created_at + max
369
+ return _session_serialization_error("Not returning session: maximum session time expired")
370
+ end
371
+ if (max = opts[:max_idle_seconds]) && Time.now.to_i > updated_at + max
372
+ return _session_serialization_error("Not returning session: maximum session idle time expired")
373
+ end
374
+
375
+ data = data.slice(10+padding_bytes, data.bytesize)
376
+
377
+ if bitmap & DEFLATE_BIT > 0
378
+ data = Zlib::Inflate.inflate(data)
379
+ end
380
+
381
+ env = @env
382
+ env[SESSION_CREATED_AT] = created_at
383
+ env[SESSION_UPDATED_AT] = updated_at
384
+ env[SESSION_SERIALIZED] = data
385
+
386
+ opts[:parser].call(data)
387
+ end
388
+
389
+ def _serialize_session(session)
390
+ opts = roda_class.opts[:sessions]
391
+ env = @env
392
+ now = Time.now.to_i
393
+ json_data = opts[:serializer].call(session).force_encoding('BINARY')
394
+
395
+ if (serialized_session = env[SESSION_SERIALIZED]) &&
396
+ (updated_at = env[SESSION_UPDATED_AT]) &&
397
+ (now - updated_at < opts[:skip_within]) &&
398
+ (serialized_session == json_data)
399
+ return
400
+ end
401
+
402
+ bitmap = 0
403
+ json_length = json_data.bytesize
404
+
405
+ if json_length > opts[:gzip_over]
406
+ json_data = Zlib.deflate(json_data)
407
+ json_length = json_data.bytesize
408
+ bitmap |= DEFLATE_BIT
409
+ end
410
+
411
+ # When calculating padding bytes to use, include 10 bytes for bitmap and
412
+ # session create/update times, so total size of encrypted data is a
413
+ # multiple of pad_size.
414
+ if (pad_size = opts[:pad_size]) && (padding_bytes = (json_length+10) % pad_size) != 0
415
+ padding_bytes = pad_size - padding_bytes
416
+ bitmap |= padding_bytes
417
+ padding_data = SecureRandom.random_bytes(padding_bytes)
418
+ end
419
+
420
+ session_create_time = env[SESSION_CREATED_AT]
421
+ serialized_data = [bitmap, session_create_time||now, now].pack('vVV')
422
+
423
+ serialized_data << padding_data if padding_data
424
+ serialized_data << json_data
425
+
426
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
427
+ cipher.encrypt
428
+ cipher.key = opts[:cipher_secret]
429
+ cipher_iv = cipher.random_iv
430
+ encrypted_data = cipher.update(serialized_data) << cipher.final
431
+
432
+ data = String.new
433
+ data << "\0" # version marker
434
+ data << cipher_iv
435
+ data << encrypted_data
436
+ data << OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:hmac_secret], data+opts[:key])
437
+
438
+ data = Base64.urlsafe_encode64(data)
439
+
440
+ if data.bytesize >= 4096
441
+ raise CookieTooLarge, "attempted to create cookie larger than 4096 bytes"
442
+ end
443
+
444
+ data
445
+ end
446
+ end
447
+ end
448
+
449
+ register_plugin(:sessions, Sessions)
450
+ end
451
+ end
@@ -106,7 +106,9 @@ class Roda
106
106
  # int :: Converts value to integer using +to_i+ (note that invalid input strings will be
107
107
  # returned as 0)
108
108
  # pos_int :: Converts value using +to_i+, but non-positive values are converted to +nil+
109
- # Integer :: Converts value to integer using <tt>Kernel::Integer(value, 10)</tt>
109
+ # Integer :: Converts value to integer using <tt>Kernel::Integer</tt>, with base 10 for
110
+ # string inputs, and a check that the output value is equal to the input
111
+ # value for numeric inputs.
110
112
  # float :: Converts value to float using +to_f+ (note that invalid input strings will be
111
113
  # returned as 0.0)
112
114
  # Float :: Converts value to float using <tt>Kernel::Float(value)</tt>
@@ -453,7 +455,18 @@ class Roda
453
455
  end
454
456
 
455
457
  handle_type(:Integer) do |v|
456
- string_or_numeric!(v) && ::Kernel::Integer(v, 10)
458
+ if string_or_numeric!(v)
459
+ case v
460
+ when String
461
+ ::Kernel::Integer(v, 10)
462
+ when Integer
463
+ v
464
+ else
465
+ i = ::Kernel::Integer(v)
466
+ raise Error, "numeric value passed to Integer contains non-Integer part: #{v.inspect}" unless i == v
467
+ i
468
+ end
469
+ end
457
470
  end
458
471
 
459
472
  handle_type(:float) do |v|