roda 3.77.0 → 3.79.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: 8afa46b8055c19e63e1efeebf444b422cd810701c759936d476797515630245d
4
- data.tar.gz: 4e857447435707de1d586857126795e2a1191f685ca8893e99cd1c78aeb50c02
3
+ metadata.gz: 8e77e7eba739ca8bf0afe44555c8af8c9188f4ce8668310aad110a2afc638701
4
+ data.tar.gz: 19db55450dfe15e7aa4f678d7556ec427e52bcad421a096791d47a5a3fe128b4
5
5
  SHA512:
6
- metadata.gz: 2ecfeb211d574d46bdc31995c3551afb97af50acf8567cce83b46808e87ff797a5ef7c769e42c0477ed6a6d9271040b818da72e77c91e4f91f9760201d2cd5ca
7
- data.tar.gz: 45e67fbd1da64839c6dc5a8dc0975a0dca5f67a77c83ae79a62ab22e7d9cf0002cb8b8f6a22661bdaf97ad973b6bc6ff928af2e416a96c35b12150e9fe0eaf4d
6
+ metadata.gz: f504398b08e35ca42b765afca4357d750836ce5d7e3e7ec9ba73b061dde57fd2bbd3fa5d7752b9e6995739fac8a8718b0a8a71d70432ec14b65c95e13dadeb29
7
+ data.tar.gz: ceb20219c23d4e44d006c5fe177fff3ef2cacb77ec75e109b92c03d12f0e9cf434459759d007df96becfa0e426c89f4f8f9517c00c579135cfbb0e613852a0a1
data/CHANGELOG CHANGED
@@ -1,3 +1,13 @@
1
+ = 3.79.0 (2024-04-12)
2
+
3
+ * Do not update template mtime when there is an error reloading templates in the render plugin (jeremyevans)
4
+
5
+ * Add hmac_paths plugin for preventing path enumeration and supporting access control (jeremyevans)
6
+
7
+ = 3.78.0 (2024-03-13)
8
+
9
+ * Add permissions_policy plugin for setting Permissions-Policy header (jeremyevans)
10
+
1
11
  = 3.77.0 (2024-02-12)
2
12
 
3
13
  * Support formaction/formmethod attributes in forms in route_csrf plugin (jeremyevans)
@@ -0,0 +1,99 @@
1
+ = New Features
2
+
3
+ * A permissions_policy plugin has been added that allows you to easily set a
4
+ Permissions-Policy header for the application, which browsers can use to
5
+ determine whether to allow specific functionality on the returned page
6
+ (mainly related to which JavaScript APIs the page is allowed to use).
7
+
8
+ You would generally call the plugin with a block to set the default policy:
9
+
10
+ plugin :permissions_policy do |pp|
11
+ pp.camera :none
12
+ pp.fullscreen :self
13
+ pp.clipboard_read :self, 'https://example.com'
14
+ end
15
+
16
+ Then, anywhere in the routing tree, you can customize the policy for just that
17
+ branch or action using the same block syntax:
18
+
19
+ r.get 'foo' do
20
+ permissions_policy do |pp|
21
+ pp.camera :self
22
+ end
23
+ # ...
24
+ end
25
+
26
+ In addition to using a block, you can also call methods on the object returned
27
+ by the method:
28
+
29
+ r.get 'foo' do
30
+ permissions_policy.camera :self
31
+ # ...
32
+ end
33
+
34
+ You can use the :default plugin option to set the default for all settings.
35
+ For example, to disallow all access for each setting by default:
36
+
37
+ plugin :permissions_policy, default: :none
38
+
39
+ The following methods are available for configuring the permissions policy,
40
+ which specify the setting (substituting _ with -):
41
+
42
+ * accelerometer
43
+ * ambient_light_sensor
44
+ * autoplay
45
+ * bluetooth
46
+ * camera
47
+ * clipboard_read
48
+ * clipboard_write
49
+ * display_capture
50
+ * encrypted_media
51
+ * fullscreen
52
+ * geolocation
53
+ * gyroscope
54
+ * hid
55
+ * idle_detection
56
+ * keyboard_map
57
+ * magnetometer
58
+ * microphone
59
+ * midi
60
+ * payment
61
+ * picture_in_picture
62
+ * publickey_credentials_get
63
+ * screen_wake_lock
64
+ * serial
65
+ * sync_xhr
66
+ * usb
67
+ * web_share
68
+ * window_management
69
+
70
+ All of these methods support any number of arguments, and each argument should
71
+ be one of the following values:
72
+
73
+ :all :: Grants permission to all domains (must be only argument)
74
+ :none :: Does not allow permission at all (must be only argument)
75
+ :self :: Allows feature in current document and any nested browsing contexts
76
+ that use the same domain as the current document.
77
+ :src :: Allows feature in current document and any nested browsing contexts
78
+ that use the same domain as the src of the iframe.
79
+ String :: Specifies origin domain where access is allowed
80
+
81
+ When calling a method with no arguments, the setting is removed from the policy instead
82
+ of being left empty, since all of these setting require at least one value. Likewise,
83
+ if the policy does not have any settings, the header will not be added.
84
+
85
+ Calling the method overrides any previous setting. Each of the methods has +add_*+ and
86
+ +get_*+ methods defined. The +add_*+ method appends to any existing setting, and the +get_*+ method
87
+ returns the current value for the setting (this will be +:all+ if all domains are allowed, or
88
+ any array of strings/:self/:src).
89
+
90
+ permissions_policy.fullscreen :self, 'https://example.com'
91
+ # fullscreen (self "https://example.com")
92
+
93
+ permissions_policy.add_fullscreen 'https://*.example.com'
94
+ # fullscreen (self "https://example.com" "https://*.example.com")
95
+
96
+ permissions_policy.get_fullscreen
97
+ # => [:self, "https://example.com", "https://*.example.com"]
98
+
99
+ The clear method can be used to remove all settings from the policy.
@@ -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.
@@ -89,7 +89,7 @@ class Roda
89
89
  # content_security_policy.add_script_src 'example.com', [:nonce, 'foobarbaz']
