roda 3.18.0 → 3.19.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,69 @@
1
+ # frozen-string-literal: true
2
+
3
+ #
4
+ class Roda
5
+ module RodaPlugins
6
+ # The match_hook plugin adds hooks that are called upon a successful match
7
+ # by any of the matchers.
8
+ #
9
+ # plugin :match_hook
10
+ #
11
+ # match_hook do
12
+ # logger.debug("#{request.matched_path} matched. #{request.remaining_path} remaining.")
13
+ # end
14
+ module MatchHook
15
+ def self.configure(app)
16
+ app.opts[:match_hooks] ||= []
17
+ end
18
+
19
+ module ClassMethods
20
+ # Freeze the array of hook methods when freezing the app
21
+ def freeze
22
+ opts[:match_hooks].freeze
23
+ super
24
+ end
25
+
26
+ # Add a match hook.
27
+ def match_hook(&block)
28
+ opts[:match_hooks] << define_roda_method("match_hook", 0, &block)
29
+
30
+ if opts[:match_hooks].length == 1
31
+ class_eval("alias _match_hook #{opts[:match_hooks].first}", __FILE__, __LINE__)
32
+ else
33
+ class_eval("def _match_hook; #{opts[:match_hooks].join(';')} end", __FILE__, __LINE__)
34
+ end
35
+
36
+ public :_match_hook
37
+
38
+ nil
39
+ end
40
+ end
41
+
42
+ module InstanceMethods
43
+ # Default empty method if no match hooks are defined.
44
+ def _match_hook
45
+ end
46
+ end
47
+
48
+ module RequestMethods
49
+ private
50
+
51
+ # Call the match hook if yielding to the block before yielding to the block.
52
+ def if_match(_)
53
+ super do |*a|
54
+ scope._match_hook
55
+ yield(*a)
56
+ end
57
+ end
58
+
59
+ # Call the match hook before yielding to the block
60
+ def always
61
+ scope._match_hook
62
+ super
63
+ end
64
+ end
65
+ end
66
+
67
+ register_plugin :match_hook, MatchHook
68
+ end
69
+ end
@@ -13,6 +13,10 @@ class Roda
13
13
  # if the first segment in the path matches a named route, and dispatch
14
14
  # to that named route.
15
15
  #
16
+ # The hash_routes plugin offers a +r.hash_routes+ method that is similar to
17
+ # and performs better than the +r.multi_route+ method, and it is recommended
18
+ # to consider using that instead of this plugin.
19
+ #
16
20
  # Example:
17
21
  #
18
22
  # plugin :multi_route
@@ -16,6 +16,10 @@ class Roda
16
16
  # +multi_view_compile+ class method that will take an array of view template
17
17
  # names and construct a regexp that can be passed to +r.multi_view+.
18
18
  #
19
+ # The hash_routes plugin offers a views method that is similar to and performs
20
+ # better than the +r.multi_view+ method, and it is recommended to consider
21
+ # using that instead of this plugin.
22
+ #
19
23
  # Example:
20
24
  #
21
25
  # plugin :multi_view
@@ -34,7 +34,7 @@ class Roda
34
34
  def is_exactly(s)
35
35
  rp = @remaining_path
36
36
  if _match_string(s)
37
- if @remaining_path == ''
37
+ if @remaining_path.empty?
38
38
  always{yield}
39
39
  else
40
40
  @remaining_path = rp
@@ -20,8 +20,9 @@ class Roda
20
20
  # The sessions plugin adds support for sessions using cookies. It is the recommended
21
21
  # way to support sessions in Roda applications.
22
22
  #
23
- # The session cookies are encrypted with AES-256-CTR and then signed with HMAC-SHA-256.
24
- # By default, session data is padded to reduce information leaked based on the session size.
23
+ # The session cookies are encrypted with AES-256-CTR using a separate encryption key per cookie,
24
+ # and then signed with HMAC-SHA-256. By default, session data is padded to reduce information
25
+ # leaked based on the session size.
25
26
  #
26
27
  # Sessions are serialized via JSON, so session information should only store data that
