roda 3.79.0 → 3.81.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e77e7eba739ca8bf0afe44555c8af8c9188f4ce8668310aad110a2afc638701
4
- data.tar.gz: 19db55450dfe15e7aa4f678d7556ec427e52bcad421a096791d47a5a3fe128b4
3
+ metadata.gz: a9d94f31e568bd2774c3e582152f0bcdbffdf3fcd7e5a95241e73b3fd5b7ac3c
4
+ data.tar.gz: b9ad44642acdbaa0035cbd6ece521ca3432d2e7c23d75a331172ecd6c6288c60
5
5
  SHA512:
6
- metadata.gz: f504398b08e35ca42b765afca4357d750836ce5d7e3e7ec9ba73b061dde57fd2bbd3fa5d7752b9e6995739fac8a8718b0a8a71d70432ec14b65c95e13dadeb29
7
- data.tar.gz: ceb20219c23d4e44d006c5fe177fff3ef2cacb77ec75e109b92c03d12f0e9cf434459759d007df96becfa0e426c89f4f8f9517c00c579135cfbb0e613852a0a1
6
+ metadata.gz: 1cd64e84b47a1a7a01d9165d16e14d95d2c43287b58f2557d9492a201832c1388157152eab1fd12052c7691e3969e5ce1cb9c0ad706e73ad6de822dcc29e1590
7
+ data.tar.gz: 86320c603d0c9b90e2c82f16cc5229b76afe66dde5bdbccce9b28ce076e80808a35410098fe0eae5cb3f5b3180b79f4265b3d525e7861a9dd65890315d274fad
data/CHANGELOG CHANGED
@@ -1,3 +1,15 @@
1
+ = 3.81.0 (2024-06-12)
2
+
3
+ * Make assets plugin :early_hints option follow Rack 3 SPEC if using Rack 3 (jeremyevans)
4
+
5
+ * Correctly parse Ruby 3.4 backtraces in exception_page plugin (jeremyevans)
6
+
7
+ * Support :until and :seconds option in hmac_paths plugin, for paths valid only until a specific time (jeremyevans)
8
+
9
+ = 3.80.0 (2024-05-10)
10
+
11
+ * Support :namespace option in hmac_paths plugin, allowing for easy per-user/per-group HMAC paths (jeremyevans)
12
+
1
13
  = 3.79.0 (2024-04-12)
2
14
 
3
15
  * Do not update template mtime when there is an error reloading templates in the render plugin (jeremyevans)