90
90
  # # script-src 'self' 'unsafe-eval' example.com 'nonce-foobarbaz';
91
91
  #
92
- # content_security_policy.get_script_src 'example.com', [:nonce, 'foobarbaz']
92
+ # content_security_policy.get_script_src
93
93
  # # => [:self, :unsafe_eval, 'example.com', [:nonce, 'foobarbaz']]
94
94
  #
95
95
  # The clear method can be used to remove all settings from the policy.
@@ -0,0 +1,266 @@
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
+ # You can use +:root+, +:method+, and +:params+ at the same time:
116
+ #
117
+ # hmac_path('/1', root: '/widget', method: :get, params: {foo: 'bar'})
118
+ # # => "/widget/9169af1b8f40c62a1c2bb15b1b377c65bda681b8efded0e613a4176387468c15/mp/1?foo=bar"
119
+ #
120
+ # 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>.
122
+ #
123
+ # To handle secret rotation, you can provide an +:old_secret+ option when loading the
124
+ # plugin.
125
+ #
126
+ # plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes',
127
+ # old_secret: 'previous-secret-value-with-at-least-32-bytes'
128
+ #
129
+ # This will use +:secret+ for constructing new paths, but will respect paths generated by
130
+ # +:old_secret+.
131
+ module HmacPaths
132
+ def self.configure(app, opts=OPTS)
133
+ hmac_secret = opts[:secret]
134
+ unless hmac_secret.is_a?(String) && hmac_secret.bytesize >= 32
135
+ raise RodaError, "hmac_paths plugin :secret option must be a string containing at least 32 bytes"
136
+ end
137
+
138
+ if hmac_old_secret = opts[:old_secret]
139
+ unless hmac_old_secret.is_a?(String) && hmac_old_secret.bytesize >= 32
140
+ raise RodaError, "hmac_paths plugin :old_secret option must be a string containing at least 32 bytes if present"
141
+ end
142
+ end
143
+
144
+ app.opts[:hmac_paths_secret] = hmac_secret
145
+ app.opts[:hmac_paths_old_secret] = hmac_old_secret
146
+ end
147
+
148
+ module InstanceMethods
149
+ # Return a path with an HMAC. Designed to be used with r.hmac_path, to make sure
150
+ # users can only request paths that they have been provided by the application
151
+ # (directly or indirectly). This can prevent users of a site from enumerating
152
+ # valid paths. The given path should be a string starting with +/+. Options:
153
+ #
154
+ # :method :: Limits the returned path to only be valid for the given request method.
155
+ # :params :: Includes parameters in the query string of the returned path, and
156
+ # limits the returned path to only be valid for that exact query string.
157
+ # :root :: Should be an empty string or string starting with +/+. This will be
158
+ # the already matched path of the routing tree using r.hmac_path. Defaults
159
+ # to the empty string, which will returns paths valid for r.hmac_path at
160
+ # the top level of the routing tree.
161
+ def hmac_path(path, opts=OPTS)
162
+ unless path.is_a?(String) && path.getbyte(0) == 47
163
+ raise RodaError, "path must be a string starting with /"
164
+ end
165
+
166
+ root = opts[:root] || ''
167
+ unless root.is_a?(String) && ((root_byte = root.getbyte(0)) == 47 || root_byte == nil)
168
+ raise RodaError, "root must be empty string or string starting with /"
169
+ end
170
+
171
+ flags = String.new
172
+ path = path.dup
173
+
174
+ if method = opts[:method]
175
+ flags << 'm'
176
+ end
177
+
178
+ if params = opts[:params]
179
+ flags << 'p'
180
+ path << '?' << Rack::Utils.build_query(params)
181
+ end
182
+
183
+ flags << '0' if flags.empty?
184
+
185
+ hmac_path = if method
186
+ "#{method.to_s.upcase}:/#{flags}#{path}"
187
+ else
188
+ "/#{flags}#{path}"
189
+ end
190
+
191
+ "#{root}/#{hmac_path_hmac(root, hmac_path)}/#{flags}#{path}"
192
+ end
193
+
194
+ # The HMAC to use in hmac_path, for the given root, path, and options.
195
+ def hmac_path_hmac(root, path, opts=OPTS)
196
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, hmac_path_hmac_secret(root, opts), path)
197
+ end
198
+
199
+ private
200
+
201
+ # 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
203
+ def hmac_path_hmac_secret(root, opts=OPTS)
204
+ secret = opts[:secret] || self.opts[:hmac_paths_secret]
205
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, secret, root)
206
+ end
207
+ end
208
+
209
+ module RequestMethods
210
+ # Looks at the first segment of the remaining path, and if it contains a valid HMAC for the
211
+ # rest of the path considering the flags in the second segment and the given options, the
212
+ # block matches and is yielded to, and the result of the block is returned. Otherwise, the
213
+ # block does not matches and routing continues after the call.
214
+ def hmac_path(opts=OPTS, &block)
215
+ orig_path = remaining_path
216
+ mpath = matched_path
217
+
218
+ on String do |submitted_hmac|
219
+ rpath = remaining_path
220
+
221
+ if submitted_hmac.bytesize == 64
222
+ on String do |flags|
223
+ if flags.bytesize >= 1
224
+ if flags.include?('m')
225
+ rpath = "#{env['REQUEST_METHOD'].to_s.upcase}:#{rpath}"
226
+ end
227
+
228
+ if flags.include?('p')
229
+ rpath = "#{rpath}?#{env["QUERY_STRING"]}"
230
+ end
231
+
232
+ if hmac_path_valid?(mpath, rpath, submitted_hmac)
233
+ always(&block)
234
+ end
235
+ end
236
+
237
+ # Return from method without matching
238
+ @remaining_path = orig_path
239
+ return
240
+ end
241
+ end
242
+
243
+ # Return from method without matching
244
+ @remaining_path = orig_path
245
+ return
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ # 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)
254
+ true
255
+ 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)
257
+ else
258
+ false
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ register_plugin(:hmac_paths, HmacPaths)
265
+ end
266
+ end
@@ -0,0 +1,326 @@
1
+ # frozen-string-literal: true
2
+
3
+ #
4
+ class Roda
5
+ module RodaPlugins
6
+ # A permissions_policy plugin has been added that allows you to easily set a
7
+ # Permissions-Policy header for the application, which browsers can use to
8
+ # determine whether to allow specific functionality on the returned page
9
+ # (mainly related to which JavaScript APIs the page is allowed to use).
10
+ #
11
+ # You would generally call the plugin with a block to set the default policy:
12
+ #
13
+ # plugin :permissions_policy do |pp|
14
+ # pp.camera :none
15
+ # pp.fullscreen :self
16
+ # pp.clipboard_read :self, 'https://example.com'
17
+ # end
18
+ #
19
+ # Then, anywhere in the routing tree, you can customize the policy for just that
20
+ # branch or action using the same block syntax:
21
+ #
22
+ # r.get 'foo' do
23
+ # permissions_policy do |pp|
24
+ # pp.camera :self
25
+ # end
26
+ # # ...
27
+ # end
28
+ #
29
+ # In addition to using a block, you can also call methods on the object returned
30
+ # by the method:
31
+ #
32
+ # r.get 'foo' do
33
+ # permissions_policy.camera :self
34
+ # # ...
35
+ # end
36
+ #
37
+ # You can use the :default plugin option to set the default for all settings.
38
+ # For example, to disallow all access for each setting by default:
39
+ #
40
+ # plugin :permissions_policy, default: :none
41
+ #
42
+ # The following methods are available for configuring the permissions policy,
43
+ # which specify the setting (substituting _ with -):
44
+ #
45
+ # * accelerometer
46
+ # * ambient_light_sensor
47
+ # * autoplay
48
+ # * bluetooth
49
+ # * camera
50
+ # * clipboard_read
51
+ # * clipboard_write
52
+ # * display_capture
53
+ # * encrypted_media
54
+ # * fullscreen
55
+ # * geolocation
56
+ # * gyroscope
57
+ # * hid
58
+ # * idle_detection
59
+ # * keyboard_map
60
+ # * magnetometer
61
+ # * microphone
62
+ # * midi
63
+ # * payment
64
+ # * picture_in_picture
65
+ # * publickey_credentials_get
66
+ # * screen_wake_lock
67
+ # * serial
68
+ # * sync_xhr
69
+ # * usb
70
+ # * web_share
71
+ # * window_management
72
+ #
73
+ # All of these methods support any number of arguments, and each argument should
74
+ # be one of the following values:
75
+ #
76
+ # :all :: Grants permission to all domains (must be only argument)
77
+ # :none :: Does not allow permission at all (must be only argument)
78
+ # :self :: Allows feature in current document and any nested browsing contexts
79
+ # that use the same domain as the current document.
80
+ # :src :: Allows feature in current document and any nested browsing contexts
81
+ # that use the same domain as the src of the iframe.
82
+ # String :: Specifies origin domain where access is allowed
83
+ #
84
+ # When calling a method with no arguments, the setting is removed from the policy instead
85
+ # of being left empty, since all of these setting require at least one value. Likewise,
86
+ # if the policy does not have any settings, the header will not be added.
87
+ #
88
+ # Calling the method overrides any previous setting. Each of the methods has +add_*+ and
89
+ # +get_*+ methods defined. The +add_*+ method appends to any existing setting, and the +get_*+ method
90
+ # returns the current value for the setting (this will be +:all+ if all domains are allowed, or
91
+ # any array of strings/:self/:src).
92
+ #
93
+ # permissions_policy.fullscreen :self, 'https://example.com'
94
+ # # fullscreen (self "https://example.com")
95
+ #
96
+ # permissions_policy.add_fullscreen 'https://*.example.com'
97
+ # # fullscreen (self "https://example.com" "https://*.example.com")
98
+ #
99
+ # permissions_policy.get_fullscreen
100
+ # # => [:self, "https://example.com", "https://*.example.com"]
101
+ #
102
+ # The clear method can be used to remove all settings from the policy.
103
+ module PermissionsPolicy
104
+ SUPPORTED_SETTINGS = %w'
105
+ accelerometer
106
+ ambient-light-sensor
107
+ autoplay
108
+ bluetooth
109
+ camera
110
+ clipboard-read
111
+ clipboard-write
112
+ display-capture
113
+ encrypted-media
114
+ fullscreen
115
+ geolocation
116
+ gyroscope
117
+ hid
118
+ idle-detection
119
+ keyboard-map
120
+ magnetometer
121
+ microphone
122
+ midi
123
+ payment
124
+ picture-in-picture
125
+ publickey-credentials-get
126
+ screen-wake-lock
127
+ serial
128
+ sync-xhr
129
+ usb
130
+ web-share
131
+ window-management
132
+ '.each(&:freeze).freeze
133
+ private_constant :SUPPORTED_SETTINGS
134
+
135
+ # Represents a permissions policy.
136
+ class Policy
137
+ SUPPORTED_SETTINGS.each do |setting|
138
+ meth = setting.gsub('-', '_').freeze
139
+
140
+ # Setting method name sets the setting value, or removes it if no args are given.
141
+ define_method(meth) do |*args|
142
+ if args.empty?
143
+ @opts.delete(setting)
144
+ else
145
+ @opts[setting] = option_value(args)
146
+ end
147
+ nil
148
+ end
149
+
150
+ # add_* method name adds to the setting value, or clears setting if no values
151
+ # are given.
152
+ define_method(:"add_#{meth}") do |*args|
153
+ unless args.empty?
154
+ case v = @opts[setting]
155
+ when :all
156
+ # If all domains are already allowed, there is no reason to add more.
157
+ return
158
+ when Array
159
+ @opts[setting] = option_value(v + args)
160
+ else
161
+ @opts[setting] = option_value(args)
162
+ end
163
+ end
164
+ nil
165
+ end
166
+
167
+ # get_* method always returns current setting value.
168
+ define_method(:"get_#{meth}") do
169
+ @opts[setting]
170
+ end
171
+ end
172
+
173
+ def initialize
174
+ clear
175
+ end
176
+
177
+ # Clear all settings, useful to remove any inherited settings.
178
+ def clear
179
+ @opts = {}
180
+ end
181
+
182
+ # Do not allow future modifications to any settings.
183
+ def freeze
184
+ @opts.freeze
185
+ header_value.freeze
186
+ super
187
+ end
188
+
189
+ # The header name to use, depends on whether report only mode has been enabled.
190
+ def header_key
191
+ @report_only ? RodaResponseHeaders::PERMISSIONS_POLICY_REPORT_ONLY : RodaResponseHeaders::PERMISSIONS_POLICY
192
+ end
193
+
194
+ # The header value to use.
195
+ def header_value
196
+ return @header_value if @header_value
197
+
198
+ s = String.new
199
+ @opts.each do |k, vs|
200
+ s << k << "="
201
+
202
+ if vs == :all
203
+ s << '*, '
204
+ else
205
+ s << '('
206
+ vs.each{|v| append_formatted_value(s, v)}
207
+ s.chop! unless vs.empty?
208
+ s << '), '
209
+ end
210
+ end
211
+ s.chop!
212
+ s.chop!
213
+ @header_value = s
214
+ end
215
+
216
+ # Set whether the Permissions-Policy-Report-Only header instead of the
217
+ # default Permissions-Policy header.
218
+ def report_only(report=true)
219
+ @report_only = report
220
+ end
221
+
222
+ # Whether this policy uses report only mode.
223
+ def report_only?
224
+ !!@report_only
225
+ end
226
+
227
+ # Set the current policy in the headers hash. If no settings have been made
228
+ # in the policy, does not set a header.
229
+ def set_header(headers)
230
+ return if @opts.empty?
231
+ headers[header_key] ||= header_value
232
+ end
233
+
234
+ private
235
+
236
+ # Formats nested values, quoting strings and using :self and :src verbatim.
237
+ def append_formatted_value(s, v)
238
+ case v
239
+ when String
240
+ s << v.inspect << ' '
241
+ when :self
242
+ s << 'self '
243
+ when :src
244
+ s << 'src '
245
+ else
246
+ raise RodaError, "unsupported Permissions-Policy item value used: #{v.inspect}"
247
+ end
248
+ end
249
+
250
+ # Make object copy use copy of settings, and remove cached header value.
251
+ def initialize_copy(_)
252
+ super
253
+ @opts = @opts.dup
254
+ @header_value = nil
255
+ end
256
+
257
+ # The option value to store for the given args.
258
+ def option_value(args)
259
+ if args.length == 1
260
+ case args[0]
261
+ when :all
262
+ :all
263
+ when :none
264
+ EMPTY_ARRAY
265
+ else
266
+ args.freeze
267
+ end
268
+ else
269
+ args.freeze
270
+ end
271
+ end
272
+ end
273
+
274
+ # Yield the current Permissions Policy to the block.
275
+ def self.configure(app, opts=OPTS)
276
+ policy = app.opts[:permissions_policy] = if policy = app.opts[:permissions_policy]
277
+ policy.dup
278
+ else
279
+ Policy.new
280
+ end
281
+
282
+ if default = opts[:default]
283
+ SUPPORTED_SETTINGS.each do |setting|
284
+ policy.send(setting.gsub('-', '_'), *default)
285
+ end
286
+ end
287
+
288
+ yield policy if defined?(yield)
289
+ policy.freeze
290
+ end
291
+
292
+ module InstanceMethods
293
+ # If a block is given, yield the current permission policy. Returns the
294
+ # current permissions policy.
295
+ def permissions_policy
296
+ policy = @_response.permissions_policy
297
+ yield policy if defined?(yield)
298
+ policy
299
+ end
300
+ end
301
+
302
+ module ResponseMethods
303
+ # Unset any permissions policy when reinitializing
304
+ def initialize
305
+ super
306
+ @permissions_policy &&= nil
307
+ end
308
+
309
+ # The current permissions policy to be used for this response.
310
+ def permissions_policy
311
+ @permissions_policy ||= roda_class.opts[:permissions_policy].dup
312
+ end
313
+
314
+ private
315
+
316
+ # Set the appropriate permissions policy header.
317
+ def set_default_headers
318
+ super
319
+ (@permissions_policy || roda_class.opts[:permissions_policy]).set_header(headers)
320
+ end
321
+ end
322
+ end
323
+
324
+ register_plugin(:permissions_policy, PermissionsPolicy)
325
+ end
326
+ 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/response.rb CHANGED
@@ -14,7 +14,8 @@ class Roda
14
14
 