27
28
  # allows roundtrips via JSON (String, Integer, Float, Array, Hash, true, false, and nil).
@@ -77,6 +78,12 @@ class Roda
77
78
  # bytes if given.
78
79
  # :pad_size :: Pad session data (after possible compression, before encryption), to a multiple of this
79
80
  # many bytes (default: 32). This can be between 2-4096 bytes, or +nil+ to disable padding.
81
+ # :per_cookie_cipher_secret :: Uses a separate cipher key for every cookie, with the key used generated using
82
+ # HMAC-SHA-256 of 32 bytes of random data with the default cipher secret. This
83
+ # offers additional protection in case the random initialization vector used when
84
+ # encrypting the session data has been reused. Odds of that are 1 in 2**64 if
85
+ # initialization vector is truly random, but weaknesses in the random number
86
+ # generator could make the odds much higher. Default is +true+.
80
87
  # :parser :: The parser for the serialized session data (default: <tt>JSON.method(:parse)</tt>).
81
88
  # :serializer :: The serializer for the session data (default +:to_json.to_proc+).
82
89
  # :skip_within :: If the last update time for the session cookie is less than this number of seconds from the
@@ -105,13 +112,18 @@ class Roda
105
112
  #
106
113
  # = Session Cookie Cryptography/Format
107
114
  #
108
- # Session cookies created by this plugin use the following format:
115
+ # Session cookies created by this plugin by default use the following format:
109
116
  #
110
- # urlsafe_base64(version + IV + auth tag + encrypted session data + HMAC)
117
+ # urlsafe_base64("\1" + random_data + IV + encrypted session data + HMAC)
118
+ #
119
+ # If +:per_cookie_cipher_secret+ option is set to +false+, an older format is used:
120
+ #
121
+ # urlsafe_base64("\0" + IV + encrypted session data + HMAC)
111
122
  #
112
123
  # where:
113
124
  #
114
- # version :: 1 byte, currently must be 0, other values reserved for future expansion.
125
+ # version :: 1 byte, currently must be 1 or 0, other values reserved for future expansion.
126
+ # random_data :: 32 bytes, used for generating the per-cookie secret
115
127
  # IV :: 16 bytes, initialization vector for AES-256-CTR cipher.
116
128
  # encrypted session data :: >=12 bytes of data encrypted with AES-256-CTR cipher, see below.
117
129
  # HMAC :: 32 bytes, HMAC-SHA-256 of all preceding data plus cookie key (so that a cookie value
@@ -141,6 +153,7 @@ class Roda
141
153
  SESSION_CREATED_AT = 'roda.session.created_at'.freeze
142
154
  SESSION_UPDATED_AT = 'roda.session.updated_at'.freeze
143
155
  SESSION_SERIALIZED = 'roda.session.serialized'.freeze
156
+ SESSION_VERSION_NUM = 'roda.session.version'.freeze
144
157
  SESSION_DELETE_RACK_COOKIE = 'roda.session.delete_rack_session_cookie'.freeze
145
158
 
146
159
  # Exception class used when creating a session cookie that would exceed the
@@ -166,6 +179,9 @@ class Roda
166
179
  opts[:parser] ||= app.opts[:json_parser] || JSON.method(:parse)
167
180
  opts[:serializer] ||= app.opts[:json_serializer] || :to_json.to_proc
168
181
 
182
+ opts[:per_cookie_cipher_secret] = true unless opts.has_key?(:per_cookie_cipher_secret)
183
+ opts[:session_version_num] = opts[:per_cookie_cipher_secret] ? 1 : 0
184
+
169
185
  if opts[:upgrade_from_rack_session_cookie_secret]
170
186
  opts[:upgrade_from_rack_session_cookie_key] ||= 'rack.session'
171
187
  rsco = opts[:upgrade_from_rack_session_cookie_options] = Hash[opts[:upgrade_from_rack_session_cookie_options] || OPTS]
@@ -317,10 +333,13 @@ class Roda
317
333
  rescue ArgumentError
318
334
  return _session_serialization_error("Unable to decode session: invalid base64")
319
335
  end
