roda 3.78.0 → 3.80.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: 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