15
15
  %w'Allow Cache-Control Content-Disposition Content-Encoding Content-Length
16
16
  Content-Security-Policy Content-Security-Policy-Report-Only Content-Type
17
- ETag Expires Last-Modified Link Location Set-Cookie Transfer-Encoding Vary'.
17
+ ETag Expires Last-Modified Link Location Set-Cookie Transfer-Encoding Vary
18
+ Permissions-Policy Permissions-Policy-Report-Only'.
18
19
  each do |value|
19
20
  value = value.downcase if downcase
20
21
  const_set(value.gsub('-', '_').upcase!.to_sym, value.freeze)
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 = 77
7
+ RodaMinorVersion = 79
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.77.0
4
+ version: 3.79.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-02-12 00:00:00.000000000 Z
11
+ date: 2024-04-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -251,6 +251,8 @@ extra_rdoc_files:
251
251
  - doc/release_notes/3.75.0.txt
252
252
  - doc/release_notes/3.76.0.txt
253
253
  - doc/release_notes/3.77.0.txt
254
+ - doc/release_notes/3.78.0.txt
255
+ - doc/release_notes/3.79.0.txt
254
256
  - doc/release_notes/3.8.0.txt
255
257
  - doc/release_notes/3.9.0.txt
256
258
  files:
@@ -335,6 +337,8 @@ files:
335
337
  - doc/release_notes/3.75.0.txt