320
- length = data.bytesize
321
- if data.length < 61
322
- # minimum length (1+16+12+32) (version+cipher_iv+minimum session+hmac)
336
+
337
+ case version = data.getbyte(0)
338
+ when 1
339
+ per_cookie_secret = true
340
+ # minimum length (1+32+16+12+32) (version+random_data+cipher_iv+minimum session+hmac)
323
341
  # 1 : version
342
+ # 32 : random_data (if per_cookie_cipher_secret)
324
343
  # 16 : cipher_iv
325
344
  # 12 : minimum_session
326
345
  # 2 : bitmap for gzip + padding info
@@ -328,12 +347,20 @@ class Roda
328
347
  # 4 : update time
329
348
  # 2 : data
330
349
  # 32 : HMAC-SHA-256
331
- return _session_serialization_error("Unable to decode session: data too short")
350
+ min_data_length = 93
351
+ when 0
352
+ per_cookie_secret = false
353
+ # minimum length (1+16+12+32) (version+cipher_iv+minimum session+hmac)
354
+ min_data_length = 61
355
+ when nil
356
+ return _session_serialization_error("Unable to decode session: no data")
357
+ else
358
+ return _session_serialization_error("Unable to decode session: version marker unsupported")
332
359
  end
333
360
 
334
- unless data.getbyte(0) == 0
335
- # version marker
336
- return _session_serialization_error("Unable to decode session: version marker unsupported")
361
+ length = data.bytesize
362
+ if data.length < min_data_length
363
+ return _session_serialization_error("Unable to decode session: data too short")
337
364
  end
338
365
 
339
366
  encrypted_data = data.slice!(0, length-32)
@@ -345,7 +372,15 @@ class Roda
345
372
  end
346
373
  end
347
374
 
375
+ # Remove version
348
376
  encrypted_data.slice!(0)
377
+
378
+ cipher_secret = opts[use_old_cipher_secret ? :old_cipher_secret : :cipher_secret]
379
+ if per_cookie_secret
380
+ cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, encrypted_data.slice!(0, 32))
381
+ end
382
+ cipher_iv = encrypted_data.slice!(0, 16)
383
+
349
384
  cipher = OpenSSL::Cipher.new("aes-256-ctr")
350
385
 
351
386
  # Not rescuing cipher errors. If there is an error in the decryption, that's
@@ -353,8 +388,8 @@ class Roda
353
388
  # able to forge a valid HMAC, in which case the error should be raised to
354
389
  # alert the application owner about the problem.
355
390
  cipher.decrypt
356
- cipher.key = opts[use_old_cipher_secret ? :old_cipher_secret : :cipher_secret]
357
- cipher_iv = cipher.iv = encrypted_data.slice!(0, 16)
391
+ cipher.key = cipher_secret
392
+ cipher.iv = cipher_iv
358
393
  data = cipher.update(encrypted_data) << cipher.final
359
394
 
360
395
  bitmap, created_at, updated_at = data.unpack('vVV')
@@ -376,6 +411,7 @@ class Roda
376
411
  env[SESSION_CREATED_AT] = created_at
377
412
  env[SESSION_UPDATED_AT] = updated_at
378
413
  env[SESSION_SERIALIZED] = data
414
+ env[SESSION_VERSION_NUM] = version
379
415
 
380
416
  opts[:parser].call(data)
381
417
  end
@@ -387,6 +423,7 @@ class Roda
387
423
  json_data = opts[:serializer].call(session).force_encoding('BINARY')
388
424
 
389
425
  if (serialized_session = env[SESSION_SERIALIZED]) &&
426
+ (opts[:session_version_num] == env[SESSION_VERSION_NUM]) &&
390
427
  (updated_at = env[SESSION_UPDATED_AT]) &&
391
428
  (now - updated_at < opts[:skip_within]) &&
392
429
  (serialized_session == json_data)
@@ -418,14 +455,24 @@ class Roda
418
455
  serialized_data << padding_data if padding_data
419
456
  serialized_data << json_data
420
457
 
