diff --git a/CHANGELOG b/CHANGELOG index 05580872..2c0f29c9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ += master + +* Add permissions_policy plugin for setting Permissions-Policy header (jeremyevans) + = 3.77.0 (2024-02-12) * Support formaction/formmethod attributes in forms in route_csrf plugin (jeremyevans) diff --git a/lib/roda/plugins/permissions_policy.rb b/lib/roda/plugins/permissions_policy.rb new file mode 100644 index 00000000..378e9f5d --- /dev/null +++ b/lib/roda/plugins/permissions_policy.rb @@ -0,0 +1,324 @@ +# frozen-string-literal: true + +# +class Roda + module RodaPlugins + # The permissions_policy plugin allows you to easily set a Permissions-Policy + # header for the application, which Chrome-based browsers will use to determine + # whether to allow access for specific type of requests (mainly related to which + # JavaScript APIs the page is allowed to use). + # + # You would generally call the plugin with a block to set the default policy: + # + # plugin :permissions_policy do |pp| + # pp.camera :none + # pp.fullscreen :self + # pp.clipboard-read :self, 'https://example.com' + # end + # + # Then, anywhere in the routing tree, you can customize the policy for just that + # branch or action using the same block syntax: + # + # r.get 'foo' do + # permissions_policy do |pp| + # pp.camera :self + # end + # # ... + # end + # + # In addition to using a block, you can also call methods on the object returned + # by the method: + # + # r.get 'foo' do + # permissions_policy.camera :self + # # ... + # end + # + # You can use the :default plugin option to set the default for all settings. + # For example, to disallow all access for each setting by default: + # + # plugin :permissions_policy, default: :none + # + # The following methods are available for configuring the permissions policy, + # which specify the setting (substituting _ with -): + # + # * accelerometer + # * ambient_light_sensor + # * autoplay + # * bluetooth + # * camera + # * clipboard_read + # * clipboard_write + # * display_capture + # * encrypted_media + # * fullscreen + # * geolocation + # * gyroscope + # * hid + # * idle_detection + # * keyboard_map + # * magnetometer + # * microphone + # * midi + # * payment + # * picture_in_picture + # * publickey_credentials_get + # * screen_wake_lock + # * serial + # * sync_xhr + # * usb + # * web_share + # * window_management + # + # All of these methods support any number of arguments, and each argument should + # be one of the following values: + # + # :all :: Grants permission to all domains (must be only argument) + # :none :: Does not allow permission at all (must be only argument) + # :self :: Allows feature in current document and any nested browsing contexts + # that use the same domain as the current document. + # :src :: Allows feature in current document and any nested browsing contexts + # that use the same domain as the src of the iframe. + # String :: Specifies origin domain where access is allowed + # + # When calling a method with no arguments, the setting is removed from the policy instead + # of being left empty, since all of these setting require at least one value. Likewise, + # if the policy does not have any settings, the header will not be added. + # + # Calling the method overrides any previous setting. Each of the methods has +add_*+ and + # +get_*+ methods defined. The +add_*+ method appends to any existing setting, and the +get_*+ method + # returns the current value for the setting (this will be +:all+ if all domains are allowed, or + # any array of strings/:self/:src). + # + # permissions_policy.fullscreen :self, 'https://example.com' + # permissions_policy.add_fullscreen 'https://*.example.com' + # # fullscreen (self "https://example.com" "https://*.example.com") + # + # permissions_policy.get_fullscreen + # # => [:self, "https://example.com", "https://*.example.com"] + # + # The clear method can be used to remove all settings from the policy. + module PermissionsPolicy + SUPPORTED_SETTINGS = %w' + accelerometer + ambient-light-sensor + autoplay + bluetooth + camera + clipboard-read + clipboard-write + display-capture + encrypted-media + fullscreen + geolocation + gyroscope + hid + idle-detection + keyboard-map + magnetometer + microphone + midi + payment + picture-in-picture + publickey-credentials-get + screen-wake-lock + serial + sync-xhr + usb + web-share + window-management + '.each(&:freeze).freeze + private_constant :SUPPORTED_SETTINGS + + # Represents a permissions policy. + class Policy + SUPPORTED_SETTINGS.each do |setting| + meth = setting.gsub('-', '_').freeze + + # Setting method name sets the setting value, or removes it if no args are given. + define_method(meth) do |*args| + if args.empty? + @opts.delete(setting) + else + @opts[setting] = option_value(args) + end + nil + end + + # add_* method name adds to the setting value, or clears setting if no values + # are given. + define_method(:"add_#{meth}") do |*args| + unless args.empty? + case v = @opts[setting] + when :all + # If all domains are already allowed, there is no reason to add more. + return + when Array + @opts[setting] = option_value(v + args) + else + @opts[setting] = option_value(args) + end + end + nil + end + + # get_* method always returns current setting value. + define_method(:"get_#{meth}") do + @opts[setting] + end + end + + def initialize + clear + end + + # Clear all settings, useful to remove any inherited settings. + def clear + @opts = {} + end + + # Do not allow future modifications to any settings. + def freeze + @opts.freeze + header_value.freeze + super + end + + # The header name to use, depends on whether report only mode has been enabled. + def header_key + @report_only ? RodaResponseHeaders::PERMISSIONS_POLICY_REPORT_ONLY : RodaResponseHeaders::PERMISSIONS_POLICY + end + + # The header value to use. + def header_value + return @header_value if @header_value + + s = String.new + @opts.each do |k, vs| + s << k << "=" + + if vs == :all + s << '*, ' + else + s << '(' + vs.each{|v| append_formatted_value(s, v)} + s.chop! unless vs.empty? + s << '), ' + end + end + s.chop! + s.chop! + @header_value = s + end + + # Set whether the Permissions-Policy-Report-Only header instead of the + # default Permissions-Policy header. + def report_only(report=true) + @report_only = report + end + + # Whether this policy uses report only mode. + def report_only? + !!@report_only + end + + # Set the current policy in the headers hash. If no settings have been made + # in the policy, does not set a header. + def set_header(headers) + return if @opts.empty? + headers[header_key] ||= header_value + end + + private + + # Formats nested values, quoting strings and using :self and :src verbatim. + def append_formatted_value(s, v) + case v + when String + s << v.inspect << ' ' + when :self + s << 'self ' + when :src + s << 'src ' + else + raise RodaError, "unsupported Permissions-Policy item value used: #{v.inspect}" + end + end + + # Make object copy use copy of settings, and remove cached header value. + def initialize_copy(_) + super + @opts = @opts.dup + @header_value = nil + end + + # The option value to store for the given args. + def option_value(args) + if args.length == 1 + case args[0] + when :all + :all + when :none + EMPTY_ARRAY + else + args.freeze + end + else + args.freeze + end + end + end + + # Yield the current Permissions Policy to the block. + def self.configure(app, opts=OPTS) + policy = app.opts[:permissions_policy] = if policy = app.opts[:permissions_policy] + policy.dup + else + Policy.new + end + + if default = opts[:default] + SUPPORTED_SETTINGS.each do |setting| + policy.send(setting.gsub('-', '_'), *default) + end + end + + yield policy if defined?(yield) + policy.freeze + end + + module InstanceMethods + # If a block is given, yield the current permission policy. Returns the + # current permissions policy. + def permissions_policy + policy = @_response.permissions_policy + yield policy if defined?(yield) + policy + end + end + + module ResponseMethods + # Unset any permissions policy when reinitializing + def initialize + super + @permissions_policy &&= nil + end + + # The current permissions policy to be used for this response. + def permissions_policy + @permissions_policy ||= roda_class.opts[:permissions_policy].dup + end + + private + + # Set the appropriate permissions policy header. + def set_default_headers + super + (@permissions_policy || roda_class.opts[:permissions_policy]).set_header(headers) + end + end + end + + register_plugin(:permissions_policy, PermissionsPolicy) + end +end diff --git a/lib/roda/response.rb b/lib/roda/response.rb index 38bc4489..46c60c4b 100644 --- a/lib/roda/response.rb +++ b/lib/roda/response.rb @@ -14,7 +14,8 @@ module RodaResponseHeaders %w'Allow Cache-Control Content-Disposition Content-Encoding Content-Length Content-Security-Policy Content-Security-Policy-Report-Only Content-Type - ETag Expires Last-Modified Link Location Set-Cookie Transfer-Encoding Vary'. + ETag Expires Last-Modified Link Location Set-Cookie Transfer-Encoding Vary + Permissions-Policy Permissions-Policy-Report-Only'. each do |value| value = value.downcase if downcase const_set(value.gsub('-', '_').upcase!.to_sym, value.freeze) diff --git a/spec/plugin/permissions_policy_spec.rb b/spec/plugin/permissions_policy_spec.rb new file mode 100644 index 00000000..d0e9fa42 --- /dev/null +++ b/spec/plugin/permissions_policy_spec.rb @@ -0,0 +1,161 @@ +require_relative "../spec_helper" + +describe "permissions_policy plugin" do + it "does not add header if no options are set" do + app(:permissions_policy){'a'} + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/a").must_be_nil + end + + it "sets Permissions-Policy header" do + app(:bare) do + plugin :permissions_policy do |pp| + pp.camera :none + pp.fullscreen :self + pp.midi :self, 'http://example.com' + pp.geolocation :all + end + + route do |r| + r.get 'ro' do + permissions_policy.report_only + '' + end + + r.get 'nro' do + permissions_policy.report_only + permissions_policy.report_only(false) + permissions_policy.report_only?.inspect + end + + r.get 'get' do + permissions_policy.get_geolocation.inspect + end + + r.get 'add' do + permissions_policy.add_camera('http://foo.com', 'https://bar.com') + permissions_policy.add_geolocation('http://foo.com', 'https://bar.com') + permissions_policy.add_fullscreen('https://foo.com', 'http://bar.com') + permissions_policy.add_midi('https://foo.com') + '' + end + + r.get 'empty' do + permissions_policy.add_geolocation + '' + end + + r.get 'set' do + permissions_policy.fullscreen('http://foobar.com', 'https://barfoo.com') + '' + end + + r.get 'block' do + permissions_policy do |pp| + pp.geolocation(:src, 'http://foo.com', 'https://bar.com') + pp.camera :all + pp.add_midi + pp.fullscreen + pp.report_only + end + '' + end + + r.get 'clear' do + permissions_policy do |pp| + pp.clear + pp.add_geolocation('http://foo.com', 'https://bar.com') + end + '' + end + + 'a' + end + end + + v = 'camera=(), fullscreen=(self), midi=(self "http://example.com"), geolocation=*' + + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/a").must_equal v + + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/nro").must_equal v + header(RodaResponseHeaders::PERMISSIONS_POLICY_REPORT_ONLY, "/nro").must_be_nil + body("/nro").must_equal 'false' + + header(RodaResponseHeaders::PERMISSIONS_POLICY_REPORT_ONLY, "/ro").must_equal v + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/ro").must_be_nil + + body('/get').must_equal ':all' + + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/add").must_equal 'camera=("http://foo.com" "https://bar.com"), fullscreen=(self "https://foo.com" "http://bar.com"), midi=(self "http://example.com" "https://foo.com"), geolocation=*' + + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/empty").must_equal 'camera=(), fullscreen=(self), midi=(self "http://example.com"), geolocation=*' + + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/set").must_equal 'camera=(), fullscreen=("http://foobar.com" "https://barfoo.com"), midi=(self "http://example.com"), geolocation=*' + + header(RodaResponseHeaders::PERMISSIONS_POLICY_REPORT_ONLY, "/block").must_equal 'camera=*, midi=(self "http://example.com"), geolocation=(src "http://foo.com" "https://bar.com")' + + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/clear").must_equal 'geolocation=("http://foo.com" "https://bar.com")' + end + + it "raises error for unsupported Permission-Policy values" do + app{} + proc{app.plugin(:permissions_policy){|pp| pp.fullscreen Object.new}}.must_raise Roda::RodaError + proc{app.plugin(:permissions_policy){|pp| pp.fullscreen []}}.must_raise Roda::RodaError + proc{app.plugin(:permissions_policy){|pp| pp.fullscreen [:a]}}.must_raise Roda::RodaError + proc{app.plugin(:permissions_policy){|pp| pp.fullscreen [:a, :b, :c]}}.must_raise Roda::RodaError + end + + it "supports :default plugin option" do + app(:bare) do + plugin :permissions_policy, :default=>:none + route do |r| + '' + end + end + + header(RodaResponseHeaders::PERMISSIONS_POLICY).must_equal Roda::RodaPlugins::PermissionsPolicy.const_get(:SUPPORTED_SETTINGS).map{|s| "#{s}=()"}.join(', ') + end + + it "supports all documented settings" do + app(:permissions_policy) do |r| + permissions_policy.send(r.path[1..-1], :self) + end + + Roda::RodaPlugins::PermissionsPolicy.const_get(:SUPPORTED_SETTINGS).each do |setting| + header(RodaResponseHeaders::PERMISSIONS_POLICY, "/#{setting.gsub('-', '_')}").must_equal "#{setting}=(self)" + end + end + + it "does not override existing heading" do + app(:permissions_policy) do |r| + permissions_policy.fullscreen :self + response[RodaResponseHeaders::PERMISSIONS_POLICY] = "foo" + '' + end + header(RodaResponseHeaders::PERMISSIONS_POLICY).must_equal "foo" + end + + it "works with error_handler" do + app(:bare) do + plugin(:error_handler){|_| ''} + plugin :permissions_policy do |pp| + pp.fullscreen :self + pp.camera :self, 'https://example.com' + pp.midi :none + end + + route do |r| + r.get 'a' do + permissions_policy.fullscreen 'foo.com' + raise + end + + raise + end + end + + header(RodaResponseHeaders::PERMISSIONS_POLICY).must_equal 'fullscreen=(self), camera=(self "https://example.com"), midi=()' + + # Don't include updates before the error + header(RodaResponseHeaders::PERMISSIONS_POLICY, '/a').must_equal 'fullscreen=(self), camera=(self "https://example.com"), midi=()' + end +end