roda 3.79.0 → 3.81.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.
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