@@ -0,0 +1,31 @@
1
+ = New Features
2
+
3
+ * The hmac_paths plugin now supports a :namespace option for both hmac_path and
4
+ r.hmac_path. The :namespace option makes the generated HMAC values unique
5
+ per namespace, allowing easy use of per user/group HMAC paths. This can
6
+ be useful if the same path will show different information to different
7
+ users/groups, and you want to prevent path enumeration for each user/group
8
+ (not allow paths enumerated by one user/group to be valid for a different
9
+ user/group). Example:
10
+
11
+ hmac_path('/widget/1', namespace: '1')
12
+ # => "/3793ac2a72ea399c40cbd63f154d19f0fe34cdf8d347772134c506a0b756d590/n/widget/1"
13
+
14
+ hmac_path('/widget/1', namespace: '2')
15
+ # => "/0e1e748860d4fd17fe9b7c8259b1e26996502c38e465f802c2c9a0a13000087c/n/widget/1"
16
+
17
+ The HMAC path created with namespace: '1' will only be valid when calling
18
+ r.hmac_path with namespace: '1' (similar for namespace: '2').
19
+
20
+ It is expected that the most common use of the :namespace option is to
21
+ reference session values, so the value of each path depends on the logged in
22
+ user. You can use the :namespace_session_key plugin option to set the
23
+ default namespace for both hmac_path and r.hmac_path:
24
+
25
+ plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes',
26
+ namespace_session_key: 'account_id'
27
+
28
+ This will use <tt>session['account_id']</tt> (converted to a string) as the namespace
29
+ for both hmac_path and r.hmac_path, unless a specific :namespace option is
30
+ given, making it simple to implement per user/group HMAC paths across an
31
+ application.
@@ -0,0 +1,24 @@
1
+ = New Features
2
+
3
+ * The hmac_paths plugin now supports :until and :seconds options for
4
+ hmac_path, to create a path that is only valid for a specific amount of
5
+ time. :until sets a specific time that the path will be valid until,
6
+ and :seconds makes the path only valid for the given number of seconds.
7
+
8
+ hmac_path('/widget/1', until: Time.utc(2100))
9
+ # => "/dc8b6e56e4cbe7815df7880d42f0e02956b2e4c49881b6060ceb0e49745a540d/t/4102444800/widget/1"
10
+
11
+ Requests for the path after the given time will not be matched by
12
+ r.hmac_path.
13
+
14
+ = Other Improvements
15
+
16
+ * The early_hints plugin now correctly follows the Rack 3 SPEC when
17
+ using Rack 3. This was not caught previously because Rack only
18
+ added official support for early_hints in the last month.
19
+
20
+ * Ruby 3.4 backtraces are now parsed correctly in the exception_page
21
+ plugin.
22
+
23
+ * Some plugins that accept a block no longer issue an unused block
24
+ warning on Ruby 3.4.
@@ -736,7 +736,9 @@ class Roda
736
736
  paths = assets_paths(type)
737
737
  if o[:early_hints]
738
738
  early_hint_as = ltype == :js ? 'script' : 'style'
739
- send_early_hints('Link'=>paths.map{|p| "<#{p}>; rel=preload; as=#{early_hint_as}"}.join("\n"))
739
+ early_hints = paths.map{|p| "<#{p}>; rel=preload; as=#{early_hint_as}"}
740
+ early_hints = early_hints.join("\n") if Rack.release < '3'
741
+ send_early_hints(RodaResponseHeaders::LINK=>early_hints)
740
742
  end
741
743
  paths.map{|p| "#{tag_start}#{h(p)}#{tag_end}"}.join("\n")
742
744
  end
@@ -245,7 +245,7 @@ END
245
245
 
246
246
  frames = exception.backtrace.map.with_index do |line, i|
247
247
  frame = {:id=>i}
248
- if line =~ /\A(.*?):(\d+)(?::in `(.*)')?\Z/
248
+ if line =~ /\A(.*?):(\d+)(?::in [`'](.*)')?\Z/
249
249
  filename = frame[:filename] = $1
250
250
  lineno = frame[:lineno] = $2.to_i
251
251
  frame[:function] = $3
@@ -21,7 +21,7 @@ class Roda
21
21
  # !request.path.start_with?('/admin/')
22
22
  # end
23
23
  module FilterCommonLogger
24
- def self.load_dependencies(app)
24
+ def self.load_dependencies(app, &_)
25
25
  app.plugin :common_logger
26
26
  end
27
27
 
@@ -112,13 +112,53 @@ class Roda
112
112
  # this for POST requests (or other HTTP verbs that can have request bodies), use +r.GET+
113
113
  # instead of +r.params+ to specifically check query string parameters.
114
114
  #
115
- # You can use +:root+, +:method+, and +:params+ at the same time:
115
+ # The generated paths can be timestamped, so that they are only valid until a given time
116
+ # or for a given number of seconds after they are generated, using the :until or :seconds
117
+ # options:
116
118
  #
