diff --git a/README.md b/README.md index 114cb7b4..0190d834 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,50 @@ SecureHeaders::Configuration.default do |config| img_src: %w(somewhereelse.com), report_uri: %w(https://report-uri.io/example-csp-report-only) }) + + # Optional: Use the modern report-to directive (with Reporting-Endpoints header) + config.csp = config.csp.merge({ + report_to: "csp-endpoint" + }) + + # When using report-to, configure the reporting endpoints header + config.reporting_endpoints = { + "csp-endpoint": "https://report-uri.io/example-csp", + "csp-report-only": "https://report-uri.io/example-csp-report-only" + } end ``` +### CSP Reporting + +SecureHeaders supports both the legacy `report-uri` and the modern `report-to` directives for CSP violation reporting: + +#### report-uri (Legacy) +The `report-uri` directive sends violations to a URL endpoint. It's widely supported but limited to POST requests with JSON payloads. + +```ruby +config.csp = { + default_src: %w('self'), + report_uri: %w(https://example.com/csp-report) +} +``` + +#### report-to (Modern) +The `report-to` directive specifies a named reporting endpoint defined in the `Reporting-Endpoints` header. This enables more flexible reporting through the HTTP Reporting API standard. + +```ruby +config.csp = { + default_src: %w('self'), + report_to: "csp-endpoint" +} + +config.reporting_endpoints = { + "csp-endpoint": "https://example.com/reports" +} +``` + +**Recommendation:** Use both `report-uri` and `report-to` for maximum compatibility while transitioning to the modern approach. + ### Deprecated Configuration Values * `block_all_mixed_content` - this value is deprecated in favor of `upgrade_insecure_requests`. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/block-all-mixed-content for more information. diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index 6426e538..42d30779 100644 --- a/lib/secure_headers.rb +++ b/lib/secure_headers.rb @@ -11,6 +11,7 @@ require "secure_headers/headers/referrer_policy" require "secure_headers/headers/clear_site_data" require "secure_headers/headers/expect_certificate_transparency" +require "secure_headers/headers/reporting_endpoints" require "secure_headers/middleware" require "secure_headers/railtie" require "secure_headers/view_helper" @@ -208,7 +209,7 @@ def raise_on_unknown_target(target) def config_and_target(request, target) config = config_for(request) - target = guess_target(config) unless target + target ||= guess_target(config) raise_on_unknown_target(target) [config, target] end diff --git a/lib/secure_headers/configuration.rb b/lib/secure_headers/configuration.rb index e96f4f9d..1c83263a 100644 --- a/lib/secure_headers/configuration.rb +++ b/lib/secure_headers/configuration.rb @@ -131,6 +131,7 @@ def deep_copy_if_hash(value) csp: ContentSecurityPolicy, csp_report_only: ContentSecurityPolicy, cookies: Cookie, + reporting_endpoints: ReportingEndpoints, }.freeze CONFIG_ATTRIBUTES = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.keys.freeze @@ -167,6 +168,7 @@ def initialize(&block) @x_permitted_cross_domain_policies = nil @x_xss_protection = nil @expect_certificate_transparency = nil + @reporting_endpoints = nil self.referrer_policy = OPT_OUT self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT) @@ -192,6 +194,7 @@ def dup copy.clear_site_data = @clear_site_data copy.expect_certificate_transparency = @expect_certificate_transparency copy.referrer_policy = @referrer_policy + copy.reporting_endpoints = self.class.send(:deep_copy_if_hash, @reporting_endpoints) copy end diff --git a/lib/secure_headers/headers/clear_site_data.rb b/lib/secure_headers/headers/clear_site_data.rb index 0fdc0c2a..05d26a8f 100644 --- a/lib/secure_headers/headers/clear_site_data.rb +++ b/lib/secure_headers/headers/clear_site_data.rb @@ -11,43 +11,41 @@ class ClearSiteData EXECUTION_CONTEXTS = "executionContexts".freeze ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS] - class << self - # Public: make an clear-site-data header name, value pair - # - # Returns nil if not configured, returns header name and value if configured. - def make_header(config = nil, user_agent = nil) - case config - when nil, OPT_OUT, [] - # noop - when Array - [HEADER_NAME, make_header_value(config)] - when true - [HEADER_NAME, make_header_value(ALL_TYPES)] - end + # Public: make an clear-site-data header name, value pair + # + # Returns nil if not configured, returns header name and value if configured. + def self.make_header(config = nil, user_agent = nil) + case config + when nil, OPT_OUT, [] + # noop + when Array + [HEADER_NAME, make_header_value(config)] + when true + [HEADER_NAME, make_header_value(ALL_TYPES)] end + end - def validate_config!(config) - case config - when nil, OPT_OUT, true - # valid - when Array - unless config.all? { |t| t.is_a?(String) } - raise ClearSiteDataConfigError.new("types must be Strings") - end - else - raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`") + def self.validate_config!(config) + case config + when nil, OPT_OUT, true + # valid + when Array + unless config.all? { |t| t.is_a?(String) } + raise ClearSiteDataConfigError.new("types must be Strings") end + else + raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`") end + end - # Public: Transform a clear-site-data config (an Array of Strings) into a - # String that can be used as the value for the clear-site-data header. - # - # types - An Array of String of types of data to clear. - # - # Returns a String of quoted values that are comma separated. - def make_header_value(types) - types.map { |t| %("#{t}") }.join(", ") - end + # Public: Transform a clear-site-data config (an Array of Strings) into a + # String that can be used as the value for the clear-site-data header. + # + # types - An Array of String of types of data to clear. + # + # Returns a String of quoted values that are comma separated. + def self.make_header_value(types) + types.map { |t| %("#{t}") }.join(", ") end end end diff --git a/lib/secure_headers/headers/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index ae225e7c..9d4a0242 100644 --- a/lib/secure_headers/headers/content_security_policy.rb +++ b/lib/secure_headers/headers/content_security_policy.rb @@ -63,6 +63,8 @@ def build_value build_sandbox_list_directive(directive_name) when :media_type_list build_media_type_list_directive(directive_name) + when :report_to_endpoint + build_report_to_directive(directive_name) end end.compact.join("; ") end @@ -100,6 +102,13 @@ def build_media_type_list_directive(directive) end end + def build_report_to_directive(directive) + return unless endpoint_name = @config.directive_value(directive) + if endpoint_name && endpoint_name.is_a?(String) && !endpoint_name.empty? + [symbol_to_hyphen_case(directive), endpoint_name].join(" ") + end + end + # Private: builds a string that represents one directive in a minified form. # # directive_name - a symbol representing the various ALL_DIRECTIVES @@ -179,11 +188,12 @@ def append_nonce(source_list, nonce) end # Private: return the list of directives, - # starting with default-src and ending with report-uri. + # starting with default-src and ending with reporting directives (alphabetically ordered). def directives [ DEFAULT_SRC, BODY_DIRECTIVES, + REPORT_TO, REPORT_URI, ].flatten end diff --git a/lib/secure_headers/headers/cookie.rb b/lib/secure_headers/headers/cookie.rb index 9d3f7cd1..8f78612c 100644 --- a/lib/secure_headers/headers/cookie.rb +++ b/lib/secure_headers/headers/cookie.rb @@ -7,10 +7,8 @@ module SecureHeaders class CookiesConfigError < StandardError; end class Cookie - class << self - def validate_config!(config) - CookiesConfig.new(config).validate! - end + def self.validate_config!(config) + CookiesConfig.new(config).validate! end attr_reader :raw_cookie, :config diff --git a/lib/secure_headers/headers/expect_certificate_transparency.rb b/lib/secure_headers/headers/expect_certificate_transparency.rb index 4b7272dd..582e8a05 100644 --- a/lib/secure_headers/headers/expect_certificate_transparency.rb +++ b/lib/secure_headers/headers/expect_certificate_transparency.rb @@ -9,31 +9,29 @@ class ExpectCertificateTransparency REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze - class << self - # Public: Generate a expect-ct header. - # - # Returns nil if not configured, returns header name and value if - # configured. - def make_header(config, use_agent = nil) - return if config.nil? || config == OPT_OUT + # Public: Generate a expect-ct header. + # + # Returns nil if not configured, returns header name and value if + # configured. + def self.make_header(config, use_agent = nil) + return if config.nil? || config == OPT_OUT - header = new(config) - [HEADER_NAME, header.value] - end + header = new(config) + [HEADER_NAME, header.value] + end - def validate_config!(config) - return if config.nil? || config == OPT_OUT - raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash + def self.validate_config!(config) + return if config.nil? || config == OPT_OUT + raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash - unless [true, false, nil].include?(config[:enforce]) - raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR) - end + unless [true, false, nil].include?(config[:enforce]) + raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR) + end - if !config[:max_age] - raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR) - elsif config[:max_age].to_s !~ /\A\d+\z/ - raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR) - end + if !config[:max_age] + raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR) + elsif config[:max_age].to_s !~ /\A\d+\z/ + raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR) end end diff --git a/lib/secure_headers/headers/policy_management.rb b/lib/secure_headers/headers/policy_management.rb index 3129c0d3..48fe1bdf 100644 --- a/lib/secure_headers/headers/policy_management.rb +++ b/lib/secure_headers/headers/policy_management.rb @@ -39,6 +39,7 @@ def self.included(base) SCRIPT_SRC = :script_src STYLE_SRC = :style_src REPORT_URI = :report_uri + REPORT_TO = :report_to DIRECTIVES_1_0 = [ DEFAULT_SRC, @@ -51,7 +52,8 @@ def self.included(base) SANDBOX, SCRIPT_SRC, STYLE_SRC, - REPORT_URI + REPORT_URI, + REPORT_TO ].freeze BASE_URI = :base_uri @@ -110,9 +112,9 @@ def self.included(base) ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_EXPERIMENTAL).uniq.sort - # Think of default-src and report-uri as the beginning and end respectively, + # Think of default-src and report-uri/report-to as the beginning and end respectively, # everything else is in between. - BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI] + BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI, REPORT_TO] DIRECTIVE_VALUE_TYPES = { BASE_URI => :source_list, @@ -129,10 +131,11 @@ def self.included(base) NAVIGATE_TO => :source_list, OBJECT_SRC => :source_list, PLUGIN_TYPES => :media_type_list, + PREFETCH_SRC => :source_list, + REPORT_TO => :report_to_endpoint, + REPORT_URI => :source_list, REQUIRE_SRI_FOR => :require_sri_for_list, REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list, - REPORT_URI => :source_list, - PREFETCH_SRC => :source_list, SANDBOX => :sandbox_list, SCRIPT_SRC => :source_list, SCRIPT_SRC_ELEM => :source_list, @@ -158,6 +161,7 @@ def self.included(base) FORM_ACTION, FRAME_ANCESTORS, NAVIGATE_TO, + REPORT_TO, REPORT_URI, ] @@ -344,6 +348,8 @@ def validate_directive!(directive, value) validate_require_sri_source_expression!(directive, value) when :require_trusted_types_for_list validate_require_trusted_types_for_source_expression!(directive, value) + when :report_to_endpoint + validate_report_to_endpoint_expression!(directive, value) else raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}") end @@ -398,6 +404,18 @@ def validate_require_trusted_types_for_source_expression!(directive, require_tru end end + # Private: validates that a report-to endpoint expression: + # 1. is a string + # 2. is not empty + def validate_report_to_endpoint_expression!(directive, endpoint_name) + unless endpoint_name.is_a?(String) + raise ContentSecurityPolicyConfigError.new("#{directive} must be a string. Found #{endpoint_name.class} value") + end + if endpoint_name.empty? + raise ContentSecurityPolicyConfigError.new("#{directive} must not be empty") + end + end + # Private: validates that a source expression: # 1. is an array of strings # 2. does not contain any deprecated, now invalid values (inline, eval, self, none) diff --git a/lib/secure_headers/headers/referrer_policy.rb b/lib/secure_headers/headers/referrer_policy.rb index a4589117..2094656c 100644 --- a/lib/secure_headers/headers/referrer_policy.rb +++ b/lib/secure_headers/headers/referrer_policy.rb @@ -15,29 +15,27 @@ class ReferrerPolicy unsafe-url ) - class << self - # Public: generate an Referrer Policy header. - # - # Returns a default header if no configuration is provided, or a - # header name and value based on the config. - def make_header(config = nil, user_agent = nil) - return if config == OPT_OUT - config ||= DEFAULT_VALUE - [HEADER_NAME, Array(config).join(", ")] - end + # Public: generate an Referrer Policy header. + # + # Returns a default header if no configuration is provided, or a + # header name and value based on the config. + def self.make_header(config = nil, user_agent = nil) + return if config == OPT_OUT + config ||= DEFAULT_VALUE + [HEADER_NAME, Array(config).join(", ")] + end - def validate_config!(config) - case config - when nil, OPT_OUT - # valid - when String, Array - config = Array(config) - unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) } - raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}") - end - else - raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}") + def self.validate_config!(config) + case config + when nil, OPT_OUT + # valid + when String, Array + config = Array(config) + unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) } + raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}") end + else + raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}") end end end diff --git a/lib/secure_headers/headers/reporting_endpoints.rb b/lib/secure_headers/headers/reporting_endpoints.rb new file mode 100644 index 00000000..c5b048ff --- /dev/null +++ b/lib/secure_headers/headers/reporting_endpoints.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +module SecureHeaders + class ReportingEndpointsConfigError < StandardError; end + class ReportingEndpoints + HEADER_NAME = "reporting-endpoints".freeze + + class << self + # Public: generate a Reporting-Endpoints header. + # + # The config should be a Hash of endpoint names to URLs. + # Example: { "csp-endpoint" => "https://example.com/reports" } + # + # Returns nil if config is OPT_OUT or nil, or a header name and + # formatted header value based on the config. + def make_header(config = nil) + return if config.nil? || config == OPT_OUT + validate_config!(config) + [HEADER_NAME, format_endpoints(config)] + end + + def validate_config!(config) + case config + when nil, OPT_OUT + # valid + when Hash + config.each_pair do |name, url| + unless name.is_a?(String) && !name.empty? + raise ReportingEndpointsConfigError.new("Endpoint name must be a non-empty string, got: #{name.inspect}") + end + unless url.is_a?(String) && !url.empty? + raise ReportingEndpointsConfigError.new("Endpoint URL must be a non-empty string, got: #{url.inspect}") + end + unless url.start_with?("https://") + raise ReportingEndpointsConfigError.new("Endpoint URLs must use https, got: #{url.inspect}") + end + end + else + raise TypeError.new("Must be a Hash of endpoint names to URLs. Found #{config.class}: #{config}") + end + end + + private + + def format_endpoints(config) + config.map do |name, url| + %{#{name}="#{url}"} + end.join(", ") + end + end + end +end diff --git a/lib/secure_headers/headers/strict_transport_security.rb b/lib/secure_headers/headers/strict_transport_security.rb index 3d78a484..eaf46cb6 100644 --- a/lib/secure_headers/headers/strict_transport_security.rb +++ b/lib/secure_headers/headers/strict_transport_security.rb @@ -9,21 +9,19 @@ class StrictTransportSecurity VALID_STS_HEADER = /\Amax-age=\d+(; includeSubdomains)?(; preload)?\z/i MESSAGE = "The config value supplied for the HSTS header was invalid. Must match #{VALID_STS_HEADER}" - class << self - # Public: generate an hsts header name, value pair. - # - # Returns a default header if no configuration is provided, or a - # header name and value based on the config. - def make_header(config = nil, user_agent = nil) - return if config == OPT_OUT - [HEADER_NAME, config || DEFAULT_VALUE] - end + # Public: generate an hsts header name, value pair. + # + # Returns a default header if no configuration is provided, or a + # header name and value based on the config. + def self.make_header(config = nil, user_agent = nil) + return if config == OPT_OUT + [HEADER_NAME, config || DEFAULT_VALUE] + end - def validate_config!(config) - return if config.nil? || config == OPT_OUT - raise TypeError.new("Must be a string. Found #{config.class}: #{config} #{config.class}") unless config.is_a?(String) - raise STSConfigError.new(MESSAGE) unless config =~ VALID_STS_HEADER - end + def self.validate_config!(config) + return if config.nil? || config == OPT_OUT + raise TypeError.new("Must be a string. Found #{config.class}: #{config} #{config.class}") unless config.is_a?(String) + raise STSConfigError.new(MESSAGE) unless config =~ VALID_STS_HEADER end end end diff --git a/lib/secure_headers/headers/x_content_type_options.rb b/lib/secure_headers/headers/x_content_type_options.rb index 96f8d314..773bad82 100644 --- a/lib/secure_headers/headers/x_content_type_options.rb +++ b/lib/secure_headers/headers/x_content_type_options.rb @@ -6,22 +6,20 @@ class XContentTypeOptions HEADER_NAME = "x-content-type-options".freeze DEFAULT_VALUE = "nosniff" - class << self - # Public: generate an X-Content-Type-Options header. - # - # Returns a default header if no configuration is provided, or a - # header name and value based on the config. - def make_header(config = nil, user_agent = nil) - return if config == OPT_OUT - [HEADER_NAME, config || DEFAULT_VALUE] - end + # Public: generate an X-Content-Type-Options header. + # + # Returns a default header if no configuration is provided, or a + # header name and value based on the config. + def self.make_header(config = nil, user_agent = nil) + return if config == OPT_OUT + [HEADER_NAME, config || DEFAULT_VALUE] + end - def validate_config!(config) - return if config.nil? || config == OPT_OUT - raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) - unless config.casecmp(DEFAULT_VALUE) == 0 - raise XContentTypeOptionsConfigError.new("Value can only be nil or 'nosniff'") - end + def self.validate_config!(config) + return if config.nil? || config == OPT_OUT + raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) + unless config.casecmp(DEFAULT_VALUE) == 0 + raise XContentTypeOptionsConfigError.new("Value can only be nil or 'nosniff'") end end end diff --git a/lib/secure_headers/headers/x_download_options.rb b/lib/secure_headers/headers/x_download_options.rb index 1eb1356a..0042e9d0 100644 --- a/lib/secure_headers/headers/x_download_options.rb +++ b/lib/secure_headers/headers/x_download_options.rb @@ -5,22 +5,20 @@ class XDownloadOptions HEADER_NAME = "x-download-options".freeze DEFAULT_VALUE = "noopen" - class << self - # Public: generate an x-download-options header. - # - # Returns a default header if no configuration is provided, or a - # header name and value based on the config. - def make_header(config = nil, user_agent = nil) - return if config == OPT_OUT - [HEADER_NAME, config || DEFAULT_VALUE] - end + # Public: generate an x-download-options header. + # + # Returns a default header if no configuration is provided, or a + # header name and value based on the config. + def self.make_header(config = nil, user_agent = nil) + return if config == OPT_OUT + [HEADER_NAME, config || DEFAULT_VALUE] + end - def validate_config!(config) - return if config.nil? || config == OPT_OUT - raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) - unless config.casecmp(DEFAULT_VALUE) == 0 - raise XDOConfigError.new("Value can only be nil or 'noopen'") - end + def self.validate_config!(config) + return if config.nil? || config == OPT_OUT + raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) + unless config.casecmp(DEFAULT_VALUE) == 0 + raise XDOConfigError.new("Value can only be nil or 'noopen'") end end end diff --git a/lib/secure_headers/headers/x_frame_options.rb b/lib/secure_headers/headers/x_frame_options.rb index 636e3bfa..9788ca3e 100644 --- a/lib/secure_headers/headers/x_frame_options.rb +++ b/lib/secure_headers/headers/x_frame_options.rb @@ -10,22 +10,20 @@ class XFrameOptions DEFAULT_VALUE = SAMEORIGIN VALID_XFO_HEADER = /\A(#{SAMEORIGIN}\z|#{DENY}\z|#{ALLOW_ALL}\z|#{ALLOW_FROM}[:\s])/i - class << self - # Public: generate an X-Frame-Options header. - # - # Returns a default header if no configuration is provided, or a - # header name and value based on the config. - def make_header(config = nil, user_agent = nil) - return if config == OPT_OUT - [HEADER_NAME, config || DEFAULT_VALUE] - end + # Public: generate an X-Frame-Options header. + # + # Returns a default header if no configuration is provided, or a + # header name and value based on the config. + def self.make_header(config = nil, user_agent = nil) + return if config == OPT_OUT + [HEADER_NAME, config || DEFAULT_VALUE] + end - def validate_config!(config) - return if config.nil? || config == OPT_OUT - raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) - unless config =~ VALID_XFO_HEADER - raise XFOConfigError.new("Value must be SAMEORIGIN|DENY|ALLOW-FROM:|ALLOWALL") - end + def self.validate_config!(config) + return if config.nil? || config == OPT_OUT + raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) + unless config =~ VALID_XFO_HEADER + raise XFOConfigError.new("Value must be SAMEORIGIN|DENY|ALLOW-FROM:|ALLOWALL") end end end diff --git a/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb b/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb index a34dd58f..4ca81b68 100644 --- a/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +++ b/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb @@ -6,22 +6,20 @@ class XPermittedCrossDomainPolicies DEFAULT_VALUE = "none" VALID_POLICIES = %w(all none master-only by-content-type by-ftp-filename) - class << self - # Public: generate an x-permitted-cross-domain-policies header. - # - # Returns a default header if no configuration is provided, or a - # header name and value based on the config. - def make_header(config = nil, user_agent = nil) - return if config == OPT_OUT - [HEADER_NAME, config || DEFAULT_VALUE] - end + # Public: generate an x-permitted-cross-domain-policies header. + # + # Returns a default header if no configuration is provided, or a + # header name and value based on the config. + def self.make_header(config = nil, user_agent = nil) + return if config == OPT_OUT + [HEADER_NAME, config || DEFAULT_VALUE] + end - def validate_config!(config) - return if config.nil? || config == OPT_OUT - raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) - unless VALID_POLICIES.include?(config.downcase) - raise XPCDPConfigError.new("Value can only be one of #{VALID_POLICIES.join(', ')}") - end + def self.validate_config!(config) + return if config.nil? || config == OPT_OUT + raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) + unless VALID_POLICIES.include?(config.downcase) + raise XPCDPConfigError.new("Value can only be one of #{VALID_POLICIES.join(', ')}") end end end diff --git a/lib/secure_headers/headers/x_xss_protection.rb b/lib/secure_headers/headers/x_xss_protection.rb index bd5b7faa..756bc4c3 100644 --- a/lib/secure_headers/headers/x_xss_protection.rb +++ b/lib/secure_headers/headers/x_xss_protection.rb @@ -6,21 +6,19 @@ class XXssProtection DEFAULT_VALUE = "0".freeze VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/ - class << self - # Public: generate an X-Xss-Protection header. - # - # Returns a default header if no configuration is provided, or a - # header name and value based on the config. - def make_header(config = nil, user_agent = nil) - return if config == OPT_OUT - [HEADER_NAME, config || DEFAULT_VALUE] - end + # Public: generate an X-Xss-Protection header. + # + # Returns a default header if no configuration is provided, or a + # header name and value based on the config. + def self.make_header(config = nil, user_agent = nil) + return if config == OPT_OUT + [HEADER_NAME, config || DEFAULT_VALUE] + end - def validate_config!(config) - return if config.nil? || config == OPT_OUT - raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) - raise XXssProtectionConfigError.new("Invalid format (see VALID_X_XSS_HEADER)") unless config.to_s =~ VALID_X_XSS_HEADER - end + def self.validate_config!(config) + return if config.nil? || config == OPT_OUT + raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) + raise XXssProtectionConfigError.new("Invalid format (see VALID_X_XSS_HEADER)") unless config.to_s =~ VALID_X_XSS_HEADER end end end diff --git a/spec/lib/secure_headers/configuration_spec.rb b/spec/lib/secure_headers/configuration_spec.rb index 73e9b16b..fc5f2ec6 100644 --- a/spec/lib/secure_headers/configuration_spec.rb +++ b/spec/lib/secure_headers/configuration_spec.rb @@ -22,7 +22,9 @@ module SecureHeaders configuration = Configuration.dup expect(original_configuration).not_to be(configuration) Configuration::CONFIG_ATTRIBUTES.each do |attr| + # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod expect(original_configuration.send(attr)).to eq(configuration.send(attr)) + # rubocop:enable GitHub/AvoidObjectSendWithDynamicMethod end end @@ -97,7 +99,7 @@ module SecureHeaders end it "gives cookies a default config" do - expect(Configuration.default.cookies).to eq({httponly: true, secure: true, samesite: {lax: true}}) + expect(Configuration.default.cookies).to eq({ httponly: true, secure: true, samesite: { lax: true } }) end it "allows OPT_OUT" do @@ -111,11 +113,11 @@ module SecureHeaders it "allows me to be explicit too" do Configuration.default do |config| - config.cookies = {httponly: true, secure: true, samesite: {lax: false}} + config.cookies = { httponly: true, secure: true, samesite: { lax: false } } end config = Configuration.dup - expect(config.cookies).to eq({httponly: true, secure: true, samesite: {lax: false}}) + expect(config.cookies).to eq({ httponly: true, secure: true, samesite: { lax: false } }) end end end 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 37cb62a7..ed8d8e2b 100644 --- a/spec/lib/secure_headers/headers/content_security_policy_spec.rb +++ b/spec/lib/secure_headers/headers/content_security_policy_spec.rb @@ -3,7 +3,7 @@ module SecureHeaders describe ContentSecurityPolicy do - let (:default_opts) do + let(:default_opts) do { default_src: %w(https:), img_src: %w(https: data:), @@ -167,49 +167,74 @@ module SecureHeaders end it "supports strict-dynamic" do - csp = ContentSecurityPolicy.new({default_src: %w('self'), script_src: [ContentSecurityPolicy::STRICT_DYNAMIC], script_nonce: 123456}) + csp = ContentSecurityPolicy.new({ default_src: %w('self'), script_src: [ContentSecurityPolicy::STRICT_DYNAMIC], script_nonce: 123456 }) expect(csp.value).to eq("default-src 'self'; script-src 'strict-dynamic' 'nonce-123456' 'unsafe-inline'") end it "supports strict-dynamic and opting out of the appended 'unsafe-inline'" do - csp = ContentSecurityPolicy.new({default_src: %w('self'), script_src: [ContentSecurityPolicy::STRICT_DYNAMIC], script_nonce: 123456, disable_nonce_backwards_compatibility: true }) + csp = ContentSecurityPolicy.new({ default_src: %w('self'), script_src: [ContentSecurityPolicy::STRICT_DYNAMIC], script_nonce: 123456, disable_nonce_backwards_compatibility: true }) expect(csp.value).to eq("default-src 'self'; script-src 'strict-dynamic' 'nonce-123456'") end it "supports script-src-elem directive" do - csp = ContentSecurityPolicy.new({script_src: %w('self'), script_src_elem: %w('self')}) + csp = ContentSecurityPolicy.new({ script_src: %w('self'), script_src_elem: %w('self') }) expect(csp.value).to eq("script-src 'self'; script-src-elem 'self'") end it "supports script-src-attr directive" do - csp = ContentSecurityPolicy.new({script_src: %w('self'), script_src_attr: %w('self')}) + csp = ContentSecurityPolicy.new({ script_src: %w('self'), script_src_attr: %w('self') }) expect(csp.value).to eq("script-src 'self'; script-src-attr 'self'") end it "supports style-src-elem directive" do - csp = ContentSecurityPolicy.new({style_src: %w('self'), style_src_elem: %w('self')}) + csp = ContentSecurityPolicy.new({ style_src: %w('self'), style_src_elem: %w('self') }) expect(csp.value).to eq("style-src 'self'; style-src-elem 'self'") end it "supports style-src-attr directive" do - csp = ContentSecurityPolicy.new({style_src: %w('self'), style_src_attr: %w('self')}) + csp = ContentSecurityPolicy.new({ style_src: %w('self'), style_src_attr: %w('self') }) expect(csp.value).to eq("style-src 'self'; style-src-attr 'self'") end it "supports trusted-types directive" do - csp = ContentSecurityPolicy.new({trusted_types: %w(blahblahpolicy)}) + csp = ContentSecurityPolicy.new({ trusted_types: %w(blahblahpolicy) }) expect(csp.value).to eq("trusted-types blahblahpolicy") end it "supports trusted-types directive with 'none'" do - csp = ContentSecurityPolicy.new({trusted_types: %w('none')}) + csp = ContentSecurityPolicy.new({ trusted_types: %w('none') }) expect(csp.value).to eq("trusted-types 'none'") end it "allows duplicate policy names in trusted-types directive" do - csp = ContentSecurityPolicy.new({trusted_types: %w(blahblahpolicy 'allow-duplicates')}) + csp = ContentSecurityPolicy.new({ trusted_types: %w(blahblahpolicy 'allow-duplicates') }) expect(csp.value).to eq("trusted-types blahblahpolicy 'allow-duplicates'") end + + it "supports report-to directive with endpoint name" do + csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_to: "csp-endpoint" }) + expect(csp.value).to eq("default-src 'self'; report-to csp-endpoint") + end + + it "includes report-to before report-uri in alphabetical order" do + csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_uri: %w(/csp_report), report_to: "csp-endpoint" }) + expect(csp.value).to eq("default-src 'self'; report-to csp-endpoint; report-uri /csp_report") + end + + it "does not add report-to if the endpoint name is empty" do + csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_to: "" }) + expect(csp.value).to eq("default-src 'self'") + end + + it "does not add report-to if not provided" do + csp = ContentSecurityPolicy.new({ default_src: %w('self') }) + expect(csp.value).not_to include("report-to") + end + + it "supports report-to without report-uri" do + csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_to: "reporting-endpoint-name" }) + expect(csp.value).to eq("default-src 'self'; report-to reporting-endpoint-name") + end end end end diff --git a/spec/lib/secure_headers/headers/cookie_spec.rb b/spec/lib/secure_headers/headers/cookie_spec.rb index cdb24246..b0a9152b 100644 --- a/spec/lib/secure_headers/headers/cookie_spec.rb +++ b/spec/lib/secure_headers/headers/cookie_spec.rb @@ -35,7 +35,7 @@ module SecureHeaders context "when configured with a Hash" do it "flags cookies as Secure when whitelisted" do - cookie = Cookie.new(raw_cookie, secure: { only: ["_session"]}, httponly: OPT_OUT, samesite: OPT_OUT) + cookie = Cookie.new(raw_cookie, secure: { only: ["_session"] }, httponly: OPT_OUT, samesite: OPT_OUT) expect(cookie.to_s).to eq("_session=thisisatest; secure") end @@ -56,7 +56,7 @@ module SecureHeaders context "when configured with a Hash" do it "flags cookies as HttpOnly when whitelisted" do - cookie = Cookie.new(raw_cookie, httponly: { only: ["_session"]}, secure: OPT_OUT, samesite: OPT_OUT) + cookie = Cookie.new(raw_cookie, httponly: { only: ["_session"] }, secure: OPT_OUT, samesite: OPT_OUT) expect(cookie.to_s).to eq("_session=thisisatest; HttpOnly") end @@ -75,7 +75,7 @@ module SecureHeaders end it "flags SameSite=#{flag} when configured with a boolean" do - cookie = Cookie.new(raw_cookie, samesite: { flag.downcase.to_sym => true}, secure: OPT_OUT, httponly: OPT_OUT) + cookie = Cookie.new(raw_cookie, samesite: { flag.downcase.to_sym => true }, secure: OPT_OUT, httponly: OPT_OUT) expect(cookie.to_s).to eq("_session=thisisatest; SameSite=#{flag}") end @@ -86,7 +86,7 @@ module SecureHeaders end it "flags SameSite=Strict when configured with a boolean" do - cookie = Cookie.new(raw_cookie, {samesite: { strict: true}, secure: OPT_OUT, httponly: OPT_OUT}) + cookie = Cookie.new(raw_cookie, { samesite: { strict: true }, secure: OPT_OUT, httponly: OPT_OUT }) expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Strict") end @@ -146,7 +146,7 @@ module SecureHeaders (cookie_options - [flag]).each do |other_flag| it "raises an exception when SameSite #{flag} and #{other_flag} enforcement modes are configured with booleans" do expect do - Cookie.validate_config!(samesite: { flag => true, other_flag => true}) + Cookie.validate_config!(samesite: { flag => true, other_flag => true }) end.to raise_error(CookiesConfigError) 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 c621e88e..626a1a82 100644 --- a/spec/lib/secure_headers/headers/policy_management_spec.rb +++ b/spec/lib/secure_headers/headers/policy_management_spec.rb @@ -8,7 +8,7 @@ module SecureHeaders Configuration.default end - let (:default_opts) do + let(:default_opts) do { default_src: %w(https:), img_src: %w(https: data:), @@ -169,6 +169,30 @@ module SecureHeaders ContentSecurityPolicy.validate_config!(ContentSecurityPolicyReportOnlyConfig.new(default_opts.merge(report_only: true))) end.to_not raise_error end + + it "requires report_to to be a string" do + expect do + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: ["endpoint"]))) + end.to raise_error(ContentSecurityPolicyConfigError) + end + + it "rejects empty report_to endpoint names" do + expect do + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: ""))) + end.to raise_error(ContentSecurityPolicyConfigError) + end + + it "accepts valid report_to endpoint names" do + expect do + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: "csp-endpoint"))) + end.to_not raise_error + end + + it "accepts report_to with hyphens and underscores" do + expect do + ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: "csp-endpoint_name-123"))) + end.to_not raise_error + end end describe "#combine_policies" do diff --git a/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb b/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb new file mode 100644 index 00000000..286e204c --- /dev/null +++ b/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true +require "spec_helper" + +module SecureHeaders + describe ReportingEndpoints do + describe "#make_header" do + it "returns nil when config is nil" do + expect(ReportingEndpoints.make_header(nil)).to be_nil + end + + it "returns nil when config is OPT_OUT" do + expect(ReportingEndpoints.make_header(OPT_OUT)).to be_nil + end + + it "formats a single endpoint" do + config = { "csp-endpoint" => "https://example.com/csp-reports" } + header_name, value = ReportingEndpoints.make_header(config) + expect(header_name).to eq("reporting-endpoints") + expect(value).to eq('csp-endpoint="https://example.com/csp-reports"') + end + + it "formats multiple endpoints" do + config = { + "csp-endpoint" => "https://example.com/csp-reports", + "permissions-endpoint" => "https://example.com/permissions-reports" + } + header_name, value = ReportingEndpoints.make_header(config) + expect(header_name).to eq("reporting-endpoints") + # Order may vary, so check both endpoints are present + expect(value).to include('csp-endpoint="https://example.com/csp-reports"') + expect(value).to include('permissions-endpoint="https://example.com/permissions-reports"') + expect(value).to include(",") + end + + it "validates that endpoints are present" do + expect do + ReportingEndpoints.validate_config!({}) + end.to_not raise_error + end + end + + describe "#validate_config!" do + it "accepts nil" do + expect do + ReportingEndpoints.validate_config!(nil) + end.to_not raise_error + end + + it "accepts OPT_OUT" do + expect do + ReportingEndpoints.validate_config!(OPT_OUT) + end.to_not raise_error + end + + it "accepts valid endpoint configuration" do + expect do + ReportingEndpoints.validate_config!({ + "csp-violations" => "https://example.com/reports" + }) + end.to_not raise_error + end + + it "rejects non-hash config" do + expect do + ReportingEndpoints.validate_config!("not a hash") + end.to raise_error(TypeError) + end + + it "rejects empty endpoint name" do + expect do + ReportingEndpoints.validate_config!({ + "" => "https://example.com/reports" + }) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "rejects non-string endpoint name" do + expect do + ReportingEndpoints.validate_config!({ + 123 => "https://example.com/reports" + }) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "rejects empty endpoint URL" do + expect do + ReportingEndpoints.validate_config!({ + "csp-endpoint" => "" + }) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "rejects non-string endpoint URL" do + expect do + ReportingEndpoints.validate_config!({ + "csp-endpoint" => 123 + }) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "rejects non-https URLs" do + expect do + ReportingEndpoints.validate_config!({ + "csp-endpoint" => "http://example.com/reports" + }) + end.to raise_error(ReportingEndpointsConfigError, /must use https/) + end + end + end +end diff --git a/spec/lib/secure_headers/middleware_spec.rb b/spec/lib/secure_headers/middleware_spec.rb index b3925f85..f019b597 100644 --- a/spec/lib/secure_headers/middleware_spec.rb +++ b/spec/lib/secure_headers/middleware_spec.rb @@ -42,7 +42,7 @@ module SecureHeaders end context "cookies should be flagged" do it "flags cookies as secure" do - Configuration.default { |config| config.cookies = {secure: true, httponly: OPT_OUT, samesite: OPT_OUT} } + Configuration.default { |config| config.cookies = { secure: true, httponly: OPT_OUT, samesite: OPT_OUT } } request = Rack::Request.new("HTTPS" => "on") _, env = cookie_middleware.call request.env expect(env["Set-Cookie"]).to eq("foo=bar; secure") @@ -62,7 +62,7 @@ module SecureHeaders context "cookies should not be flagged" do it "does not flags cookies as secure" do - Configuration.default { |config| config.cookies = {secure: OPT_OUT, httponly: OPT_OUT, samesite: OPT_OUT} } + Configuration.default { |config| config.cookies = { secure: OPT_OUT, httponly: OPT_OUT, samesite: OPT_OUT } } request = Rack::Request.new("HTTPS" => "on") _, env = cookie_middleware.call request.env expect(env["Set-Cookie"]).to eq("foo=bar") @@ -75,7 +75,7 @@ module SecureHeaders reset_config end it "flags cookies from configuration" do - Configuration.default { |config| config.cookies = { secure: true, httponly: true, samesite: { lax: true} } } + Configuration.default { |config| config.cookies = { secure: true, httponly: true, samesite: { lax: true } } } request = Rack::Request.new("HTTPS" => "on") _, env = cookie_middleware.call request.env @@ -85,7 +85,7 @@ module SecureHeaders it "flags cookies with a combination of SameSite configurations" do cookie_middleware = Middleware.new(lambda { |env| [200, env.merge("Set-Cookie" => ["_session=foobar", "_guest=true"]), "app"] }) - Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } }, httponly: OPT_OUT, secure: OPT_OUT} } + Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } }, httponly: OPT_OUT, secure: OPT_OUT } } request = Rack::Request.new("HTTPS" => "on") _, env = cookie_middleware.call request.env diff --git a/spec/lib/secure_headers_spec.rb b/spec/lib/secure_headers_spec.rb index fd66d487..5de8409a 100644 --- a/spec/lib/secure_headers_spec.rb +++ b/spec/lib/secure_headers_spec.rb @@ -54,7 +54,7 @@ module SecureHeaders describe "#header_hash_for" do it "allows you to opt out of individual headers via API" do Configuration.default do |config| - config.csp = { default_src: %w('self'), script_src: %w('self')} + config.csp = { default_src: %w('self'), script_src: %w('self') } config.csp_report_only = config.csp end SecureHeaders.opt_out_of_header(request, :csp) @@ -174,11 +174,11 @@ module SecureHeaders end Configuration.named_append(:moar_default_sources) do |request| - { default_src: %w(https:), style_src: %w('self')} + { default_src: %w(https:), style_src: %w('self') } end Configuration.named_append(:how_about_a_script_src_too) do |request| - { script_src: %w('unsafe-inline')} + { script_src: %w('unsafe-inline') } end SecureHeaders.use_content_security_policy_named_append(request, :moar_default_sources) @@ -318,7 +318,7 @@ module SecureHeaders default_src: %w('self'), script_src: %w('self') } - config.csp_report_only = config.csp.merge({script_src: %w(foo.com)}) + config.csp_report_only = config.csp.merge({ script_src: %w(foo.com) }) end hash = SecureHeaders.header_hash_for(request) @@ -342,42 +342,42 @@ module SecureHeaders end it "allows appending to the enforced policy" do - SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}, :enforced) + 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'; script-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) + 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'; script-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) + 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) + 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'; script-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) + 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'; script-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) + 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") @@ -392,7 +392,7 @@ module SecureHeaders script_src: %w('self') } end - SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}) + 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") @@ -408,7 +408,7 @@ module SecureHeaders script_src: %w('self') } end - SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}) + 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") @@ -427,7 +427,7 @@ module SecureHeaders script_src: %w('self') } end - SecureHeaders.append_content_security_policy_directives(request, {script_src: %w(anothercdn.com)}) + 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 'self' anothercdn.com") @@ -527,6 +527,146 @@ module SecureHeaders end end.to raise_error(CookiesConfigError) end + + it "validates report_to directive on configuration" do + expect do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + report_to: ["not_a_string"] + } + end + end.to raise_error(ContentSecurityPolicyConfigError) + end + + it "allows report_to directive with string endpoint" do + expect do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + report_to: "csp-endpoint" + } + end + end.to_not raise_error + end + end + + describe "report_to with overrides and appends" do + let(:request) { double("Request", scheme: "https", env: {}) } + + it "overrides the report_to directive" do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + report_to: "endpoint-1" + } + end + + SecureHeaders.override_content_security_policy_directives(request, report_to: "endpoint-2") + headers = SecureHeaders.header_hash_for(request) + csp_header = headers[ContentSecurityPolicyConfig::HEADER_NAME] + expect(csp_header).to include("report-to endpoint-2") + end + + it "includes report_to when appending CSP directives" do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self') + } + end + + SecureHeaders.append_content_security_policy_directives(request, report_to: "new-endpoint") + headers = SecureHeaders.header_hash_for(request) + csp_header = headers[ContentSecurityPolicyConfig::HEADER_NAME] + expect(csp_header).to include("report-to new-endpoint") + end + + it "handles report_to with report_uri together" do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + report_uri: %w(/csp-report), + report_to: "reporting-endpoint" + } + end + + headers = SecureHeaders.header_hash_for(request) + csp_header = headers[ContentSecurityPolicyConfig::HEADER_NAME] + # Both should be present + expect(csp_header).to include("report-to reporting-endpoint") + expect(csp_header).to include("report-uri /csp-report") + # report-to should come before report-uri (alphabetical order) + expect(csp_header.index("report-to")).to be < csp_header.index("report-uri") + end + end + + describe "reporting_endpoints header generation" do + let(:request) { double("Request", scheme: "https", env: {}) } + + before(:each) do + reset_config + end + + it "includes reporting_endpoints header in generated headers" do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self') + } + config.reporting_endpoints = { + "csp-endpoint" => "https://example.com/reports" + } + end + + headers = SecureHeaders.header_hash_for(request) + expect(headers["reporting-endpoints"]).to eq('csp-endpoint="https://example.com/reports"') + end + + it "includes reporting_endpoints after config.dup() is called" do + # This test specifically validates that reporting_endpoints survives + # the .dup() call made by the middleware + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self') + } + config.reporting_endpoints = { + "csp-violations" => "https://api.example.com/reports?enforcement=enforce", + "csp-violations-report-only" => "https://api.example.com/reports?enforcement=report-only" + } + end + + # Simulate what the middleware does internally + config = Configuration.dup # ← This calls .dup() which must preserve reporting_endpoints + headers = config.generate_headers + + expect(headers["reporting-endpoints"]).to include('csp-violations="https://api.example.com/reports?enforcement=enforce"') + expect(headers["reporting-endpoints"]).to include('csp-violations-report-only="https://api.example.com/reports?enforcement=report-only"') + end + + it "does not include reporting_endpoints header when OPT_OUT" do + Configuration.default do |config| + config.csp = { default_src: %w('self'), script_src: %w('self') } + config.reporting_endpoints = OPT_OUT + end + + headers = SecureHeaders.header_hash_for(request) + expect(headers["reporting-endpoints"]).to be_nil + end + + it "does not include reporting_endpoints header when not configured" do + Configuration.default do |config| + config.csp = { default_src: %w('self'), script_src: %w('self') } + end + + headers = SecureHeaders.header_hash_for(request) + expect(headers["reporting-endpoints"]).to be_nil + end end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b0c774d9..65627eec 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -41,18 +41,16 @@ def expect_default_values(hash) module SecureHeaders class Configuration - class << self - def clear_default_config - remove_instance_variable(:@default_config) if defined?(@default_config) - end + def self.clear_default_config + remove_instance_variable(:@default_config) if defined?(@default_config) + end - def clear_overrides - remove_instance_variable(:@overrides) if defined?(@overrides) - end + def self.clear_overrides + remove_instance_variable(:@overrides) if defined?(@overrides) + end - def clear_appends - remove_instance_variable(:@appends) if defined?(@appends) - end + def self.clear_appends + remove_instance_variable(:@appends) if defined?(@appends) end end end