roda 3.9.0 → 3.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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|