117
- # hmac_path('/1', root: '/widget', method: :get, params: {foo: 'bar'})
118
- # # => "/widget/9169af1b8f40c62a1c2bb15b1b377c65bda681b8efded0e613a4176387468c15/mp/1?foo=bar"
119
+ # hmac_path('/widget/1', until: Time.utc(2100))
120
+ # # => "/dc8b6e56e4cbe7815df7880d42f0e02956b2e4c49881b6060ceb0e49745a540d/t/4102444800/widget/1"
121
+ #
122
+ # hmac_path('/widget/1', seconds: Time.utc(2100).to_i - Time.now.to_i)
123
+ # # => "/dc8b6e56e4cbe7815df7880d42f0e02956b2e4c49881b6060ceb0e49745a540d/t/4102444800/widget/1"
124
+ #
125
+ # The :namespace option, if provided, should be a string, and it modifies the generated HMACs
126
+ # to only match those in the same namespace. This can be used to provide different paths to
127
+ # different users or groups of users.
128
+ #
129
+ # hmac_path('/widget/1', namespace: '1')
130
+ # # => "/3793ac2a72ea399c40cbd63f154d19f0fe34cdf8d347772134c506a0b756d590/n/widget/1"
131
+ #
132
+ # hmac_path('/widget/1', namespace: '2')
133
+ # # => "/0e1e748860d4fd17fe9b7c8259b1e26996502c38e465f802c2c9a0a13000087c/n/widget/1"
134
+ #
135
+ # The +r.hmac_path+ method accepts a :namespace option, and if a :namespace option is
136
+ # provided, it will only match an hmac path if the namespace given matches the one used
137
+ # when the hmac path was created.
138
+ #
139
+ # r.hmac_path(namespace: '1'){}
140
+ # # will match "/3793ac2a72ea399c40cbd63f154d19f0fe34cdf8d347772134c506a0b756d590/n/widget/1"
141
+ # # will not match "/0e1e748860d4fd17fe9b7c8259b1e26996502c38e465f802c2c9a0a13000087c/n/widget/1"
142
+ #
143
+ # The most common use of the :namespace option is to reference session values, so the value of
144
+ # each path depends on the logged in user. You can use the +:namespace_session_key+ plugin
145
+ # option to set the default namespace for both +hmac_path+ and +r.hmac_path+:
146
+ #
147
+ # plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes',
148
+ # namespace_session_key: 'account_id'
149
+ #
150
+ # This will use <tt>session['account_id']</tt> as the default namespace for both +hmac_path+
151
+ # and +r.hmac_path+ (if the session value is not nil, it is converted to a string using +to_s+).
152
+ # You can override the default namespace by passing a +:namespace+ option when calling +hmac_path+
153
+ # and +r.hmac_path+.
154
+ #
155
+ # You can use +:root+, +:method+, +:params+, and +:namespace+ at the same time:
156
+ #
157
+ # hmac_path('/1', root: '/widget', method: :get, params: {foo: 'bar'}, namespace: '1')
158
+ # # => "/widget/c14c78a81d34d766cf334a3ddbb7a6b231bc2092ef50a77ded0028586027b14e/mpn/1?foo=bar"
119
159
  #
120
160
  # This gives you a path only valid for a GET request with a root of <tt>/widget</tt> and
121
- # a query string of <tt>foo=bar</tt>.
161
+ # a query string of <tt>foo=bar</tt>, using namespace +1+.
122
162
  #
123
163
  # To handle secret rotation, you can provide an +:old_secret+ option when loading the
124
164
  # plugin.
@@ -128,6 +168,48 @@ class Roda
128
168
  #
129
169
  # This will use +:secret+ for constructing new paths, but will respect paths generated by
130
170
  # +:old_secret+.
