roda 3.78.0 → 3.80.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: 44eb48fa9dd507c495e32f90e5ef3b9b197d47f1c148f5d91714272afdca74f8
4
- data.tar.gz: 276ceaf7256b816b4a5c59e7c2508cb9066e1d1693d398e7be43ac17be7a9115
3
+ metadata.gz: 0cebe935d536e1f903b212075108ba9cbcade4aa2e8d2abf1c5e0d6e6f539ce8
4
+ data.tar.gz: 9b0c576aaa36c5a05596c1bc1bec23d3ab0f37c56dc72342bfa962b10442928f
5
5
  SHA512:
6
- metadata.gz: b99672a570f6c119375435546104627d27437cb66372f61df0b7e08a9eb4ae66709b33162d2cf0354b2a22c3ed7c456b60423218cd734a9fe644c9b278c5c44f
7
- data.tar.gz: 6bd6f328f173bfe2dd28b1b4ea9452158f127ac2c93dcf4de6783af5513d38803e9642c3c24b899e16d05dbdb35569464dc9ca2f00111a8814193e48870afe45
6
+ metadata.gz: 0e83d0fe8e1f70bb196ea5f285f7a00590ab1669e4b1d686f859d1e5c65a8e760d1320b345306740d00c7d063f0f6bada1af88433b3b04c6b429f439bb7e2521
7
+ data.tar.gz: e9b0ffd5b5fb7e976a971f11fbed4f0beb35868dcb7aa70c41c005046a9053cb6c62caf29f669c02cd53e67cd5db51a28076f058824c90a1c1b917a9b7f0f4fb
data/CHANGELOG CHANGED
@@ -1,3 +1,13 @@
1
+ = 3.80.0 (2024-05-10)
2
+
3
+ * Support :namespace option in hmac_paths plugin, allowing for easy per-user/per-group HMAC paths (jeremyevans)
4
+
5
+ = 3.79.0 (2024-04-12)
6
+
7
+ * Do not update template mtime when there is an error reloading templates in the render plugin (jeremyevans)
8
+
9
+ * Add hmac_paths plugin for preventing path enumeration and supporting access control (jeremyevans)
10
+
1
11
  = 3.78.0 (2024-03-13)
2
12
 
3
13
  * Add permissions_policy plugin for setting Permissions-Policy header (jeremyevans)