458
+ cipher_secret = opts[:cipher_secret]
459
+ if per_cookie_secret = opts[:per_cookie_cipher_secret]
460
+ version = "\1"
461
+ per_cookie_secret_base = SecureRandom.random_bytes(32)
462
+ cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, per_cookie_secret_base)
463
+ else
464
+ version = "\0"
465
+ end
466
+
421
467
  cipher = OpenSSL::Cipher.new("aes-256-ctr")
422
468
  cipher.encrypt
423
- cipher.key = opts[:cipher_secret]
469
+ cipher.key = cipher_secret
424
470
  cipher_iv = cipher.random_iv
425
471
  encrypted_data = cipher.update(serialized_data) << cipher.final
426
472
 
427
473
  data = String.new
428
- data << "\0" # version marker
474
+ data << version
475
+ data << per_cookie_secret_base if per_cookie_secret_base
429
476
  data << cipher_iv
430
477
  data << encrypted_data
431
478
  data << OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:hmac_secret], data+opts[:key])
@@ -36,7 +36,7 @@ class Roda
36
36
  # leading slash in the path argument.
37
37
  #
38
38
  # Second, the static_* routing methods only take a single string argument for
39
- # the path, they do not acccept other options, and do not handle placeholders
39
+ # the path, they do not accept other options, and do not handle placeholders
40
40
  # in strings. For any routes needing placeholders, you should use Roda's
41
41
  # routing tree.
42
42
  #
@@ -49,56 +49,26 @@ class Roda
49
49
  # static_route block to have shared behavior for different request methods,
50
50
  # while still handling the request methods differently.
51
51
  module StaticRouting
52
- def self.configure(app)
53
- app.opts[:static_routes] ||= {}
52
+ def self.load_dependencies(app)
53
+ app.plugin :hash_routes
54
54
  end
55
55
 
56
56
  module ClassMethods
57
- # Freeze the static route metadata when freezing the app.
58
- def freeze
59
- opts[:static_routes].freeze.each_value(&:freeze)
60
- super
61
- end
62
-
63
- # Duplicate static route metadata in subclass.
64
- def inherited(subclass)
65
- super
66
- static_routes = subclass.opts[:static_routes]
67
- opts[:static_routes].each do |k, v|
68
- static_routes[k] = v.dup
69
- end
70
- end
71
-
72
57
  # Add a static route for any request method. These are
73
58
  # tried after the request method specific static routes (e.g.
74
59
  # static_get), but allow you to use Roda's routing tree
75
60
  # methods inside the route for handling shared behavior while
76
61
  # still allowing request method specific handling.
77
62
  def static_route(path, &block)
78
- add_static_route(nil, path, &block)
63
+ hash_path(:static_routing, path, &block)
79
64
  end
80
65
 
81
- # Return the static route for the given request method and path.
82
- def static_route_for(method, path)
83
- if h = opts[:static_routes][path]
84
- h[method] || h[nil]
85
- end
86
- end
87
-
88
66
  [:get, :post, :delete, :head, :options, :link, :patch, :put, :trace, :unlink].each do |meth|
89
67
  request_method = meth.to_s.upcase
90
68
  define_method("static_#{meth}") do |path, &block|
91
- add_static_route(request_method, path, &block)
69
+ hash_path(request_method, path, &block)
92
70
  end
93
71
  end
94
-
95
- private
96
-
97
- # Add a static route for the given method.
98
- def add_static_route(method, path, &block)
99
- routes = opts[:static_routes][path] ||= {}
100
- routes[method] = define_roda_method(routes[method] || "static_route_#{method}_#{path}", 1, &convert_route_block(block))
101
- end
102
72
  end
103
73
 
104
74
  module InstanceMethods
@@ -108,11 +78,8 @@ class Roda
108
78
  # instead having the routing tree handle the request.
109
79
  def _roda_before_30__static_routing
110
80
  r = @_request
111
- if meth = self.class.static_route_for(r.request_method, r.path_info)
112
- r.instance_variable_set(:@remaining_path, '')
113
- r.send(:block_result, send(meth, r))
114
- throw :halt, @_response.finish
115
- end
81
+ r.hash_paths(r.request_method)
82
+ r.hash_paths(:static_routing)
116
83
  end
117
84
  end
118
85
  end