171
+ #
172
+ # = HMAC Construction
173
+ #
174
+ # This describes the internals for how HMACs are constructed based on the options provided
175
+ # to +hmac_path+. In the examples below:
176
+ #
177
+ # * +HMAC+ is the raw HMAC-SHA256 output (first argument is secret, second is data)
178
+ # * +HMAC_hex+ is the hexidecimal version of +HMAC+
179
+ # * +secret+ is the plugin :secret option
180
+ #
181
+ # The +:secret+ plugin option is never used directly as the HMAC secret. All HMACs are
182
+ # generated with a root-specific secret. The root will be the empty if no +:root+ option
183
+ # is given. The hmac path flags are always included in the hmac calculation, prepended to the
184
+ # path:
185
+ #
186
+ # r.hmac_path('/1')
187
+ # HMAC_hex(HMAC_hex(secret, ''), '/0/1')
188
+ #
189
+ # r.hmac_path('/1', root: '/2')
190
+ # HMAC_hex(HMAC_hex(secret, '/2'), '/0/1')
191
+ #
192
+ # The +:method+ option uses an uppercase version of the method prepended to the path. This
193
+ # cannot conflict with the path itself, since paths must start with a slash.
194
+ #
195
+ # r.hmac_path('/1', method: :get)
196
+ # HMAC_hex(HMAC_hex(secret, ''), 'GET:/m/1')
197
+ #
198
+ # The +:params+ option includes the query string for the params in the HMAC:
199
+ #
200
+ # r.hmac_path('/1', params: {k: 2})
201
+ # HMAC_hex(HMAC_hex(secret, ''), '/p/1?k=2')
202
+ #
203
+ # The +:until+ and +:seconds+ option include the timestamp in the HMAC:
204
+ #
205
+ # r.hmac_path('/1', until: Time.utc(2100))
206
+ # HMAC_hex(HMAC_hex(secret, ''), '/t/4102444800/1')
207
+ #
208
+ # If a +:namespace+ option is provided, the original secret used before the +:root+ option is
209
+ # an HMAC of the +:secret+ plugin option and the given namespace.
210
+ #
211
+ # r.hmac_path('/1', namespace: '2')
212
+ # HMAC_hex(HMAC_hex(HMAC(secret, '2'), ''), '/n/1')
131
213
  module HmacPaths
132
214
  def self.configure(app, opts=OPTS)
133
215
  hmac_secret = opts[:secret]
@@ -143,6 +225,10 @@ class Roda
143
225
 
144
226
  app.opts[:hmac_paths_secret] = hmac_secret
145
227
  app.opts[:hmac_paths_old_secret] = hmac_old_secret
228
+
229
+ if opts[:namespace_session_key]
230
+ app.opts[:hmac_paths_namespace_session_key] = opts[:namespace_session_key]
231
+ end
146
232
  end
147
233
 
148
234
  module InstanceMethods
@@ -152,12 +238,17 @@ class Roda
152
238
  # valid paths. The given path should be a string starting with +/+. Options:
153
239
  #
154
240
  # :method :: Limits the returned path to only be valid for the given request method.
241
+ # :namespace :: Make the HMAC value depend on the given namespace. If this is not
242
+ # provided, the default namespace is used. To explicitly not use a
243
+ # namespace when there is a default namespace, pass a nil value.
155
244
  # :params :: Includes parameters in the query string of the returned path, and
156
245
  # limits the returned path to only be valid for that exact query string.
157
246
  # :root :: Should be an empty string or string starting with +/+. This will be
158
247
  # the already matched path of the routing tree using r.hmac_path. Defaults
159
248
  # to the empty string, which will returns paths valid for r.hmac_path at
160
249
  # the top level of the routing tree.
250
+ # :seconds :: Make the given path valid for the given integer number of seconds.
251
+ # :until :: Make the given path valid until the given Time.
161
252
  def hmac_path(path, opts=OPTS)
162
253
  unless path.is_a?(String) && path.getbyte(0) == 47
163
254
  raise RodaError, "path must be a string starting with /"
@@ -168,6 +259,12 @@ class Roda
168
259
  raise RodaError, "root must be empty string or string starting with /"
169
260
  end
170
261
 
262
+ if valid_until = opts[:until]
263
+ valid_until = valid_until.to_i
264
+ elsif seconds = opts[:seconds]
265
+ valid_until = Time.now.to_i + seconds
266
+ end
267
+
171
268
  flags = String.new