@@ -0,0 +1,148 @@
1
+ = New Features
2
+
3
+ * The hmac_paths plugin allows protection of paths using an HMAC. This can be used
4
+ to prevent users enumerating paths, since only paths with valid HMACs will be
5
+ respected.
6
+
7
+ To use the plugin, you must provide a :secret option. This sets the secret for
8
+ the HMACs. Make sure to keep this value secret, as this plugin does not provide
9
+ protection against users who know the secret value. The secret must be at least
10
+ 32 bytes.
11
+
12
+ plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes'
13
+
14
+ To generate a valid HMAC path, you call the hmac_path method:
15
+
16
+ hmac_path('/widget/1')
17
+ # => "/0c2feaefdfc80cc73da19b060c713d4193c57022815238c6657ce2d99b5925eb/0/widget/1"
18
+
19
+ The first segment in the returned path is the HMAC. The second segment is flags for
20
+ the type of paths (see below), and the rest of the path is as given.
21
+
22
+ To protect a path or any subsection in the routing tree, you wrap the related code
23
+ in an +r.hmac_path+ block.
24
+
25
+ route do |r|
26
+ r.hmac_path do
27
+ r.get 'widget', Integer do |widget_id|
28
+ # ...
29
+ end
30
+ end
31
+ end
32
+
33
+ If first segment of the remaining path contains a valid HMAC for the rest of the path (considering
34
+ the flags), then r.hmac_path will match and yield to the block, and routing continues inside
35
+ the block with the HMAC and flags segments removed.
36
+
37
+ In the above example, if you provide a user a link for widget with ID 1, there is no way
38
+ for them to guess the valid path for the widget with ID 2, preventing a user from
39
+ enumerating widgets, without relying on custom access control. Users can only access
40
+ paths that have been generated by the application and provided to them, either directly
41
+ or indirectly.
42
+
43
+ In the above example, r.hmac_path is used at the root of the routing tree. If you
44
+ would like to call it below the root of the routing tree, it works correctly, but you
45
+ must pass hmac_path the :root option specifying where r.hmac_paths will be called from.
46
+ Consider this example:
47
+
48
+ route do |r|
49
+ r.on 'widget' do
50
+ r.hmac_path do
51
+ r.get Integer do |widget_id|
52
+ # ...
53
+ end
54
+ end
55
+ end
56
+
57
+ r.on 'foobar' do
58
+ r.hmac_path do
59
+ r.get Integer do |foobar_id|
60
+ # ...
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ For security reasons, the hmac_path plugin does not allow an HMAC path designed for
67
+ widgets to be a valid match in the r.hmac_path call inside the "r.on 'foobar'"
68
+ block, preventing users who have a valid HMAC for a widget from looking at the page for
69
+ a foobar with the same ID. When generating HMAC paths where the matching r.hmac_path
70
+ call is not at the root of the routing tree, you must pass the :root option:
71
+
72
+ hmac_path('/1', root: '/widget')
73
+ # => "/widget/daccafce3ce0df52e5ce774626779eaa7286085fcbde1e4681c74175ff0bbacd/0/1"
74
+
75
+ hmac_path('/1', root: '/foobar')
76
+ # => "/foobar/c5fdaf482771d4f9f38cc13a1b2832929026a4ceb05e98ed6a0cd5a00bf180b7/0/1"
77
+
78
+ Note how the HMAC changes even though the path is the same.
79
+
80
+ In addition to the +:root+ option, there are additional options that further constrain
81
+ use of the generated paths.
82
+
83
+ The :method option creates a path that can only be called with a certain request
84
+ method:
85
+
86
+ hmac_path('/widget/1', method: :get)
87
+ # => "/d38c1e634ecf9a3c0ab9d0832555b035d91b35069efcbf2670b0dfefd4b62fdd/m/widget/1"
88
+
89
+ Note how this results in a different HMAC than the original hmac_path('/widget/1')
90
+ call. This sets the flags segment to "m", which means r.hmac_path will consider the
91
+ request mehod when checking the HMAC, and will only match if the provided request method
92
+ is GET. This allows you to provide a user the ability to submit a GET request for the
93
+ underlying path, without providing them the ability to submit a POST request for the
94
+ underlying path, with no other access control.
95
+
96
+ The :params option accepts a hash of params, converts it into a query string, and
97
+ includes the query string in the returned path. It sets the flags segment to +p+, which
98
+ means r.hmac_path will check for that exact query string. Requests with an empty query
99
+ string or a different string will not match.
100
+
101
+ hmac_path('/widget/1', params: {foo: 'bar'})
102
+ # => "/fe8d03f9572d5af6c2866295bd3c12c2ea11d290b1cbd016c3b68ee36a678139/p/widget/1?foo=bar"
103
+
104
+ For GET requests, which cannot have request bodies, that is sufficient to ensure that the
105
+ submitted params are exactly as specified. However, POST requests can have request bodies,
106
+ and request body params override query string params in r.params. So if you are using
107
+ this for POST requests (or other HTTP verbs that can have request bodies), use r.GET
108
+ instead of r.params to specifically check query string parameters.
109
+
110
+ You can use +:root+, +:method+, and +:params+ at the same time:
111
+
112
+ hmac_path('/1', root: '/widget', method: :get, params: {foo: 'bar'})
113
+ # => "/widget/9169af1b8f40c62a1c2bb15b1b377c65bda681b8efded0e613a4176387468c15/mp/1?foo=bar"
114
+
115
+ This gives you a path only valid for a GET request with a root of "/widget" and
116
+ a query string of "foo=bar".
117
+
118
+ To handle secret rotation, you can provide an :old_secret option when loading the
119
+ plugin.
120
+
121
+ plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes',
122
+ old_secret: 'previous-secret-value-with-at-least-32-bytes'
123
+
124
+ This will use :secret for constructing new paths, but will respect paths generated by
125
+ :old_secret.
126
+
127
+ = Other Improvements
128
+
129
+ * When not using cached templates in the render plugin, the render plugin
130
+ now has better handling when a template is modified and results in an
131
+ error. Previously, the error would be raised on the first request after
132
+ the template modification, but subsequent requests would use the
133
+ previous template value. The render plugin will no longer update the
134
+ last modified time in this case, so if a template is modified and
135
+ introduces an error (e.g. SyntaxError in an erb template), all future
136
+ requests that use the template will result in the error being raised,
137
+ until the template is fixed.
138
+
139
+ = Backwards Compatibility
140
+
141
+ * The internal TemplateMtimeWrapper API has been modified. As documented,
142
+ this is an internal class and the API can change in any Roda version.
143
+ However, if any code was relying on the previous implementation of
144
+ TemplateMtimeWrapper#modified?, it will need to be modified, as that
145
+ method has been replaced with TemplateMtimeWrapper#if_modified.
146
+
147
+ Additionally, the TemplateMtimeWrapper#compiled_method_lambda API has
148
+ also changed.
@@ -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,373 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'openssl'
4
+
5
+ #
6
+ class Roda
7
+ module RodaPlugins
8
+ # The hmac_paths plugin allows protection of paths using an HMAC. This can be used
9
+ # to prevent users enumerating paths, since only paths with valid HMACs will be
10
+ # respected.
11
+ #
12
+ # To use the plugin, you must provide a +secret+ option. This sets the secret for
13
+ # the HMACs. Make sure to keep this value secret, as this plugin does not provide
14
+ # protection against users who know the secret value. The secret must be at least
15
+ # 32 bytes.
16
+ #
17
+ # plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes'
18
+ #
19
+ # To generate a valid HMAC path, you call the +hmac_path+ method:
20
+ #
21
+ # hmac_path('/widget/1')
22
+ # # => "/0c2feaefdfc80cc73da19b060c713d4193c57022815238c6657ce2d99b5925eb/0/widget/1"
23
+ #
24
+ # The first segment in the returned path is the HMAC. The second segment is flags for
25
+ # the type of paths (see below), and the rest of the path is as given.
26
+ #
27
+ # To protect a path or any subsection in the routing tree, you wrap the related code
28
+ # in an +r.hmac_path+ block.
29
+ #
30
+ # route do |r|
31
+ # r.hmac_path do
32
+ # r.get 'widget', Integer do |widget_id|
33
+ # # ...
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # If first segment of the remaining path contains a valid HMAC for the rest of the path (considering
39
+ # the flags), then +r.hmac_path+ will match and yield to the block, and routing continues inside
40
+ # the block with the HMAC and flags segments removed.
41
+ #
42
+ # In the above example, if you provide a user a link for widget with ID 1, there is no way
43
+ # for them to guess the valid path for the widget with ID 2, preventing a user from
44
+ # enumerating widgets, without relying on custom access control. Users can only access
45
+ # paths that have been generated by the application and provided to them, either directly
46
+ # or indirectly.
47
+ #
48
+ # In the above example, +r.hmac_path+ is used at the root of the routing tree. If you
49
+ # would like to call it below the root of the routing tree, it works correctly, but you
50
+ # must pass +hmac_path+ the +:root+ option specifying where +r.hmac_paths+ will be called from.
51
+ # Consider this example:
52
+ #
53
+ # route do |r|
54
+ # r.on 'widget' do
55
+ # r.hmac_path do
56
+ # r.get Integer do |widget_id|
57
+ # # ...
58
+ # end
59
+ # end
60
+ # end
61
+ #
62
+ # r.on 'foobar' do
63
+ # r.hmac_path do
64
+ # r.get Integer do |foobar_id|
65
+ # # ...
66
+ # end
67
+ # end
68
+ # end
69
+ # end
70
+ #
71
+ # For security reasons, the hmac_path plugin does not allow an HMAC path designed for
72
+ # widgets to be a valid match in the +r.hmac_path+ call inside the <tt>r.on 'foobar'</tt>
73
+ # block, preventing users who have a valid HMAC for a widget from looking at the page for
74
+ # a foobar with the same ID. When generating HMAC paths where the matching +r.hmac_path+
75
+ # call is not at the root of the routing tree, you must pass the +:root+ option:
76
+ #
77
+ # hmac_path('/1', root: '/widget')
78
+ # # => "/widget/daccafce3ce0df52e5ce774626779eaa7286085fcbde1e4681c74175ff0bbacd/0/1"
79
+ #
80
+ # hmac_path('/1', root: '/foobar')
81
+ # # => "/foobar/c5fdaf482771d4f9f38cc13a1b2832929026a4ceb05e98ed6a0cd5a00bf180b7/0/1"
82
+ #
83
+ # Note how the HMAC changes even though the path is the same.
84
+ #
85
+ # In addition to the +:root+ option, there are additional options that further constrain
86
+ # use of the generated paths.
87
+ #
88
+ # The +:method+ option creates a path that can only be called with a certain request
89
+ # method:
90
+ #
91
+ # hmac_path('/widget/1', method: :get)
92
+ # # => "/d38c1e634ecf9a3c0ab9d0832555b035d91b35069efcbf2670b0dfefd4b62fdd/m/widget/1"
93
+ #
94
+ # Note how this results in a different HMAC than the original <tt>hmac_path('/widget/1')</tt>
95
+ # call. This sets the flags segment to +m+, which means +r.hmac_path+ will consider the
96
+ # request mehod when checking the HMAC, and will only match if the provided request method
97
+ # is GET. This allows you to provide a user the ability to submit a GET request for the
98
+ # underlying path, without providing them the ability to submit a POST request for the
99
+ # underlying path, with no other access control.
100
+ #
101
+ # The +:params+ option accepts a hash of params, converts it into a query string, and
102
+ # includes the query string in the returned path. It sets the flags segment to +p+, which
103
+ # means +r.hmac_path+ will check for that exact query string. Requests with an empty query
104
+ # string or a different string will not match.
105
+ #
106
+ # hmac_path('/widget/1', params: {foo: 'bar'})
107
+ # # => "/fe8d03f9572d5af6c2866295bd3c12c2ea11d290b1cbd016c3b68ee36a678139/p/widget/1?foo=bar"
108
+ #
109
+ # For GET requests, which cannot have request bodies, that is sufficient to ensure that the
110
+ # submitted params are exactly as specified. However, POST requests can have request bodies,
111
+ # and request body params override query string params in +r.params+. So if you are using
112
+ # this for POST requests (or other HTTP verbs that can have request bodies), use +r.GET+
113
+ # instead of +r.params+ to specifically check query string parameters.
114
+ #
115
+ # The :namespace option, if provided, should be a string, and it modifies the generated HMACs
116
+ # to only match those in the same namespace. This can be used to provide different paths to
117
+ # different users or groups of users.
118
+ #
119
+ # hmac_path('/widget/1', namespace: '1')
120
+ # # => "/3793ac2a72ea399c40cbd63f154d19f0fe34cdf8d347772134c506a0b756d590/n/widget/1"
121
+ #
122
+ # hmac_path('/widget/1', namespace: '2')
123
+ # # => "/0e1e748860d4fd17fe9b7c8259b1e26996502c38e465f802c2c9a0a13000087c/n/widget/1"
124
+ #
125
+ # The +r.hmac_path+ method accepts a :namespace option, and if a :namespace option is
126
+ # provided, it will only match an hmac path if the namespace given matches the one used
127
+ # when the hmac path was created.
128
+ #
129
+ # r.hmac_path(namespace: '1'){}
130
+ # # will match "/3793ac2a72ea399c40cbd63f154d19f0fe34cdf8d347772134c506a0b756d590/n/widget/1"
131
+ # # will not match "/0e1e748860d4fd17fe9b7c8259b1e26996502c38e465f802c2c9a0a13000087c/n/widget/1"
132
+ #
133
+ # The most common use of the :namespace option is to reference session values, so the value of
134
+ # each path depends on the logged in user. You can use the +:namespace_session_key+ plugin
135
+ # option to set the default namespace for both +hmac_path+ and +r.hmac_path+:
136
+ #
137
+ # plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes',
138
+ # namespace_session_key: 'account_id'
139
+ #
140
+ # This will use <tt>session['account_id']</tt> as the default namespace for both +hmac_path+
141
+ # and +r.hmac_path+ (if the session value is not nil, it is converted to a string using +to_s+).
142
+ # You can override the default namespace by passing a +:namespace+ option when calling +hmac_path+
143
+ # and +r.hmac_path+.
144
+ #
145
+ # You can use +:root+, +:method+, +:params+, and +:namespace+ at the same time:
146
+ #
147
+ # hmac_path('/1', root: '/widget', method: :get, params: {foo: 'bar'}, namespace: '1')
148
+ # # => "/widget/c14c78a81d34d766cf334a3ddbb7a6b231bc2092ef50a77ded0028586027b14e/mpn/1?foo=bar"
149
+ #
150
+ # This gives you a path only valid for a GET request with a root of <tt>/widget</tt> and
151
+ # a query string of <tt>foo=bar</tt>, using namespace +1+.
152
+ #
153
+ # To handle secret rotation, you can provide an +:old_secret+ option when loading the
154
+ # plugin.
155
+ #
156
+ # plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes',
157
+ # old_secret: 'previous-secret-value-with-at-least-32-bytes'
158
+ #
159
+ # This will use +:secret+ for constructing new paths, but will respect paths generated by
160
+ # +:old_secret+.
161
+ #
162
+ # = HMAC Construction
163
+ #
164
+ # This describes the internals for how HMACs are constructed based on the options provided
165
+ # to +hmac_path+. In the examples below:
166
+ #
167
+ # * +HMAC+ is the raw HMAC-SHA256 output (first argument is secret, second is data)
168
+ # * +HMAC_hex+ is the hexidecimal version of +HMAC+
169
+ # * +secret+ is the plugin :secret option
170
+ #
171
+ # The +:secret+ plugin option is never used directly as the HMAC secret. All HMACs are
172
+ # generated with a root-specific secret. The root will be the empty if no +:root+ option
173
+ # is given. The hmac path flags are always included in the hmac calculation, prepended to the
174
+ # path:
175
+ #
176
+ # r.hmac_path('/1')
177
+ # HMAC_hex(HMAC_hex(secret, ''), '/0/1')
178
+ #
179
+ # r.hmac_path('/1', root: '/2')
180
+ # HMAC_hex(HMAC_hex(secret, '/2'), '/0/1')
181
+ #
182
+ # The +:method+ option uses an uppercase version of the method prepended to the path. This
183
+ # cannot conflict with the path itself, since paths must start with a slash.
184
+ #
185
+ # r.hmac_path('/1', method: :get)
186
+ # HMAC_hex(HMAC_hex(secret, ''), 'GET:/m/1')
187
+ #
188
+ # The +:params+ option includes the query string for the params in the HMAC:
189
+ #
190
+ # r.hmac_path('/1', params: {k: 2})
191
+ # HMAC_hex(HMAC_hex(secret, ''), '/p/1?k=2')
192
+ #
193
+ # If a +:namespace+ option is provided, the original secret used before the +:root+ option is
194
+ # an HMAC of the +:secret+ plugin option and the given namespace.
195
+ #
196
+ # r.hmac_path('/1', namespace: '2')
197
+ # HMAC_hex(HMAC_hex(HMAC(secret, '2'), ''), '/n/1')
198
+ module HmacPaths
199
+ def self.configure(app, opts=OPTS)
200
+ hmac_secret = opts[:secret]
201
+ unless hmac_secret.is_a?(String) && hmac_secret.bytesize >= 32
202
+ raise RodaError, "hmac_paths plugin :secret option must be a string containing at least 32 bytes"
203
+ end
204
+
205
+ if hmac_old_secret = opts[:old_secret]
206
+ unless hmac_old_secret.is_a?(String) && hmac_old_secret.bytesize >= 32
207
+ raise RodaError, "hmac_paths plugin :old_secret option must be a string containing at least 32 bytes if present"
208
+ end
209
+ end
210
+
211
+ app.opts[:hmac_paths_secret] = hmac_secret
212
+ app.opts[:hmac_paths_old_secret] = hmac_old_secret
213
+
214
+ if opts[:namespace_session_key]
215
+ app.opts[:hmac_paths_namespace_session_key] = opts[:namespace_session_key]
216
+ end
217
+ end
218
+
219
+ module InstanceMethods
220
+ # Return a path with an HMAC. Designed to be used with r.hmac_path, to make sure
221
+ # users can only request paths that they have been provided by the application
222
+ # (directly or indirectly). This can prevent users of a site from enumerating
223
+ # valid paths. The given path should be a string starting with +/+. Options:
224
+ #
225
+ # :method :: Limits the returned path to only be valid for the given request method.
226
+ # :namespace :: Make the HMAC value depend on the given namespace. If this is not
227
+ # provided, the default namespace is used. To explicitly not use a
228
+ # namespace when there is a default namespace, pass a nil value.
229
+ # :params :: Includes parameters in the query string of the returned path, and
230
+ # limits the returned path to only be valid for that exact query string.
231
+ # :root :: Should be an empty string or string starting with +/+. This will be
232
+ # the already matched path of the routing tree using r.hmac_path. Defaults
233
+ # to the empty string, which will returns paths valid for r.hmac_path at
234
+ # the top level of the routing tree.
235
+ def hmac_path(path, opts=OPTS)
236
+ unless path.is_a?(String) && path.getbyte(0) == 47
237
+ raise RodaError, "path must be a string starting with /"
238
+ end
239
+
240
+ root = opts[:root] || ''
241
+ unless root.is_a?(String) && ((root_byte = root.getbyte(0)) == 47 || root_byte == nil)
242
+ raise RodaError, "root must be empty string or string starting with /"
243
+ end
244
+
245
+ flags = String.new
246
+ path = path.dup
247
+
248
+ if method = opts[:method]
249
+ flags << 'm'
250
+ end
251
+
252
+ if params = opts[:params]
253
+ flags << 'p'
254
+ path << '?' << Rack::Utils.build_query(params)
255
+ end
256
+
257
+ if hmac_path_namespace(opts)
258
+ flags << 'n'
259
+ end
260
+
261
+ flags << '0' if flags.empty?
262
+
263
+ hmac_path = if method
264
+ "#{method.to_s.upcase}:/#{flags}#{path}"
265
+ else
266
+ "/#{flags}#{path}"
267
+ end
268
+
269
+ "#{root}/#{hmac_path_hmac(root, hmac_path, opts)}/#{flags}#{path}"
270
+ end
271
+
272
+ # The HMAC to use in hmac_path, for the given root, path, and options.
273
+ def hmac_path_hmac(root, path, opts=OPTS)
274
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, hmac_path_hmac_secret(root, opts), path)
275
+ end
276
+
277
+ # The namespace to use for the hmac path. If a :namespace option is not
278
+ # provided, and a :namespace_session_key option was provided, this will
279
+ # use the value of the related session key, if present.
280
+ def hmac_path_namespace(opts=OPTS)
281
+ opts.fetch(:namespace){hmac_path_default_namespace}
282
+ end
283
+
284
+ private
285
+
286
+ # The secret used to calculate the HMAC in hmac_path. This is itself an HMAC, created
287
+ # using the secret given in the plugin, for the given root and options.
288
+ # This always returns a hexidecimal string.
289
+ def hmac_path_hmac_secret(root, opts=OPTS)
290
+ secret = opts[:secret] || self.opts[:hmac_paths_secret]
291
+
292
+ if namespace = hmac_path_namespace(opts)
293
+ secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, secret, namespace)
294
+ end
295
+
296
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, secret, root)
297
+ end
298
+
299
+ # The default namespace to use for hmac_path, if a :namespace option is not provided.
300
+ def hmac_path_default_namespace
301
+ if (key = opts[:hmac_paths_namespace_session_key]) && (value = session[key])
302
+ value.to_s
303
+ end
304
+ end
305
+ end
306
+
307
+ module RequestMethods
308
+ # Looks at the first segment of the remaining path, and if it contains a valid HMAC for the
309
+ # rest of the path considering the flags in the second segment and the given options, the
310
+ # block matches and is yielded to, and the result of the block is returned. Otherwise, the
311
+ # block does not matches and routing continues after the call.
312
+ def hmac_path(opts=OPTS, &block)
313
+ orig_path = remaining_path
314
+ mpath = matched_path
315
+
316
+ on String do |submitted_hmac|
317
+ rpath = remaining_path
318
+
319
+ if submitted_hmac.bytesize == 64
320
+ on String do |flags|
321
+ if flags.bytesize >= 1
322
+ if flags.include?('n') ^ !scope.hmac_path_namespace(opts).nil?
323
+ # Namespace required and not provided, or provided and not required.
324
+ # Bail early to avoid unnecessary HMAC calculation.
325
+ @remaining_path = orig_path
326
+ return
327
+ end
328
+
329
+ if flags.include?('m')
330
+ rpath = "#{env['REQUEST_METHOD'].to_s.upcase}:#{rpath}"
331
+ end
332
+
333
+ if flags.include?('p')
334
+ rpath = "#{rpath}?#{env["QUERY_STRING"]}"
335
+ end
336
+
337
+ if hmac_path_valid?(mpath, rpath, submitted_hmac, opts)
338
+ always(&block)
339
+ end
340
+ end
341
+
342
+ # Return from method without matching
343
+ @remaining_path = orig_path
344
+ return
345
+ end
346
+ end
347
+
348
+ # Return from method without matching
349
+ @remaining_path = orig_path
350
+ return
351
+ end
352
+ end
353
+
354
+ private
355
+
356
+ # Determine whether the provided hmac matches.
357
+ def hmac_path_valid?(root, path, hmac, opts=OPTS)
358
+ if Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
359
+ true
360
+ elsif old_secret = roda_class.opts[:hmac_paths_old_secret]
361
+ opts = opts.dup
362
+ opts[:secret] = old_secret
363
+ Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
364
+ else
365
+ false
366
+ end
367
+ end
368
+ end
369
+ end
370
+
371
+ register_plugin(:hmac_paths, HmacPaths)
372
+ end
373
+ end
@@ -335,8 +335,13 @@ class Roda
335
335
  # If the template file exists and the modification time has
