diff --git a/README.md b/README.md index 5a65a50f..b31e573a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ SecureHeaders::Configuration.default do |config| secure: true, # mark all cookies as "Secure" httponly: true, # mark all cookies as "HttpOnly" samesite: { - strict: true # mark all cookies as SameSite=Strict + lax: true # mark all cookies as SameSite=lax } } config.hsts = "max-age=#{20.years.to_i}; includeSubdomains; preload" @@ -48,7 +48,7 @@ SecureHeaders::Configuration.default do |config| config.referrer_policy = "origin-when-cross-origin" config.csp = { # "meta" values. these will shaped the header, but the values are not included in the header. - report_only: true, # default: false + report_only: true, # default: false [DEPRECATED: instead, configure csp_report_only] preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content. # directive values: these values will directly translate into source directives @@ -69,6 +69,10 @@ SecureHeaders::Configuration.default do |config| upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/ report_uri: %w(https://report-uri.io/example-csp) } + config.csp_report_only = config.csp.merge({ + img_src: %w(somewhereelse.com), + report_uri: %w(https://report-uri.io/example-csp-report-only) + }) config.hpkp = { report_only: false, max_age: 60.days.to_i, @@ -92,7 +96,32 @@ use SecureHeaders::Middleware ## Default values -All headers except for PublicKeyPins have a default value. See the [corresponding classes for their defaults](https://github.com/twitter/secureheaders/tree/master/lib/secure_headers/headers). +All headers except for PublicKeyPins have a default value. See the [corresponding classes for their defaults](https://github.com/twitter/secureheaders/tree/master/lib/secure_headers/headers). The default set of headers is: + +``` +Content-Security-Policy: default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline' +Strict-Transport-Security: max-age=631138519 +X-Content-Type-Options: nosniff +X-Download-Options: noopen +X-Frame-Options: sameorigin +X-Permitted-Cross-Domain-Policies: none +X-Xss-Protection: 1; mode=block +``` + +### Default CSP + +By default, the above CSP will be applied to all requests. If you **only** want to set a Report-Only header, opt-out of the default enforced header for clarity. The configuration will assume that if you only supply `csp_report_only` that you intended to opt-out of `csp` but that's for the sake of backwards compatibility and it will be removed in the future. + +```ruby +Configuration.default do |config| + config.csp = SecureHeaders::OPT_OUT # If this line is omitted, we will assume you meant to opt out. + config.csp_report_only = { + default_src: %w('self') + } +end +``` + +If ** ## Named Appends diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index ddfdb106..a5112ffe 100644 --- a/lib/secure_headers.rb +++ b/lib/secure_headers.rb @@ -14,18 +14,43 @@ require "secure_headers/railtie" require "secure_headers/view_helper" require "useragent" +require "singleton" # All headers (except for hpkp) have a default value. Provide SecureHeaders::OPT_OUT # or ":optout_of_protection" as a config value to disable a given header module SecureHeaders - OPT_OUT = :opt_out_of_protection + class NoOpHeaderConfig + include Singleton + + def boom(arg = nil) + raise "Illegal State: attempted to modify NoOpHeaderConfig. Create a new config instead." + end + + def to_h + {} + end + + def dup + self.class.instance + end + + def opt_out? + true + end + + alias_method :[], :boom + alias_method :[]=, :boom + alias_method :keys, :boom + end + + OPT_OUT = NoOpHeaderConfig.instance SECURE_HEADERS_CONFIG = "secure_headers_request_config".freeze NONCE_KEY = "secure_headers_content_security_policy_nonce".freeze HTTPS = "https".freeze - CSP = ContentSecurityPolicy ALL_HEADER_CLASSES = [ - ContentSecurityPolicy, + ContentSecurityPolicyConfig, + ContentSecurityPolicyReportOnlyConfig, StrictTransportSecurity, PublicKeyPins, ReferrerPolicy, @@ -36,7 +61,10 @@ module SecureHeaders XXssProtection ].freeze - ALL_HEADERS_BESIDES_CSP = (ALL_HEADER_CLASSES - [CSP]).freeze + ALL_HEADERS_BESIDES_CSP = ( + ALL_HEADER_CLASSES - + [ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig] + ).freeze # Headers set on http requests (excludes STS and HPKP) HTTP_HEADER_CLASSES = @@ -50,13 +78,25 @@ class << self # # additions - a hash containing directives. e.g. # script_src: %w(another-host.com) - def override_content_security_policy_directives(request, additions) - config = config_for(request) - if config.current_csp == OPT_OUT - config.dynamic_csp = {} + def override_content_security_policy_directives(request, additions, target = nil) + config, target = config_and_target(request, target) + + if [:both, :enforced].include?(target) + if config.csp.opt_out? + config.csp = ContentSecurityPolicyConfig.new({}) + end + + config.csp.merge!(additions) + end + + if [:both, :report_only].include?(target) + if config.csp_report_only.opt_out? + config.csp_report_only = ContentSecurityPolicyReportOnlyConfig.new({}) + end + + config.csp_report_only.merge!(additions) end - config.dynamic_csp = config.current_csp.merge(additions) override_secure_headers_request_config(request, config) end @@ -66,9 +106,17 @@ def override_content_security_policy_directives(request, additions) # # additions - a hash containing directives. e.g. # script_src: %w(another-host.com) - def append_content_security_policy_directives(request, additions) - config = config_for(request) - config.dynamic_csp = CSP.combine_policies(config.current_csp, additions) + def append_content_security_policy_directives(request, additions, target = nil) + config, target = config_and_target(request, target) + + if [:both, :enforced].include?(target) && !config.csp.opt_out? + config.csp.append(additions) + end + + if [:both, :report_only].include?(target) && !config.csp_report_only.opt_out? + config.csp_report_only.append(additions) + end + override_secure_headers_request_config(request, config) end @@ -112,12 +160,28 @@ def opt_out_of_all_protection(request) # returned is meant to be merged into the header value from `@app.call(env)` # in Rack middleware. def header_hash_for(request) - config = config_for(request) - unless ContentSecurityPolicy.idempotent_additions?(config.csp, config.current_csp) - config.rebuild_csp_header_cache!(request.user_agent) + config = config_for(request, prevent_dup = true) + headers = config.cached_headers + user_agent = UserAgent.parse(request.user_agent) + + if !config.csp.opt_out? && config.csp.modified? + headers = update_cached_csp(config.csp, headers, user_agent) end - use_cached_headers(config.cached_headers, request) + if !config.csp_report_only.opt_out? && config.csp_report_only.modified? + headers = update_cached_csp(config.csp_report_only, headers, user_agent) + end + + header_classes_for(request).each_with_object({}) do |klass, hash| + if header = headers[klass::CONFIG_KEY] + header_name, value = if [ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig].include?(klass) + csp_header_for_ua(header, user_agent) + else + header + end + hash[header_name] = value + end + end end # Public: specify which named override will be used for this request. @@ -138,7 +202,7 @@ def use_secure_headers_override(request, name) # # Returns the nonce def content_security_policy_script_nonce(request) - content_security_policy_nonce(request, CSP::SCRIPT_SRC) + content_security_policy_nonce(request, ContentSecurityPolicy::SCRIPT_SRC) end # Public: gets or creates a nonce for CSP. @@ -147,7 +211,7 @@ def content_security_policy_script_nonce(request) # # Returns the nonce def content_security_policy_style_nonce(request) - content_security_policy_nonce(request, CSP::STYLE_SRC) + content_security_policy_nonce(request, ContentSecurityPolicy::STYLE_SRC) end # Public: Retreives the config for a given header type: @@ -155,11 +219,15 @@ def content_security_policy_style_nonce(request) # Checks to see if there is an override for this request, then # Checks to see if a named override is used for this request, then # Falls back to the global config - def config_for(request) + def config_for(request, prevent_dup = false) config = request.env[SECURE_HEADERS_CONFIG] || Configuration.get(Configuration::DEFAULT_CONFIG) - if config.frozen? + + # Global configs are frozen, per-request configs are not. When we're not + # making modifications to the config, prevent_dup ensures we don't dup + # the object unnecessarily. It's not necessarily frozen to begin with. + if config.frozen? && !prevent_dup config.dup else config @@ -167,13 +235,38 @@ def config_for(request) end private + TARGETS = [:both, :enforced, :report_only] + def raise_on_unknown_target(target) + unless TARGETS.include?(target) + raise "Unrecognized target: #{target}. Must be [:both, :enforced, :report_only]" + end + end + + def config_and_target(request, target) + config = config_for(request) + target = guess_target(config) unless target + raise_on_unknown_target(target) + [config, target] + end + + def guess_target(config) + if !config.csp.opt_out? && !config.csp_report_only.opt_out? + :both + elsif !config.csp.opt_out? + :enforced + elsif !config.csp_report_only.opt_out? + :report_only + else + :both + end + end # Private: gets or creates a nonce for CSP. # # Returns the nonce def content_security_policy_nonce(request, script_or_style) request.env[NONCE_KEY] ||= SecureRandom.base64(32).chomp - nonce_key = script_or_style == CSP::SCRIPT_SRC ? :script_nonce : :style_nonce + nonce_key = script_or_style == ContentSecurityPolicy::SCRIPT_SRC ? :script_nonce : :style_nonce append_content_security_policy_directives(request, nonce_key => request.env[NONCE_KEY]) request.env[NONCE_KEY] end @@ -198,21 +291,12 @@ def header_classes_for(request) end end - # Private: takes a precomputed hash of headers and returns the Headers - # customized for the request. - # - # Returns a hash of header names / values valid for a given request. - def use_cached_headers(headers, request) - header_classes_for(request).each_with_object({}) do |klass, hash| - if header = headers[klass::CONFIG_KEY] - header_name, value = if klass == CSP - csp_header_for_ua(header, request) - else - header - end - hash[header_name] = value - end - end + def update_cached_csp(config, headers, user_agent) + headers = Configuration.send(:deep_copy, headers) + headers[config.class::CONFIG_KEY] = {} + variation = ContentSecurityPolicy.ua_to_variation(user_agent) + headers[config.class::CONFIG_KEY][variation] = ContentSecurityPolicy.make_header(config, user_agent) + headers end # Private: chooses the applicable CSP header for the provided user agent. @@ -220,26 +304,8 @@ def use_cached_headers(headers, request) # headers - a hash of header_config_key => [header_name, header_value] # # Returns a CSP [header, value] array - def csp_header_for_ua(headers, request) - headers[CSP.ua_to_variation(UserAgent.parse(request.user_agent))] - end - - # Private: optionally build a header with a given configure - # - # klass - corresponding Class for a given header - # config - A string, symbol, or hash config for the header - # user_agent - A string representing the UA (only used for CSP feature sniffing) - # - # Returns a 2 element array [header_name, header_value] or nil if config - # is OPT_OUT - def make_header(klass, header_config, user_agent = nil) - unless header_config == OPT_OUT - if klass == CSP - klass.make_header(header_config, user_agent) - else - klass.make_header(header_config) - end - end + def csp_header_for_ua(headers, user_agent) + headers[ContentSecurityPolicy.ua_to_variation(user_agent)] end end diff --git a/lib/secure_headers/configuration.rb b/lib/secure_headers/configuration.rb index 174dc07c..bf459ae3 100644 --- a/lib/secure_headers/configuration.rb +++ b/lib/secure_headers/configuration.rb @@ -85,7 +85,6 @@ def add_noop_configuration ALL_HEADER_CLASSES.each do |klass| config.send("#{klass::CONFIG_KEY}=", OPT_OUT) end - config.dynamic_csp = OPT_OUT end add_configuration(NOOP_CONFIGURATION, noop_config) @@ -94,6 +93,7 @@ def add_noop_configuration # Public: perform a basic deep dup. The shallow copy provided by dup/clone # can lead to modifying parent objects. def deep_copy(config) + return unless config config.each_with_object({}) do |(key, value), hash| hash[key] = if value.is_a?(Array) value.dup @@ -114,13 +114,11 @@ def deep_copy_if_hash(value) end end - attr_accessor :dynamic_csp - attr_writer :hsts, :x_frame_options, :x_content_type_options, :x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies, :referrer_policy - attr_reader :cached_headers, :csp, :cookies, :hpkp, :hpkp_report_host + attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml" if File.exists?(HASH_CONFIG_FILE) @@ -132,7 +130,8 @@ def deep_copy_if_hash(value) def initialize(&block) self.hpkp = OPT_OUT self.referrer_policy = OPT_OUT - self.csp = self.class.send(:deep_copy, CSP::DEFAULT_CONFIG) + self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT) + self.csp_report_only = OPT_OUT instance_eval &block if block_given? end @@ -142,8 +141,8 @@ def initialize(&block) def dup copy = self.class.new copy.cookies = @cookies - copy.csp = self.class.send(:deep_copy_if_hash, @csp) - copy.dynamic_csp = self.class.send(:deep_copy_if_hash, @dynamic_csp) + copy.csp = @csp.dup if @csp + copy.csp_report_only = @csp_report_only.dup if @csp_report_only copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers) copy.x_content_type_options = @x_content_type_options copy.hsts = @hsts @@ -159,9 +158,6 @@ def dup def opt_out(header) send("#{header}=", OPT_OUT) - if header == CSP::CONFIG_KEY - dynamic_csp = OPT_OUT - end self.cached_headers.delete(header) end @@ -170,20 +166,6 @@ def update_x_frame_options(value) self.cached_headers[XFrameOptions::CONFIG_KEY] = XFrameOptions.make_header(value) end - # Public: generated cached headers for a specific user agent. - def rebuild_csp_header_cache!(user_agent) - self.cached_headers[CSP::CONFIG_KEY] = {} - unless current_csp == OPT_OUT - user_agent = UserAgent.parse(user_agent) - variation = CSP.ua_to_variation(user_agent) - self.cached_headers[CSP::CONFIG_KEY][variation] = CSP.make_header(current_csp, user_agent) - end - end - - def current_csp - @dynamic_csp || @csp - end - # Public: validates all configurations values. # # Raises various configuration errors if any invalid config is detected. @@ -192,6 +174,7 @@ def current_csp def validate_config! StrictTransportSecurity.validate_config!(@hsts) ContentSecurityPolicy.validate_config!(@csp) + ContentSecurityPolicy.validate_config!(@csp_report_only) ReferrerPolicy.validate_config!(@referrer_policy) XFrameOptions.validate_config!(@x_frame_options) XContentTypeOptions.validate_config!(@x_content_type_options) @@ -207,16 +190,50 @@ def secure_cookies=(secure_cookies) @cookies = (@cookies || {}).merge(secure: secure_cookies) end - protected - def csp=(new_csp) - if self.dynamic_csp - raise IllegalPolicyModificationError, "You are attempting to modify CSP settings directly. Use dynamic_csp= instead." + if new_csp.respond_to?(:opt_out?) + @csp = new_csp.dup + else + if new_csp[:report_only] + # Deprecated configuration implies that CSPRO should be set, CSP should not - so opt out + Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#csp=` was supplied a config with report_only: true. Use #csp_report_only=" + @csp = OPT_OUT + self.csp_report_only = new_csp + else + @csp = ContentSecurityPolicyConfig.new(new_csp) + end + end + end + + # Configures the Content-Security-Policy-Report-Only header. `new_csp` cannot + # contain `report_only: false` or an error will be raised. + # + # NOTE: if csp has not been configured/has the default value when + # configuring csp_report_only, the code will assume you mean to only use + # report-only mode and you will be opted-out of enforce mode. + def csp_report_only=(new_csp) + @csp_report_only = begin + if new_csp.is_a?(ContentSecurityPolicyConfig) + new_csp.make_report_only + elsif new_csp.respond_to?(:opt_out?) + new_csp.dup + else + if new_csp[:report_only] == false # nil is a valid value on which we do not want to raise + raise ContentSecurityPolicyConfigError, "`#csp_report_only=` was supplied a config with report_only: false. Use #csp=" + else + ContentSecurityPolicyReportOnlyConfig.new(new_csp) + end + end end - @csp = self.class.send(:deep_copy_if_hash, new_csp) + if !@csp_report_only.opt_out? && @csp.to_h == ContentSecurityPolicyConfig::DEFAULT + Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#csp_report_only=` was configured before `#csp=`. It is assumed you intended to opt out of `#csp=` so be sure to add `config.csp = SecureHeaders::OPT_OUT` to your config. Ensure that #csp_report_only is configured after #csp=" + @csp = OPT_OUT + end end + protected + def cookies=(cookies) @cookies = cookies end @@ -269,12 +286,16 @@ def cache_headers! # # Returns nothing def generate_csp_headers(headers) - unless @csp == OPT_OUT - headers[CSP::CONFIG_KEY] = {} - csp_config = self.current_csp - CSP::VARIATIONS.each do |name, _| - csp = CSP.make_header(csp_config, UserAgent.parse(name)) - headers[CSP::CONFIG_KEY][name] = csp.freeze + generate_csp_headers_for_config(headers, ContentSecurityPolicyConfig::CONFIG_KEY, self.csp) + generate_csp_headers_for_config(headers, ContentSecurityPolicyReportOnlyConfig::CONFIG_KEY, self.csp_report_only) + end + + def generate_csp_headers_for_config(headers, header_key, csp_config) + unless csp_config.opt_out? + headers[header_key] = {} + ContentSecurityPolicy::VARIATIONS.each do |name, _| + csp = ContentSecurityPolicy.make_header(csp_config, UserAgent.parse(name)) + headers[header_key][name] = csp.freeze end end end diff --git a/lib/secure_headers/headers/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index efee3428..a7242cc5 100644 --- a/lib/secure_headers/headers/content_security_policy.rb +++ b/lib/secure_headers/headers/content_security_policy.rb @@ -1,8 +1,8 @@ require_relative 'policy_management' +require_relative 'content_security_policy_config' require 'useragent' module SecureHeaders - class ContentSecurityPolicyConfigError < StandardError; end class ContentSecurityPolicy include PolicyManagement @@ -10,28 +10,34 @@ class ContentSecurityPolicy VERSION_46 = ::UserAgent::Version.new("46") def initialize(config = nil, user_agent = OTHER) - @config = Configuration.send(:deep_copy, config || DEFAULT_CONFIG) + @config = if config.is_a?(Hash) + if config[:report_only] + ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG) + else + ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG) + end + elsif config.nil? + ContentSecurityPolicyConfig.new(DEFAULT_CONFIG) + else + config + end + @parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base) user_agent else UserAgent.parse(user_agent) end - normalize_child_frame_src - @report_only = @config[:report_only] - @preserve_schemes = @config[:preserve_schemes] - @script_nonce = @config[:script_nonce] - @style_nonce = @config[:style_nonce] + @frame_src = normalize_child_frame_src + @preserve_schemes = @config.preserve_schemes + @script_nonce = @config.script_nonce + @style_nonce = @config.style_nonce end ## # Returns the name to use for the header. Either "Content-Security-Policy" or # "Content-Security-Policy-Report-Only" def name - if @report_only - REPORT_ONLY - else - HEADER_NAME - end + @config.class.const_get(:HEADER_NAME) end ## @@ -49,16 +55,16 @@ def value # frame-src is deprecated, child-src is being implemented. They are # very similar and in most cases, the same value can be used for both. def normalize_child_frame_src - if @config[:frame_src] && @config[:child_src] && @config[:frame_src] != @config[:child_src] + if @config.frame_src && @config.child_src && @config.frame_src != @config.child_src Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] both :child_src and :frame_src supplied and do not match. This can lead to inconsistent behavior across browsers.") - elsif @config[:frame_src] - Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config[:frame_src]}.") + elsif @config.frame_src + Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config.frame_src}.") end if supported_directives.include?(:child_src) - @config[:child_src] = @config[:child_src] || @config[:frame_src] + @config.child_src || @config.frame_src else - @config[:frame_src] = @config[:frame_src] || @config[:child_src] + @config.frame_src || @config.child_src end end @@ -73,9 +79,9 @@ def build_value directives.map do |directive_name| case DIRECTIVE_VALUE_TYPES[directive_name] when :boolean - symbol_to_hyphen_case(directive_name) if @config[directive_name] + symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name) when :string - [symbol_to_hyphen_case(directive_name), @config[directive_name]].join(" ") + [symbol_to_hyphen_case(directive_name), @config.directive_value(directive_name)].join(" ") else build_directive(directive_name) end @@ -88,11 +94,19 @@ def build_value # # Returns a string representing a directive. def build_directive(directive) - return if @config[directive].nil? - - source_list = @config[directive].compact - return if source_list.empty? - + source_list = case directive + when :child_src + if supported_directives.include?(:child_src) + @frame_src + end + when :frame_src + unless supported_directives.include?(:child_src) + @frame_src + end + else + @config.directive_value(directive) + end + return unless source_list && source_list.any? normalized_source_list = minify_source_list(directive, source_list) [symbol_to_hyphen_case(directive), normalized_source_list].join(" ") end @@ -101,16 +115,17 @@ def build_directive(directive) # If a directive contains 'none' but has other values, 'none' is ommitted. # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression) def minify_source_list(directive, source_list) + source_list = source_list.compact if source_list.include?(STAR) keep_wildcard_sources(source_list) else - populate_nonces!(directive, source_list) - reject_all_values_if_none!(source_list) + source_list = populate_nonces(directive, source_list) + source_list = reject_all_values_if_none(source_list) unless directive == REPORT_URI || @preserve_schemes - strip_source_schemes!(source_list) + source_list = strip_source_schemes(source_list) end - dedup_source_list(source_list).join(" ") + dedup_source_list(source_list) end end @@ -120,8 +135,12 @@ def keep_wildcard_sources(source_list) end # Discard any 'none' values if more directives are supplied since none may override values. - def reject_all_values_if_none!(source_list) - source_list.reject! { |value| value == NONE } if source_list.length > 1 + def reject_all_values_if_none(source_list) + if source_list.length > 1 + source_list.reject { |value| value == NONE } + else + source_list + end end # Removes duplicates and sources that already match an existing wild card. @@ -143,12 +162,14 @@ def dedup_source_list(sources) # Private: append a nonce to the script/style directories if script_nonce # or style_nonce are provided. - def populate_nonces!(directive, source_list) + def populate_nonces(directive, source_list) case directive when SCRIPT_SRC append_nonce(source_list, @script_nonce) when STYLE_SRC append_nonce(source_list, @style_nonce) + else + source_list end end @@ -165,19 +186,23 @@ def append_nonce(source_list, nonce) source_list << UNSAFE_INLINE end end + + source_list end # Private: return the list of directives that are supported by the user agent, # starting with default-src and ending with report-uri. def directives - [DEFAULT_SRC, + [ + DEFAULT_SRC, BODY_DIRECTIVES.select { |key| supported_directives.include?(key) }, - REPORT_URI].flatten.select { |directive| @config.key?(directive) } + REPORT_URI + ].flatten end # Private: Remove scheme from source expressions. - def strip_source_schemes!(source_list) - source_list.map! { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") } + def strip_source_schemes(source_list) + source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") } end # Private: determine which directives are supported for the given user agent. diff --git a/lib/secure_headers/headers/content_security_policy_config.rb b/lib/secure_headers/headers/content_security_policy_config.rb new file mode 100644 index 00000000..52d5c673 --- /dev/null +++ b/lib/secure_headers/headers/content_security_policy_config.rb @@ -0,0 +1,128 @@ +module SecureHeaders + module DynamicConfig + def self.included(base) + base.send(:attr_writer, :modified) + base.send(:attr_reader, *base.attrs) + base.attrs.each do |attr| + base.send(:define_method, "#{attr}=") do |value| + if self.class.attrs.include?(attr) + value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list + prev_value = self.instance_variable_get("@#{attr}") + self.instance_variable_set("@#{attr}", value) + if prev_value != self.instance_variable_get("@#{attr}") + @modified = true + end + else + raise ContentSecurityPolicyConfigError, "Unknown config directive: #{attr}=#{value}" + end + end + end + end + + def initialize(hash) + from_hash(hash) + @modified = false + end + + def update_directive(directive, value) + self.send("#{directive}=", value) + end + + def directive_value(directive) + if self.class.attrs.include?(directive) + self.send(directive) + end + end + + def modified? + @modified + end + + def merge(new_hash) + ContentSecurityPolicy.combine_policies(self.to_h, new_hash) + end + + def merge!(new_hash) + from_hash(new_hash) + end + + def append(new_hash) + from_hash(ContentSecurityPolicy.combine_policies(self.to_h, new_hash)) + end + + def to_h + self.class.attrs.each_with_object({}) do |key, hash| + hash[key] = self.send(key) + end.reject { |_, v| v.nil? } + end + + def dup + self.class.new(self.to_h) + end + + def opt_out? + false + end + + def ==(o) + self.class == o.class && self.to_h == o.to_h + end + + alias_method :[], :directive_value + alias_method :[]=, :update_directive + + private + def from_hash(hash) + hash.keys.reject { |k| hash[k].nil? }.map do |k| + if self.class.attrs.include?(k) + self.send("#{k}=", hash[k]) + else + raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{hash[k]}" + end + end + end + end + + class ContentSecurityPolicyConfigError < StandardError; end + class ContentSecurityPolicyConfig + CONFIG_KEY = :csp + HEADER_NAME = "Content-Security-Policy".freeze + + def self.attrs + PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES + end + + include DynamicConfig + + # based on what was suggested in https://github.com/rails/rails/pull/24961/files + DEFAULT = { + default_src: %w('self' https:), + font_src: %w('self' https: data:), + img_src: %w('self' https: data:), + object_src: %w('none'), + script_src: %w(https:), + style_src: %w('self' https: 'unsafe-inline') + } + + def report_only? + false + end + + def make_report_only + ContentSecurityPolicyReportOnlyConfig.new(self.to_h) + end + end + + class ContentSecurityPolicyReportOnlyConfig < ContentSecurityPolicyConfig + CONFIG_KEY = :csp_report_only + HEADER_NAME = "Content-Security-Policy-Report-Only".freeze + + def report_only? + true + end + + def make_report_only + self + end + end +end diff --git a/lib/secure_headers/headers/policy_management.rb b/lib/secure_headers/headers/policy_management.rb index f7b49932..e52eda3a 100644 --- a/lib/secure_headers/headers/policy_management.rb +++ b/lib/secure_headers/headers/policy_management.rb @@ -7,9 +7,6 @@ def self.included(base) MODERN_BROWSERS = %w(Chrome Opera Firefox) DEFAULT_VALUE = "default-src https:".freeze DEFAULT_CONFIG = { default_src: %w(https:) }.freeze - HEADER_NAME = "Content-Security-Policy".freeze - REPORT_ONLY = "Content-Security-Policy-Report-Only".freeze - HEADER_NAMES = [HEADER_NAME, REPORT_ONLY] DATA_PROTOCOL = "data:".freeze BLOB_PROTOCOL = "blob:".freeze SELF = "'self'".freeze @@ -158,13 +155,13 @@ def self.included(base) PLUGIN_TYPES => :source_list, REFLECTED_XSS => :string, REPORT_URI => :source_list, - SANDBOX => :string, + SANDBOX => :source_list, SCRIPT_SRC => :source_list, STYLE_SRC => :source_list, UPGRADE_INSECURE_REQUESTS => :boolean }.freeze - CONFIG_KEY = :csp + STAR_REGEXP = Regexp.new(Regexp.escape(STAR)) HTTP_SCHEME_REGEX = %r{\Ahttps?://} @@ -181,6 +178,11 @@ def self.included(base) :preserve_schemes ].freeze + NONCES = [ + :script_nonce, + :style_nonce + ].freeze + module ClassMethods # Public: generate a header name, value array that is user-agent-aware. # @@ -196,9 +198,11 @@ def make_header(config, user_agent) # Does not validate the invididual values of the source expression (e.g. # script_src => h*t*t*p: will not raise an exception) def validate_config!(config) - return if config.nil? || config == OPT_OUT - raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config[:default_src] - config.each do |key, value| + return if config.nil? || config.opt_out? + raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config.directive_value(:default_src) + ContentSecurityPolicyConfig.attrs.each do |key| + value = config.directive_value(key) + next unless value if META_CONFIGS.include?(key) raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil? else @@ -207,18 +211,6 @@ def validate_config!(config) end end - # Public: determine if merging +additions+ will cause a change to the - # actual value of the config. - # - # e.g. config = { script_src: %w(example.org google.com)} and - # additions = { script_src: %w(google.com)} then idempotent_additions? would return - # because google.com is already in the config. - def idempotent_additions?(config, additions) - return true if config == OPT_OUT && additions == OPT_OUT - return false if config == OPT_OUT - config == combine_policies(config, additions) - end - # Public: combine the values from two different configs. # # original - the main config @@ -233,7 +225,7 @@ def idempotent_additions?(config, additions) # 3. if a value in additions does exist in the original config, the two # values are joined. def combine_policies(original, additions) - if original == OPT_OUT + if original == {} raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.") end diff --git a/spec/lib/secure_headers/configuration_spec.rb b/spec/lib/secure_headers/configuration_spec.rb index 0f82c50e..40b285e5 100644 --- a/spec/lib/secure_headers/configuration_spec.rb +++ b/spec/lib/secure_headers/configuration_spec.rb @@ -36,8 +36,8 @@ module SecureHeaders config = Configuration.get(:test_override) noop = Configuration.get(Configuration::NOOP_CONFIGURATION) - [:csp, :dynamic_csp, :cookies].each do |key| - expect(config.send(key)).to eq(noop.send(key)), "Value not copied: #{key}." + [:csp, :csp_report_only, :cookies].each do |key| + expect(config.send(key)).to eq(noop.send(key)) end end @@ -65,7 +65,7 @@ module SecureHeaders default = Configuration.get override = Configuration.get(:override) - expect(override.csp).not_to eq(default.csp) + expect(override.csp.directive_value(:default_src)).not_to be(default.csp.directive_value(:default_src)) end it "allows you to override an override" do @@ -78,9 +78,9 @@ module SecureHeaders end original_override = Configuration.get(:override) - expect(original_override.csp).to eq(default_src: %w('self')) + expect(original_override.csp.to_h).to eq(default_src: %w('self')) override_config = Configuration.get(:second_override) - expect(override_config.csp).to eq(default_src: %w('self'), script_src: %w(example.org)) + expect(override_config.csp.to_h).to eq(default_src: %w('self'), script_src: %w('self' example.org)) end it "deprecates the secure_cookies configuration" do diff --git a/spec/lib/secure_headers/headers/content_security_policy_spec.rb b/spec/lib/secure_headers/headers/content_security_policy_spec.rb index 1e30f62b..ef0fd8ce 100644 --- a/spec/lib/secure_headers/headers/content_security_policy_spec.rb +++ b/spec/lib/secure_headers/headers/content_security_policy_spec.rb @@ -14,11 +14,11 @@ module SecureHeaders describe "#name" do context "when in report-only mode" do - specify { expect(ContentSecurityPolicy.new(default_opts.merge(report_only: true)).name).to eq(ContentSecurityPolicy::HEADER_NAME + "-Report-Only") } + specify { expect(ContentSecurityPolicy.new(default_opts.merge(report_only: true)).name).to eq(ContentSecurityPolicyReportOnlyConfig::HEADER_NAME) } end context "when in enforce mode" do - specify { expect(ContentSecurityPolicy.new(default_opts).name).to eq(ContentSecurityPolicy::HEADER_NAME) } + specify { expect(ContentSecurityPolicy.new(default_opts).name).to eq(ContentSecurityPolicyConfig::HEADER_NAME) } end end diff --git a/spec/lib/secure_headers/headers/policy_management_spec.rb b/spec/lib/secure_headers/headers/policy_management_spec.rb index 8ead3137..f711d5f0 100644 --- a/spec/lib/secure_headers/headers/policy_management_spec.rb +++ b/spec/lib/secure_headers/headers/policy_management_spec.rb @@ -40,70 +40,75 @@ module SecureHeaders report_uri: %w(https://example.com/uri-directive) } - CSP.validate_config!(config) + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(config)) end it "requires a :default_src value" do expect do - CSP.validate_config!(script_src: %('self')) + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(script_src: %('self'))) end.to raise_error(ContentSecurityPolicyConfigError) end it "requires :report_only to be a truthy value" do expect do - CSP.validate_config!(default_opts.merge(report_only: "steve")) + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_only: "steve"))) end.to raise_error(ContentSecurityPolicyConfigError) end it "requires :preserve_schemes to be a truthy value" do expect do - CSP.validate_config!(default_opts.merge(preserve_schemes: "steve")) + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(preserve_schemes: "steve"))) end.to raise_error(ContentSecurityPolicyConfigError) end it "requires :block_all_mixed_content to be a boolean value" do expect do - CSP.validate_config!(default_opts.merge(block_all_mixed_content: "steve")) + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(block_all_mixed_content: "steve"))) end.to raise_error(ContentSecurityPolicyConfigError) end it "requires :upgrade_insecure_requests to be a boolean value" do expect do - CSP.validate_config!(default_opts.merge(upgrade_insecure_requests: "steve")) + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(upgrade_insecure_requests: "steve"))) end.to raise_error(ContentSecurityPolicyConfigError) end it "requires all source lists to be an array of strings" do expect do - CSP.validate_config!(default_src: "steve") + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: "steve")) end.to raise_error(ContentSecurityPolicyConfigError) end it "allows nil values" do expect do - CSP.validate_config!(default_src: %w('self'), script_src: ["https:", nil]) + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w('self'), script_src: ["https:", nil])) end.to_not raise_error end it "rejects unknown directives / config" do expect do - CSP.validate_config!(default_src: %w('self'), default_src_totally_mispelled: "steve") + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w('self'), default_src_totally_mispelled: "steve")) end.to raise_error(ContentSecurityPolicyConfigError) end # this is mostly to ensure people don't use the antiquated shorthands common in other configs it "performs light validation on source lists" do expect do - CSP.validate_config!(default_src: %w(self none inline eval)) + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w(self none inline eval))) end.to raise_error(ContentSecurityPolicyConfigError) end end describe "#combine_policies" do it "combines the default-src value with the override if the directive was unconfigured" do - combined_config = CSP.combine_policies(Configuration.default.csp, script_src: %w(anothercdn.com)) + Configuration.default do |config| + config.csp = { + default_src: %w(https:) + } + end + combined_config = ContentSecurityPolicy.combine_policies(Configuration.get.csp.to_h, script_src: %w(anothercdn.com)) csp = ContentSecurityPolicy.new(combined_config) - expect(csp.name).to eq(CSP::HEADER_NAME) + expect(csp.name).to eq(ContentSecurityPolicyConfig::HEADER_NAME) expect(csp.value).to eq("default-src https:; script-src https: anothercdn.com") end @@ -115,7 +120,7 @@ module SecureHeaders }.freeze end report_uri = "https://report-uri.io/asdf" - combined_config = CSP.combine_policies(Configuration.get.csp, report_uri: [report_uri]) + combined_config = ContentSecurityPolicy.combine_policies(Configuration.get.csp.to_h, report_uri: [report_uri]) csp = ContentSecurityPolicy.new(combined_config, USER_AGENTS[:firefox]) expect(csp.value).to include("report-uri #{report_uri}") end @@ -127,12 +132,12 @@ module SecureHeaders report_only: false }.freeze end - non_default_source_additions = CSP::NON_FETCH_SOURCES.each_with_object({}) do |directive, hash| + non_default_source_additions = ContentSecurityPolicy::NON_FETCH_SOURCES.each_with_object({}) do |directive, hash| hash[directive] = %w("http://example.org) end - combined_config = CSP.combine_policies(Configuration.get.csp, non_default_source_additions) + combined_config = ContentSecurityPolicy.combine_policies(Configuration.get.csp.to_h, non_default_source_additions) - CSP::NON_FETCH_SOURCES.each do |directive| + ContentSecurityPolicy::NON_FETCH_SOURCES.each do |directive| expect(combined_config[directive]).to eq(%w("http://example.org)) end @@ -146,9 +151,9 @@ module SecureHeaders report_only: false } end - combined_config = CSP.combine_policies(Configuration.get.csp, report_only: true) + combined_config = ContentSecurityPolicy.combine_policies(Configuration.get.csp.to_h, report_only: true) csp = ContentSecurityPolicy.new(combined_config, USER_AGENTS[:firefox]) - expect(csp.name).to eq(CSP::REPORT_ONLY) + expect(csp.name).to eq(ContentSecurityPolicyReportOnlyConfig::HEADER_NAME) end it "overrides the :block_all_mixed_content flag" do @@ -158,7 +163,7 @@ module SecureHeaders block_all_mixed_content: false } end - combined_config = CSP.combine_policies(Configuration.get.csp, block_all_mixed_content: true) + combined_config = ContentSecurityPolicy.combine_policies(Configuration.get.csp.to_h, block_all_mixed_content: true) csp = ContentSecurityPolicy.new(combined_config) expect(csp.value).to eq("default-src https:; block-all-mixed-content") end @@ -168,23 +173,9 @@ module SecureHeaders config.csp = OPT_OUT end expect do - CSP.combine_policies(Configuration.get.csp, script_src: %w(anothercdn.com)) + ContentSecurityPolicy.combine_policies(Configuration.get.csp.to_h, script_src: %w(anothercdn.com)) end.to raise_error(ContentSecurityPolicyConfigError) end end - - describe "#idempotent_additions?" do - specify { expect(ContentSecurityPolicy.idempotent_additions?(OPT_OUT, script_src: %w(b.com))).to be false } - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w(c.com))).to be false } - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, style_src: %w(b.com))).to be false } - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w(a.com b.com c.com))).to be false } - - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w(b.com))).to be true } - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w(b.com a.com))).to be true } - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w())).to be true } - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: [nil])).to be true } - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, style_src: [nil])).to be true } - specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, style_src: nil)).to be true } - end end end diff --git a/spec/lib/secure_headers/middleware_spec.rb b/spec/lib/secure_headers/middleware_spec.rb index a84e288e..13fbf8c9 100644 --- a/spec/lib/secure_headers/middleware_spec.rb +++ b/spec/lib/secure_headers/middleware_spec.rb @@ -51,7 +51,7 @@ module SecureHeaders SecureHeaders.use_secure_headers_override(request, "my_custom_config") expect(request.env[SECURE_HEADERS_CONFIG]).to be(Configuration.get("my_custom_config")) _, env = middleware.call request.env - expect(env[CSP::HEADER_NAME]).to match("example.org") + expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match("example.org") end context "secure_cookies" do diff --git a/spec/lib/secure_headers/view_helpers_spec.rb b/spec/lib/secure_headers/view_helpers_spec.rb index 2d8c432a..17723853 100644 --- a/spec/lib/secure_headers/view_helpers_spec.rb +++ b/spec/lib/secure_headers/view_helpers_spec.rb @@ -72,8 +72,11 @@ module SecureHeaders before(:all) do Configuration.default do |config| - config.csp[:script_src] = %w('self') - config.csp[:style_src] = %w('self') + config.csp = { + :default_src => %w('self'), + :script_src => %w('self'), + :style_src => %w('self') + } end end @@ -115,10 +118,10 @@ module SecureHeaders Message.new(request).result _, env = middleware.call request.env - expect(env[CSP::HEADER_NAME]).to match(/script-src[^;]*'#{Regexp.escape(expected_hash)}'/) - expect(env[CSP::HEADER_NAME]).to match(/script-src[^;]*'nonce-abc123'/) - expect(env[CSP::HEADER_NAME]).to match(/style-src[^;]*'nonce-abc123'/) - expect(env[CSP::HEADER_NAME]).to match(/style-src[^;]*'#{Regexp.escape(expected_style_hash)}'/) + expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/script-src[^;]*'#{Regexp.escape(expected_hash)}'/) + expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/script-src[^;]*'nonce-abc123'/) + expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/style-src[^;]*'nonce-abc123'/) + expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/style-src[^;]*'#{Regexp.escape(expected_style_hash)}'/) end end end diff --git a/spec/lib/secure_headers_spec.rb b/spec/lib/secure_headers_spec.rb index 1509401d..30279e5e 100644 --- a/spec/lib/secure_headers_spec.rb +++ b/spec/lib/secure_headers_spec.rb @@ -16,7 +16,7 @@ module SecureHeaders it "raises a NotYetConfiguredError if trying to opt-out of unconfigured headers" do expect do - SecureHeaders.opt_out_of_header(request, CSP::CONFIG_KEY) + SecureHeaders.opt_out_of_header(request, ContentSecurityPolicyConfig::CONFIG_KEY) end.to raise_error(Configuration::NotYetConfiguredError) end @@ -29,8 +29,12 @@ module SecureHeaders describe "#header_hash_for" do it "allows you to opt out of individual headers via API" do - Configuration.default - SecureHeaders.opt_out_of_header(request, CSP::CONFIG_KEY) + Configuration.default do |config| + config.csp = { default_src: %w('self')} + config.csp_report_only = config.csp + end + SecureHeaders.opt_out_of_header(request, ContentSecurityPolicyConfig::CONFIG_KEY) + SecureHeaders.opt_out_of_header(request, ContentSecurityPolicyReportOnlyConfig::CONFIG_KEY) SecureHeaders.opt_out_of_header(request, XContentTypeOptions::CONFIG_KEY) hash = SecureHeaders.header_hash_for(request) expect(hash['Content-Security-Policy-Report-Only']).to be_nil @@ -56,7 +60,21 @@ module SecureHeaders end it "allows you to opt out entirely" do - Configuration.default + # configure the disabled-by-default headers to ensure they also do not get set + Configuration.default do |config| + config.csp = { :default_src => ["example.com"] } + config.csp_report_only = config.csp + config.hpkp = { + report_only: false, + max_age: 10000000, + include_subdomains: true, + report_uri: "https://report-uri.io/example-hpkp", + pins: [ + {sha256: "abc"}, + {sha256: "123"} + ] + } + end SecureHeaders.opt_out_of_all_protection(request) hash = SecureHeaders.header_hash_for(request) ALL_HEADER_CLASSES.each do |klass| @@ -82,7 +100,7 @@ module SecureHeaders SecureHeaders.override_content_security_policy_directives(request, default_src: %w(https:), script_src: %w('self')) hash = SecureHeaders.header_hash_for(request) - expect(hash[CSP::HEADER_NAME]).to eq("default-src https:; script-src 'self'") + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src https:; script-src 'self'") expect(hash[XFrameOptions::HEADER_NAME]).to eq(XFrameOptions::SAMEORIGIN) end @@ -96,14 +114,14 @@ module SecureHeaders firefox_request = Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:firefox])) # append an unsupported directive - SecureHeaders.override_content_security_policy_directives(firefox_request, plugin_types: %w(flash)) + SecureHeaders.override_content_security_policy_directives(firefox_request, {plugin_types: %w(flash)}) # append a supported directive - SecureHeaders.override_content_security_policy_directives(firefox_request, script_src: %w('self')) + SecureHeaders.override_content_security_policy_directives(firefox_request, {script_src: %w('self')}) hash = SecureHeaders.header_hash_for(firefox_request) # child-src is translated to frame-src - expect(hash[CSP::HEADER_NAME]).to eq("default-src 'self'; frame-src 'self'; script-src 'self'") + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self'; frame-src 'self'; script-src 'self'") end it "produces a hash of headers with default config" do @@ -152,7 +170,7 @@ module SecureHeaders SecureHeaders.append_content_security_policy_directives(request, script_src: %w(anothercdn.com)) hash = SecureHeaders.header_hash_for(request) - expect(hash[CSP::HEADER_NAME]).to eq("default-src 'self'; script-src mycdn.com 'unsafe-inline' anothercdn.com") + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self'; script-src mycdn.com 'unsafe-inline' anothercdn.com") end it "supports named appends" do @@ -174,7 +192,7 @@ module SecureHeaders SecureHeaders.use_content_security_policy_named_append(request, :how_about_a_script_src_too) hash = SecureHeaders.header_hash_for(request) - expect(hash[CSP::HEADER_NAME]).to eq("default-src 'self' https:; script-src 'self' https: 'unsafe-inline'") + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self' https:; script-src 'self' https: 'unsafe-inline'") end it "appends a nonce to a missing script-src value" do @@ -186,7 +204,7 @@ module SecureHeaders SecureHeaders.content_security_policy_script_nonce(request) # should add the value to the header hash = SecureHeaders.header_hash_for(chrome_request) - expect(hash[CSP::HEADER_NAME]).to match /\Adefault-src 'self'; script-src 'self' 'nonce-.*'\z/ + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to match /\Adefault-src 'self'; script-src 'self' 'nonce-.*'\z/ end it "appends a hash to a missing script-src value" do @@ -198,48 +216,7 @@ module SecureHeaders SecureHeaders.append_content_security_policy_directives(request, script_src: %w('sha256-abc123')) hash = SecureHeaders.header_hash_for(chrome_request) - expect(hash[CSP::HEADER_NAME]).to match /\Adefault-src 'self'; script-src 'self' 'sha256-abc123'\z/ - end - - it "dups global configuration just once when overriding n times and only calls idempotent_additions? once" do - Configuration.default do |config| - config.csp = { - default_src: %w('self') - } - end - - expect(CSP).to receive(:idempotent_additions?).once - - # before an override occurs, the env is empty - expect(request.env[SECURE_HEADERS_CONFIG]).to be_nil - - SecureHeaders.append_content_security_policy_directives(request, script_src: %w(anothercdn.com)) - new_config = SecureHeaders.config_for(request) - expect(new_config).to_not be(Configuration.get) - - SecureHeaders.override_content_security_policy_directives(request, script_src: %w(yet.anothercdn.com)) - current_config = SecureHeaders.config_for(request) - expect(current_config).to be(new_config) - - SecureHeaders.header_hash_for(request) - end - - it "doesn't allow you to muck with csp configs when a dynamic policy is in use" do - default_config = Configuration.default - expect { default_config.csp = {} }.to raise_error(NoMethodError) - - # config is frozen - expect { default_config.send(:csp=, {}) }.to raise_error(RuntimeError) - - SecureHeaders.append_content_security_policy_directives(request, script_src: %w(anothercdn.com)) - new_config = SecureHeaders.config_for(request) - expect { new_config.send(:csp=, {}) }.to raise_error(Configuration::IllegalPolicyModificationError) - - expect do - new_config.instance_eval do - new_config.csp = {} - end - end.to raise_error(Configuration::IllegalPolicyModificationError) + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to match /\Adefault-src 'self'; script-src 'self' 'sha256-abc123'\z/ end it "overrides individual directives" do @@ -250,14 +227,19 @@ module SecureHeaders end SecureHeaders.override_content_security_policy_directives(request, default_src: %w('none')) hash = SecureHeaders.header_hash_for(request) - expect(hash[CSP::HEADER_NAME]).to eq("default-src 'none'") + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'none'") end it "overrides non-existant directives" do - Configuration.default + Configuration.default do |config| + config.csp = { + default_src: %w(https:) + } + end SecureHeaders.override_content_security_policy_directives(request, img_src: [ContentSecurityPolicy::DATA_PROTOCOL]) hash = SecureHeaders.header_hash_for(request) - expect(hash[CSP::HEADER_NAME]).to eq("default-src https:; img-src data:") + expect(hash[ContentSecurityPolicyReportOnlyConfig::HEADER_NAME]).to be_nil + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src https:; img-src data:") end it "does not append a nonce when the browser does not support it" do @@ -272,7 +254,7 @@ module SecureHeaders safari_request = Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:safari5])) nonce = SecureHeaders.content_security_policy_script_nonce(safari_request) hash = SecureHeaders.header_hash_for(safari_request) - expect(hash[CSP::HEADER_NAME]).to eq("default-src 'self'; script-src mycdn.com 'unsafe-inline'; style-src 'self'") + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self'; script-src mycdn.com 'unsafe-inline'; style-src 'self'") end it "appends a nonce to the script-src when used" do @@ -294,6 +276,202 @@ module SecureHeaders hash = SecureHeaders.header_hash_for(chrome_request) expect(hash['Content-Security-Policy']).to eq("default-src 'self'; script-src mycdn.com 'nonce-#{nonce}'; style-src 'self'") end + + it "supports the deprecated `report_only: true` format" do + expect(Kernel).to receive(:warn).once + + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + report_only: true + } + end + + expect(Configuration.get.csp).to eq(OPT_OUT) + expect(Configuration.get.csp_report_only).to be_a(ContentSecurityPolicyReportOnlyConfig) + + hash = SecureHeaders.header_hash_for(request) + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to be_nil + expect(hash[ContentSecurityPolicyReportOnlyConfig::HEADER_NAME]).to eq("default-src 'self'") + end + + it "Raises an error if csp_report_only is used with `report_only: false`" do + expect do + Configuration.default do |config| + config.csp_report_only = { + default_src: %w('self'), + report_only: false + } + end + end.to raise_error(ContentSecurityPolicyConfigError) + end + + context "setting two headers" do + before(:each) do + Configuration.default do |config| + config.csp = { + default_src: %w('self') + } + config.csp_report_only = config.csp + end + end + + it "sets identical values when the configs are the same" do + Configuration.default do |config| + config.csp = { + default_src: %w('self') + } + config.csp_report_only = { + default_src: %w('self') + } + end + + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'") + end + + it "sets different headers when the configs are different" do + Configuration.default do |config| + config.csp = { + default_src: %w('self') + } + config.csp_report_only = config.csp.merge({script_src: %w('self')}) + end + + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'; script-src 'self'") + end + + it "allows you to opt-out of enforced CSP" do + Configuration.default do |config| + config.csp = SecureHeaders::OPT_OUT + config.csp_report_only = { + default_src: %w('self') + } + end + + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to be_nil + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'") + end + + it "opts-out of enforced CSP when only csp_report_only is set" do + expect(Kernel).to receive(:warn).once + Configuration.default do |config| + config.csp_report_only = { + default_src: %w('self') + } + end + + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to be_nil + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'") + end + + it "allows you to set csp_report_only before csp" do + expect(Kernel).to receive(:warn).once + Configuration.default do |config| + config.csp_report_only = { + default_src: %w('self') + } + config.csp = config.csp_report_only.merge({script_src: %w('unsafe-inline')}) + end + + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'; script-src 'self' 'unsafe-inline'") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'") + end + + it "allows appending to the enforced policy" do + SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}, :enforced) + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'; script-src 'self' anothercdn.com") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'") + end + + it "allows appending to the report only policy" do + SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}, :report_only) + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'; script-src 'self' anothercdn.com") + end + + it "allows appending to both policies" do + SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}, :both) + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'; script-src 'self' anothercdn.com") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'; script-src 'self' anothercdn.com") + end + + it "allows overriding the enforced policy" do + SecureHeaders.override_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}, :enforced) + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'; script-src anothercdn.com") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'") + end + + it "allows overriding the report only policy" do + SecureHeaders.override_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}, :report_only) + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'; script-src anothercdn.com") + end + + it "allows overriding both policies" do + SecureHeaders.override_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}, :both) + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'; script-src anothercdn.com") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'; script-src anothercdn.com") + end + + context "when inferring which config to modify" do + it "updates the enforced header when configured" do + Configuration.default do |config| + config.csp = { + default_src: %w('self') + } + end + SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}) + + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src 'self'; script-src 'self' anothercdn.com") + expect(hash['Content-Security-Policy-Report-Only']).to be_nil + end + + it "updates the report only header when configured" do + Configuration.default do |config| + config.csp = OPT_OUT + config.csp_report_only = { + default_src: %w('self') + } + end + SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}) + + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'self'; script-src 'self' anothercdn.com") + expect(hash['Content-Security-Policy']).to be_nil + end + + it "updates both headers if both are configured" do + Configuration.default do |config| + config.csp = { + default_src: %w(enforced.com) + } + config.csp_report_only = { + default_src: %w(reportonly.com) + } + end + SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}) + + hash = SecureHeaders.header_hash_for(request) + expect(hash['Content-Security-Policy']).to eq("default-src enforced.com; script-src enforced.com anothercdn.com") + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src reportonly.com; script-src reportonly.com anothercdn.com") + end + + end + end end end @@ -309,7 +487,7 @@ module SecureHeaders it "validates your csp config upon configuration" do expect do Configuration.default do |config| - config.csp = { CSP::DEFAULT_SRC => '123456' } + config.csp = { ContentSecurityPolicy::DEFAULT_SRC => '123456' } end end.to raise_error(ContentSecurityPolicyConfigError) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9658eb89..20e8eb82 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -25,7 +25,7 @@ } def expect_default_values(hash) - expect(hash[SecureHeaders::CSP::HEADER_NAME]).to eq(SecureHeaders::CSP::DEFAULT_VALUE) + expect(hash[SecureHeaders::ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'") expect(hash[SecureHeaders::XFrameOptions::HEADER_NAME]).to eq(SecureHeaders::XFrameOptions::DEFAULT_VALUE) expect(hash[SecureHeaders::XDownloadOptions::HEADER_NAME]).to eq(SecureHeaders::XDownloadOptions::DEFAULT_VALUE) expect(hash[SecureHeaders::StrictTransportSecurity::HEADER_NAME]).to eq(SecureHeaders::StrictTransportSecurity::DEFAULT_VALUE)