172
269
  path = path.dup
173
270
 
@@ -180,6 +277,15 @@ class Roda
180
277
  path << '?' << Rack::Utils.build_query(params)
181
278
  end
182
279
 
280
+ if hmac_path_namespace(opts)
281
+ flags << 'n'
282
+ end
283
+
284
+ if valid_until
285
+ flags << 't'
286
+ path = "/#{valid_until}#{path}"
287
+ end
288
+
183
289
  flags << '0' if flags.empty?
184
290
 
185
291
  hmac_path = if method
@@ -188,7 +294,7 @@ class Roda
188
294
  "/#{flags}#{path}"
189
295
  end
190
296
 
191
- "#{root}/#{hmac_path_hmac(root, hmac_path)}/#{flags}#{path}"
297
+ "#{root}/#{hmac_path_hmac(root, hmac_path, opts)}/#{flags}#{path}"
192
298
  end
193
299
 
194
300
  # The HMAC to use in hmac_path, for the given root, path, and options.
@@ -196,14 +302,34 @@ class Roda
196
302
  OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, hmac_path_hmac_secret(root, opts), path)
197
303
  end
198
304
 
305
+ # The namespace to use for the hmac path. If a :namespace option is not
306
+ # provided, and a :namespace_session_key option was provided, this will
307
+ # use the value of the related session key, if present.
308
+ def hmac_path_namespace(opts=OPTS)
309
+ opts.fetch(:namespace){hmac_path_default_namespace}
310
+ end
311
+
199
312
  private
200
313
 
201
314
  # The secret used to calculate the HMAC in hmac_path. This is itself an HMAC, created
202
- # using the secret given in the plugin, for the given root and options. If the
315
+ # using the secret given in the plugin, for the given root and options.
316
+ # This always returns a hexidecimal string.
203
317
  def hmac_path_hmac_secret(root, opts=OPTS)
204
318
  secret = opts[:secret] || self.opts[:hmac_paths_secret]
319
+
320
+ if namespace = hmac_path_namespace(opts)
321
+ secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, secret, namespace)
322
+ end
323
+
205
324
  OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, secret, root)
206
325
  end
326
+
327
+ # The default namespace to use for hmac_path, if a :namespace option is not provided.
328
+ def hmac_path_default_namespace
329
+ if (key = opts[:hmac_paths_namespace_session_key]) && (value = session[key])
330
+ value.to_s
331
+ end
332
+ end
207
333
  end
208
334
 
209
335
  module RequestMethods
@@ -221,6 +347,13 @@ class Roda
221
347
  if submitted_hmac.bytesize == 64
222
348
  on String do |flags|
223
349
  if flags.bytesize >= 1
350
+ if flags.include?('n') ^ !scope.hmac_path_namespace(opts).nil?
351
+ # Namespace required and not provided, or provided and not required.
352
+ # Bail early to avoid unnecessary HMAC calculation.
353
+ @remaining_path = orig_path
354
+ return
355
+ end
356
+
224
357
  if flags.include?('m')
225
358
  rpath = "#{env['REQUEST_METHOD'].to_s.upcase}:#{rpath}"
226
359
  end
@@ -229,8 +362,20 @@ class Roda
229
362
  rpath = "#{rpath}?#{env["QUERY_STRING"]}"
230
363
  end
231
364
 
232
- if hmac_path_valid?(mpath, rpath, submitted_hmac)
233
- always(&block)
365
+ if hmac_path_valid?(mpath, rpath, submitted_hmac, opts)
366
+ if flags.include?('t')
367
+ on Integer do |int|
368
+ if int >= Time.now.to_i
369
+ always(&block)
370
+ else
371
+ # Return from method without matching
372
+ @remaining_path = orig_path
373
+ return
374
+ end
375
+ end
376
+ else
377
+ always(&block)
378
+ end
234
379
  end