336
336
  # changed, rebuild the template file, then call render on it.
337
337
  def render(*args, &block)
338
- modified?
339
- @template.render(*args, &block)
338
+ res = nil
339
+ modified = false
340
+ if_modified do
341
+ res = @template.render(*args, &block)
342
+ modified = true
343
+ end
344
+ modified ? res : @template.render(*args, &block)
340
345
  end
341
346
 
342
347
  # Return when the template was last modified. If the template depends on any
@@ -352,20 +357,18 @@ class Roda
352
357
 
353
358
  # If the template file has been updated, return true and update
354
359
  # the template object and the modification time. Other return false.
355
- def modified?
360
+ def if_modified
356
361
  begin
357
362
  mtime = template_last_modified
358
363
  rescue
359
364
  # ignore errors
360
365
  else
361
366
  if mtime != @mtime
362
- @mtime = mtime
363
367
  reset_template
364
- return true
368
+ yield
369
+ @mtime = mtime
365
370
  end
366
371
  end
367
-
368
- false
369
372
  end
370
373
 
371
374
  if COMPILED_METHOD_SUPPORT
@@ -375,13 +378,13 @@ class Roda
375
378
  mod = roda_class::RodaCompiledTemplates
376
379
  internal_method_name = :"_#{method_name}"
377
380
  begin
