roda 3.77.0 → 3.79.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: 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