336
338
  - doc/release_notes/3.76.0.txt
337
339
  - doc/release_notes/3.77.0.txt
340
+ - doc/release_notes/3.78.0.txt
341
+ - doc/release_notes/3.79.0.txt
338
342
  - doc/release_notes/3.8.0.txt
339
343
  - doc/release_notes/3.9.0.txt
340
344
  - lib/roda.rb
@@ -397,6 +401,7 @@ files:
397
401
  - lib/roda/plugins/head.rb
398
402
  - lib/roda/plugins/header_matchers.rb
399
403
  - lib/roda/plugins/heartbeat.rb
404
+ - lib/roda/plugins/hmac_paths.rb
400
405
  - lib/roda/plugins/hooks.rb
401
406
  - lib/roda/plugins/host_authorization.rb
402
407
  - lib/roda/plugins/indifferent_params.rb
@@ -432,6 +437,7 @@ files:
432
437
  - lib/roda/plugins/path.rb
433
438
  - lib/roda/plugins/path_matchers.rb
434
439
  - lib/roda/plugins/path_rewriter.rb
440
+ - lib/roda/plugins/permissions_policy.rb
435
441
  - lib/roda/plugins/placeholder_string_matchers.rb
436
442
  - lib/roda/plugins/plain_hash_response_headers.rb
437
443
  - lib/roda/plugins/precompile_templates.rb