378
- mod.send(:define_method, internal_method_name, send(:compiled_method, locals_keys, roda_class))
381
+ mod.send(:define_method, internal_method_name, compiled_method(locals_keys, roda_class))
379
382
  rescue ::NotImplementedError
380
383
  return false
381
384
  end
382
385
 
383
386
  mod.send(:private, internal_method_name)
384
- mod.send(:define_method, method_name, &compiled_method_lambda(self, roda_class, internal_method_name, locals_keys))
387
+ mod.send(:define_method, method_name, &compiled_method_lambda(roda_class, internal_method_name, locals_keys))
385
388
  mod.send(:private, method_name)
386
389
 
387
390
  method_name
@@ -397,10 +400,11 @@ class Roda
397
400
  # Return the lambda used to define the compiled template method. This
398
401
  # is separated into its own method so the lambda does not capture any
399
402
  # unnecessary local variables
400
- def compiled_method_lambda(template, roda_class, method_name, locals_keys=EMPTY_ARRAY)
403
+ def compiled_method_lambda(roda_class, method_name, locals_keys=EMPTY_ARRAY)
401
404
  mod = roda_class::RodaCompiledTemplates
405
+ template = self
402
406
  lambda do |locals, &block|
403
- if template.modified?
407
+ template.if_modified do
404
408
  mod.send(:define_method, method_name, Render.tilt_template_compiled_method(template, locals_keys, roda_class))
