# frozen-string-literal: true # class Roda module RodaPlugins # The content_security_policy plugin allows you to easily set a Content-Security-Policy # header for the application, which modern browsers will use to control access to specific # types of page content. # # You would generally call the plugin with a block to set the default policy: # # plugin :content_security_policy do |csp| # csp.default_src :none # csp.img_src :self # csp.style_src :self # csp.script_src :self # csp.font_src :self # csp.form_action :self # csp.base_uri :none # csp.frame_ancestors :none # csp.block_all_mixed_content # 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 # content_security_policy do |csp| # csp.object_src :self # csp.add_style_src 'bar.com' # end # # ... # end # # In addition to using a block, you can also call methods on the object returned # by the method: # # r.get 'foo' do # content_security_policy.script_src :self, 'example.com', [:nonce, 'foobarbaz'] # # ... # end # # The following methods are available for configuring the content security policy, # which specify the setting (substituting _ with -): # # * base_uri # * child_src # * connect_src # * default_src # * font_src # * form_action # * frame_ancestors # * frame_src # * img_src # * manifest_src # * media_src # * object_src # * plugin_types # * report_to # * report_uri # * require_sri_for # * sandbox # * script_src # * style_src # * worker_src # # All of these methods support any number of arguments, and each argument should # be one of the following types: # # String :: used verbatim # Symbol :: Substitutes +_+ with +-+ and surrounds with ' # Array :: only accepts 2 element arrays, joins elements with +-+ and # surrounds the result with ' # # Example: # # content_security_policy.script_src :self, :unsafe_eval, 'example.com', [:nonce, 'foobarbaz'] # # script-src 'self' 'unsafe-eval' example.com 'nonce-foobarbaz'; # # 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. # # content_security_policy.script_src :self, :unsafe_eval # content_security_policy.add_script_src 'example.com', [:nonce, 'foobarbaz'] # # script-src 'self' 'unsafe-eval' example.com 'nonce-foobarbaz'; # # content_security_policy.get_script_src 'example.com', [:nonce, 'foobarbaz'] # # => [:self, :unsafe_eval, 'example.com', [:nonce, 'foobarbaz']] # # The clear method can be used to remove all settings from the policy. # # The following methods to set boolean settings are also defined: # # * block_all_mixed_content # * upgrade_insecure_requests # # Calling these methods will turn on the related setting. To turn the setting # off again, you can call them with a +false+ argument. There is also a *? method # for each setting for returning whether the setting is currently enabled. # # Likewise there is also a +report_only+ method for turning on report only mode (the # default is enforcement mode), or turning off report only mode if a false argument # is given. Also, there is a +report_only?+ method for returning whether report only # mode is enabled. module ContentSecurityPolicy # Represents a content security policy. class Policy ' base-uri child-src connect-src default-src font-src form-action frame-ancestors frame-src img-src manifest-src media-src object-src plugin-types report-to report-uri require-sri-for sandbox script-src style-src worker-src '.split.each(&:freeze).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] = args.freeze 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? @opts[setting] ||= EMPTY_ARRAY @opts[setting] += args @opts[setting].freeze end nil end # get_* method always returns current setting value. define_method("get_#{meth}") do @opts[setting] end end %w'block-all-mixed-content upgrade-insecure-requests'.each(&:freeze).each do |setting| meth = setting.gsub('-', '_').freeze # Setting method name turns on setting if true or no argument given, # or removes setting if false is given. define_method(meth) do |arg=true| if arg @opts[setting] = true else @opts.delete(setting) end nil end # *? method returns true or false depending on whether setting is enabled. define_method("#{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::CONTENT_SECURITY_POLICY_REPORT_ONLY : RodaResponseHeaders::CONTENT_SECURITY_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 unless vs == true vs.each{|v| append_formatted_value(s, v)} end s << '; ' end @header_value = s end # Set whether the Content-Security-Policy-Report-Only header instead of the # default Content-Security-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 # Handle three types of values when formatting the header: # String :: used verbatim # Symbol :: Substitutes _ with - and surrounds with ' # Array :: only accepts 2 element arrays, joins them with - and # surrounds them with ' def append_formatted_value(s, v) case v when String s << ' ' << v when Array case v.length when 2 s << " '" << v.join('-') << "'" else raise RodaError, "unsupported CSP value used: #{v.inspect}" end when Symbol s << " '" << v.to_s.gsub('_', '-') << "'" else raise RodaError, "unsupported CSP 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 end # Yield the current Content Security Policy to the block. def self.configure(app) policy = app.opts[:content_security_policy] = if policy = app.opts[:content_security_policy] policy.dup else Policy.new end yield policy if defined?(yield) policy.freeze end module InstanceMethods # If a block is given, yield the current content security policy. Returns the # current content security policy. def content_security_policy policy = @_response.content_security_policy yield policy if defined?(yield) policy end end module ResponseMethods # Unset any content security policy when reinitializing def initialize super @content_security_policy &&= nil end # The current content security policy to be used for this response. def content_security_policy @content_security_policy ||= roda_class.opts[:content_security_policy].dup end private # Set the appropriate content security policy header. def set_default_headers super (@content_security_policy || roda_class.opts[:content_security_policy]).set_header(headers) end end end register_plugin(:content_security_policy, ContentSecurityPolicy) end end