data/lib/roda/version.rb CHANGED
@@ -4,7 +4,7 @@ class Roda
4
4
  RodaMajorVersion = 3
5
5
 
6
6
  # The minor version of Roda, updated for new feature releases of Roda.
7
- RodaMinorVersion = 18
7
+ RodaMinorVersion = 19
8
8
 
9
9
  # The patch version of Roda, updated only for bug fixes from the last
10
10
  # feature release.
@@ -55,6 +55,9 @@ describe "Roda.define_roda_method" do
55
55
 
56
56
  m1 = app.define_roda_method("x", 1){2}
57
57
  @scope.send(m1, 3).must_equal 2
58
+
59
+ m1 = app.define_roda_method("x", 1){|x, y| [x, y]}
60
+ @scope.send(m1, 4).must_equal [4, nil]
58
61
  end
59
62
 
60
63
  it "should raise for unexpected expected_arity" do
data/spec/freeze_spec.rb CHANGED
@@ -2,7 +2,16 @@ require_relative "spec_helper"
2
2
 
3
3
  describe "Roda.freeze" do
4
4
  before do
5
- app{}.freeze
5
+ app{'a'}.freeze
6
+ end
7
+
8
+ it "should result in a working application" do
9
+ body.must_equal 'a'
10
+ end
11
+
12
+ it "should not break if called more than once" do
13
+ app.freeze
14
+ body.must_equal 'a'
6
15
  end
7
16
 
8
17
  it "should make opts not be modifiable after calling finalize!" do
@@ -195,7 +195,7 @@ describe "integration" do
195
195
  end
196
196
 
197
197
  it "should have app return the rack application to call" do
198
- app(:bare){}.app.must_be_nil
198
+ app(:bare){}.app.must_be_kind_of(Proc)
199
199
  app.route{|r|}
200
200
  app.app.must_be_kind_of(Proc)
201
201
  c = Class.new{def initialize(app) @app = app end; def call(env) @app.call(env) end}
@@ -196,6 +196,22 @@ if run_tests
196
196
  js.must_include('console.log')
197
197
  end
198
198
 
199
+ it 'should handle rendering assets, linking to them, and accepting requests for them when :timestamp_paths plugin option is used with string value' do
200
+ app.plugin :assets, :timestamp_paths=>'-'
201
+ html = body('/test')
202
+ html.scan(/<link/).length.must_equal 2
203
+ html =~ %r{href="(/assets/css/\d+-app\.scss)"}
204
+ css = body($1)
205
+ html =~ %r{href="(/assets/css/\d+-raw\.css)"}
206
+ css2 = body($1)
207
+ html.scan(/<script/).length.must_equal 1
208
+ html =~ %r{src="(/assets/js/\d+-head/app\.js)"}
209
+ js = body($1)
210
+ css.must_match(/color: red;/)
211
+ css2.must_match(/color: blue;/)
212
+ js.must_include('console.log')
213
+ end
214
+
199
215
  it 'should handle early hints if the :early_hints option is used' do
200
216
  app.plugin :assets, :early_hints=>true
201
217
  eh = []
@@ -1,6 +1,6 @@
1
1
  require_relative "../spec_helper"
2
2
 
3
- describe "delay_build plugin" do
3
+ describe "delay_build plugin" do
4
4
  it "does not build rack app until app is called" do
5
5
  app(:delay_build){"a"}
6
6
  app.instance_variable_get(:@app).must_be_nil
@@ -9,7 +9,7 @@ describe "delay_build plugin" do
9
9
  refute_equal app.instance_variable_get(:@app), nil
10
10
  end
11
11
 
12
- it "only rebuilds the app if build! is called" do
12
+ it "supports the build! method for backwards compatibility" do
13
13
  app(:delay_build){"a"}
14
14
  body.must_equal "a"
15
15
  c = Class.new do
@@ -17,7 +17,6 @@ describe "delay_build plugin" do
17
17
  def call(_) [200, {}, ["b"]] end
18
18
  end
19
19
  app.use c
20
- body.must_equal "a"
21
20
  app.build!
22
21
  body.must_equal "b"
23
22
  end