405
409
  mod.send(:private, method_name)
406
410
  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 = 78
7
+ RodaMinorVersion = 80
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.78.0
4
+ version: 3.80.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-03-13 00:00:00.000000000 Z
11
+ date: 2024-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -252,7 +252,9 @@ extra_rdoc_files:
252
252
  - doc/release_notes/3.76.0.txt
253
253
  - doc/release_notes/3.77.0.txt
254
254
  - doc/release_notes/3.78.0.txt
255
+ - doc/release_notes/3.79.0.txt
255
256
  - doc/release_notes/3.8.0.txt
257
+ - doc/release_notes/3.80.0.txt
256
258
  - doc/release_notes/3.9.0.txt
257
259
  files:
258
260
  - CHANGELOG
@@ -337,7 +339,9 @@ files:
337
339
  - doc/release_notes/3.76.0.txt
338
340
  - doc/release_notes/3.77.0.txt
339
341
  - doc/release_notes/3.78.0.txt
342
+ - doc/release_notes/3.79.0.txt
340
343
  - doc/release_notes/3.8.0.txt
344
+ - doc/release_notes/3.80.0.txt
341
345
  - doc/release_notes/3.9.0.txt
342
346
  - lib/roda.rb
343
347
  - lib/roda/cache.rb
@@ -399,6 +403,7 @@ files:
399
403
  - lib/roda/plugins/head.rb
400
404
  - lib/roda/plugins/header_matchers.rb
401
405
  - lib/roda/plugins/heartbeat.rb
406
+ - lib/roda/plugins/hmac_paths.rb
402
407
  - lib/roda/plugins/hooks.rb
403
408
  - lib/roda/plugins/host_authorization.rb
404
409
  - lib/roda/plugins/indifferent_params.rb
@@ -502,7 +507,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
502
507
  - !ruby/object:Gem::Version
503
508
  version: '0'
504
509
  requirements: []
505
- rubygems_version: 3.5.3
510
+ rubygems_version: 3.5.9
506
511
  signing_key:
507
512
  specification_version: 4
508
513
  summary: Routing tree web toolkit