235
380
  end
236
381
 
@@ -249,11 +394,13 @@ class Roda
249
394
  private
250
395
 
251
396
  # Determine whether the provided hmac matches.
252
- def hmac_path_valid?(root, path, hmac)
253
- if Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path), hmac)
397
+ def hmac_path_valid?(root, path, hmac, opts=OPTS)
398
+ if Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
254
399
  true
255
400
  elsif old_secret = roda_class.opts[:hmac_paths_old_secret]
256
- Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, secret: old_secret), hmac)
401
+ opts = opts.dup
402
+ opts[:secret] = old_secret
403
+ Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
257
404
  else
258
405
  false
259
406
  end
@@ -53,13 +53,10 @@ class Roda
53
53
 
54
54
  class Params < Rack::QueryParser::Params
55
55
  if Rack.release >= '3'
56
- # rack main branch compatibility
57
- # :nocov:
58
56
  if Params < Hash
59
57
  def initialize
60
58
  super(&INDIFFERENT_PROC)
61
59
  end
62
- # :nocov:
63
60
  else
64
61
  def initialize
65
62
  @size = 0
@@ -31,7 +31,7 @@ class Roda
31
31
  # still exists mainly for backward compatibility.
32
32
  module NotFound
33
33
  # Require the status_handler plugin
34
- def self.load_dependencies(app)
34
+ def self.load_dependencies(app, &_)
35
35
  app.plugin :status_handler
36
36
  end
37
37
 
@@ -172,7 +172,7 @@ class Roda
172
172
  # a valid CSRF token was not provided.
173
173
  class InvalidToken < RodaError; end
174
174
 
175
- def self.load_dependencies(app, opts=OPTS)
175
+ def self.load_dependencies(app, opts=OPTS, &_)
176
176
  app.plugin :_base64
177
177
  end
178
178
 
@@ -476,7 +476,7 @@ class Roda
476
476
  serialized_data << json_data
477
477
 
478
478
  cipher_secret = opts[:cipher_secret]
479
- if per_cookie_secret = opts[:per_cookie_cipher_secret]
479
+ if opts[:per_cookie_cipher_secret]
480
480
  version = "\1"
481
481
  per_cookie_secret_base = SecureRandom.random_bytes(32)
482
482
  cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, per_cookie_secret_base)
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 = 79
7
+ RodaMinorVersion = 81
8
8
 
9
9
  # The patch version of Roda, updated only for bug fixes from the last
10
10
  # feature release.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roda
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.79.0
4
+ version: 3.81.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeremy Evans
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-04-12 00:00:00.000000000 Z
11
+ date: 2024-06-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -254,6 +254,8 @@ extra_rdoc_files:
254
254
  - doc/release_notes/3.78.0.txt
255
255
  - doc/release_notes/3.79.0.txt
256
256
  - doc/release_notes/3.8.0.txt
257
+ - doc/release_notes/3.80.0.txt
258
+ - doc/release_notes/3.81.0.txt
257
259
  - doc/release_notes/3.9.0.txt
258
260
  files:
259
261
  - CHANGELOG
@@ -340,6 +342,8 @@ files:
340
342
  - doc/release_notes/3.78.0.txt
341
343
  - doc/release_notes/3.79.0.txt
342
344
  - doc/release_notes/3.8.0.txt
345
+ - doc/release_notes/3.80.0.txt
346
+ - doc/release_notes/3.81.0.txt
343
347
  - doc/release_notes/3.9.0.txt
344
348
  - lib/roda.rb
345
349
  - lib/roda/cache.rb
@@ -505,7 +509,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
505
509
  - !ruby/object:Gem::Version
506
510
  version: '0'
507
511
  requirements: []
508
- rubygems_version: 3.5.3
512
+ rubygems_version: 3.5.9
509
513
  signing_key:
510
514
  specification_version: 4
511
515
  summary: Routing tree web toolkit