roda 3.18.0 → 3.19.0

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