diff --git a/README.md b/README.md index 4682249d..344d1693 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. @@ -125,6 +166,29 @@ end However, I would consider these headers anyways depending on your load and bandwidth requirements. +## Disabling secure_headers + +If you want to disable `secure_headers` entirely (e.g., for specific environments or deployment scenarios), you can use `Configuration.disable!`: + +```ruby +if ENV["ENABLE_STRICT_HEADERS"] + SecureHeaders::Configuration.default do |config| + # your configuration here + end +else + SecureHeaders::Configuration.disable! +end +``` + +**Important**: This configuration must be set during application startup (e.g., in an initializer). Once you call either `Configuration.default` or `Configuration.disable!`, the choice cannot be changed at runtime. Attempting to call `disable!` after `default` (or vice versa) will raise an `AlreadyConfiguredError`. + +When disabled, no security headers will be set by the gem. This is useful when: +- You're gradually rolling out secure_headers across different customers or deployments +- You need to migrate existing custom headers to secure_headers +- You want environment-specific control over security headers + +Note: When `disable!` is used, you don't need to configure a default configuration. The gem will not raise a `NotYetConfiguredError`. + ## Acknowledgements This project originated within the Security team at Twitter. An archived fork from the point of transition is here: https://github.com/twitter-archive/secure_headers. diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index ed4efd93..4caf1044 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" @@ -133,6 +134,7 @@ def opt_out_of_all_protection(request) # request. # # StrictTransportSecurity is not applied to http requests. + # upgrade_insecure_requests is not applied to http requests. # See #config_for to determine which config is used for a given request. # # Returns a hash of header names => header values. The value @@ -146,6 +148,11 @@ def header_hash_for(request) if request.scheme != HTTPS headers.delete(StrictTransportSecurity::HEADER_NAME) + + # Remove upgrade_insecure_requests from CSP headers for HTTP requests + # as it doesn't make sense to upgrade requests when the page itself is served over HTTP + remove_upgrade_insecure_requests_from_csp!(headers, config.csp) + remove_upgrade_insecure_requests_from_csp!(headers, config.csp_report_only) end headers end @@ -242,6 +249,23 @@ def content_security_policy_nonce(request, script_or_style) def override_secure_headers_request_config(request, config) request.env[SECURE_HEADERS_CONFIG] = config end + + # Private: removes upgrade_insecure_requests directive from a CSP config + # if it's present, and updates the headers hash with the modified CSP. + # + # headers - the headers hash to update + # csp_config - the CSP config to check and potentially modify + # + # Returns nothing (modifies headers in place) + def remove_upgrade_insecure_requests_from_csp!(headers, csp_config) + return if csp_config.opt_out? + return unless csp_config.directive_value(ContentSecurityPolicy::UPGRADE_INSECURE_REQUESTS) + + modified_config = csp_config.dup + modified_config.update_directive(ContentSecurityPolicy::UPGRADE_INSECURE_REQUESTS, false) + header_name, value = ContentSecurityPolicy.make_header(modified_config) + headers[header_name] = value if header_name && value + end end # These methods are mixed into controllers and delegate to the class method diff --git a/lib/secure_headers/configuration.rb b/lib/secure_headers/configuration.rb index 4fd459ea..2ef02d5d 100644 --- a/lib/secure_headers/configuration.rb +++ b/lib/secure_headers/configuration.rb @@ -9,23 +9,53 @@ class AlreadyConfiguredError < StandardError; end class NotYetConfiguredError < StandardError; end class IllegalPolicyModificationError < StandardError; end class << self + # Public: Disable secure_headers entirely. When disabled, no headers will be set. + # + # Note: This must be called before Configuration.default. Calling it after + # Configuration.default has been set will raise an AlreadyConfiguredError. + # + # Returns nothing + # Raises AlreadyConfiguredError if Configuration.default has already been called + def disable! + if defined?(@default_config) + raise AlreadyConfiguredError, "Configuration already set, cannot disable" + end + + @disabled = true + @noop_config = create_noop_config.freeze + + # Ensure the built-in NOOP override is available even if `default` has never been called + @overrides ||= {} + unless @overrides.key?(NOOP_OVERRIDE) + @overrides[NOOP_OVERRIDE] = method(:create_noop_config_block) + end + end + + # Public: Check if secure_headers is disabled + # + # Returns boolean + def disabled? + defined?(@disabled) && @disabled + end + # Public: Set the global default configuration. # # Optionally supply a block to override the defaults set by this library. # # Returns the newly created config. + # Raises AlreadyConfiguredError if Configuration.disable! has already been called def default(&block) + if disabled? + raise AlreadyConfiguredError, "Configuration has been disabled, cannot set default" + end + if defined?(@default_config) raise AlreadyConfiguredError, "Policy already configured" end # Define a built-in override that clears all configuration options and # results in no security headers being set. - override(NOOP_OVERRIDE) do |config| - CONFIG_ATTRIBUTES.each do |attr| - config.instance_variable_set("@#{attr}", OPT_OUT) - end - end + override(NOOP_OVERRIDE, &method(:create_noop_config_block)) new_config = new(&block).freeze new_config.validate_config! @@ -101,6 +131,7 @@ def deep_copy(config) # of ensuring that the default config is never mutated and is dup(ed) # before it is used in a request. def default_config + return @noop_config if disabled? unless defined?(@default_config) raise NotYetConfiguredError, "Default policy not yet configured" end @@ -116,6 +147,19 @@ def deep_copy_if_hash(value) value end end + + # Private: Creates a NOOP configuration that opts out of all headers + def create_noop_config + new(&method(:create_noop_config_block)) + end + + # Private: Block for creating NOOP configuration + # Used by both create_noop_config and the NOOP_OVERRIDE mechanism + def create_noop_config_block(config) + CONFIG_ATTRIBUTES.each do |attr| + config.instance_variable_set("@#{attr}", OPT_OUT) + end + end end CONFIG_ATTRIBUTES_TO_HEADER_CLASSES = { @@ -131,6 +175,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 +212,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 +238,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/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index 055771f0..e93eca21 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 @@ -129,6 +138,7 @@ def minify_source_list(directive, source_list) else source_list = populate_nonces(directive, source_list) source_list = reject_all_values_if_none(source_list) + source_list = normalize_uri_paths(source_list) unless directive == REPORT_URI || @preserve_schemes source_list = strip_source_schemes(source_list) @@ -151,6 +161,26 @@ def reject_all_values_if_none(source_list) end end + def normalize_uri_paths(source_list) + source_list.map do |source| + # Normalize domains ending in a single / as without omitting the slash accomplishes the same. + # https://www.w3.org/TR/CSP3/#match-paths § 6.6.2.10 Step 2 + begin + uri = URI(source) + if uri.path == "/" + next source.chomp("/") + end + rescue URI::InvalidURIError + end + + if source.chomp("/").include?("/") + source + else + source.chomp("/") + end + end + end + # Private: append a nonce to the script/style directories if script_nonce # or style_nonce are provided. def populate_nonces(directive, source_list) @@ -179,11 +209,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/policy_management.rb b/lib/secure_headers/headers/policy_management.rb index d34c8a8c..de5958e3 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/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/middleware.rb b/lib/secure_headers/middleware.rb index e1c07c5b..c579f69a 100644 --- a/lib/secure_headers/middleware.rb +++ b/lib/secure_headers/middleware.rb @@ -9,6 +9,7 @@ def initialize(app) def call(env) req = Rack::Request.new(env) status, headers, response = @app.call(env) + headers = Rack::Headers[headers] config = SecureHeaders.config_for(req) flag_cookies!(headers, override_secure(env, config.cookies)) unless config.cookies == OPT_OUT @@ -20,14 +21,12 @@ def call(env) # inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194 def flag_cookies!(headers, config) - if cookies = headers["Set-Cookie"] - # Support Rails 2.3 / Rack 1.1 arrays as headers - cookies = cookies.split("\n") unless cookies.is_a?(Array) + cookies = headers["Set-Cookie"] + return unless cookies - headers["Set-Cookie"] = cookies.map do |cookie| - SecureHeaders::Cookie.new(cookie, config).to_s - end.join("\n") - end + cookies_array = cookies.is_a?(Array) ? cookies : cookies.split("\n") + secured_cookies = cookies_array.map { |cookie| SecureHeaders::Cookie.new(cookie, config).to_s } + headers["Set-Cookie"] = cookies.is_a?(Array) ? secured_cookies : secured_cookies.join("\n") end # disable Secure cookies for non-https requests diff --git a/lib/secure_headers/railtie.rb b/lib/secure_headers/railtie.rb index ba255acc..64f9eec9 100644 --- a/lib/secure_headers/railtie.rb +++ b/lib/secure_headers/railtie.rb @@ -22,9 +22,12 @@ class Railtie < Rails::Railtie ActiveSupport.on_load(:action_controller) do include SecureHeaders - unless Rails.application.config.action_dispatch.default_headers.nil? - conflicting_headers.each do |header| - Rails.application.config.action_dispatch.default_headers.delete(header) + default_headers = Rails.application.config.action_dispatch.default_headers + unless default_headers.nil? + default_headers.each_key do |header| + if conflicting_headers.include?(header.downcase) + default_headers.delete(header) + end end end end diff --git a/lib/secure_headers/task_helper.rb b/lib/secure_headers/task_helper.rb new file mode 100644 index 00000000..fa4ba971 --- /dev/null +++ b/lib/secure_headers/task_helper.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module SecureHeaders + module TaskHelper + include SecureHeaders::HashHelper + + INLINE_SCRIPT_REGEX = /()(.*?)<\/script>/mx + INLINE_STYLE_REGEX = /(]*>)(.*?)<\/style>/mx + INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx + INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx + + def generate_inline_script_hashes(filename) + hashes = [] + + hashes.concat find_inline_content(filename, INLINE_SCRIPT_REGEX, false) + hashes.concat find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, true) + + hashes + end + + def generate_inline_style_hashes(filename) + hashes = [] + + hashes.concat find_inline_content(filename, INLINE_STYLE_REGEX, false) + hashes.concat find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, true) + + hashes + end + + def dynamic_content?(filename, inline_script) + !!( + (is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) || + (is_erb?(filename) && inline_script =~ /<%.*%>/) + ) + end + + private + + def find_inline_content(filename, regex, strip_trailing_whitespace) + hashes = [] + file = File.read(filename) + file.scan(regex) do # TODO don't use gsub + inline_script = Regexp.last_match.captures.last + inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace + if dynamic_content?(filename, inline_script) + puts "Looks like there's some dynamic content inside of a tag :-/" + puts "That pretty much means the hash value will never match." + puts "Code: " + inline_script + puts "=" * 20 + end + + hashes << hash_source(inline_script) + end + hashes + end + + def is_erb?(filename) + filename =~ /\.erb\Z/ + end + + def is_mustache?(filename) + filename =~ /\.mustache\Z/ + end + end +end diff --git a/lib/tasks/tasks.rake b/lib/tasks/tasks.rake index cb078246..09237bb4 100644 --- a/lib/tasks/tasks.rake +++ b/lib/tasks/tasks.rake @@ -1,58 +1,8 @@ # frozen_string_literal: true -INLINE_SCRIPT_REGEX = /()(.*?)<\/script>/mx unless defined? INLINE_SCRIPT_REGEX -INLINE_STYLE_REGEX = /(]*>)(.*?)<\/style>/mx unless defined? INLINE_STYLE_REGEX -INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_SCRIPT_HELPER_REGEX -INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_STYLE_HELPER_REGEX +require "secure_headers/task_helper" namespace :secure_headers do - include SecureHeaders::HashHelper - - def is_erb?(filename) - filename =~ /\.erb\Z/ - end - - def is_mustache?(filename) - filename =~ /\.mustache\Z/ - end - - def dynamic_content?(filename, inline_script) - (is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) || - (is_erb?(filename) && inline_script =~ /<%.*%>/) - end - - def find_inline_content(filename, regex, hashes, strip_trailing_whitespace) - file = File.read(filename) - file.scan(regex) do # TODO don't use gsub - inline_script = Regexp.last_match.captures.last - inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace - if dynamic_content?(filename, inline_script) - puts "Looks like there's some dynamic content inside of a tag :-/" - puts "That pretty much means the hash value will never match." - puts "Code: " + inline_script - puts "=" * 20 - end - - hashes << hash_source(inline_script) - end - end - - def generate_inline_script_hashes(filename) - hashes = [] - - find_inline_content(filename, INLINE_SCRIPT_REGEX, hashes, false) - find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, hashes, true) - - hashes - end - - def generate_inline_style_hashes(filename) - hashes = [] - - find_inline_content(filename, INLINE_STYLE_REGEX, hashes, false) - find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, hashes, true) - - hashes - end + include SecureHeaders::TaskHelper desc "Generate #{SecureHeaders::Configuration::HASH_CONFIG_FILE}" task :generate_hashes do |t, args| @@ -77,6 +27,7 @@ namespace :secure_headers do file.write(script_hashes.to_yaml) end - puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}" + file_count = (script_hashes["scripts"].keys + script_hashes["styles"].keys).uniq.count + puts "Script and style hashes from #{file_count} files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}" end end diff --git a/spec/lib/secure_headers/configuration_spec.rb b/spec/lib/secure_headers/configuration_spec.rb index 1c613658..420e4f26 100644 --- a/spec/lib/secure_headers/configuration_spec.rb +++ b/spec/lib/secure_headers/configuration_spec.rb @@ -119,5 +119,94 @@ module SecureHeaders config = Configuration.dup expect(config.cookies).to eq({ httponly: true, secure: true, samesite: { lax: false } }) end + + describe ".disable!" do + it "disables secure_headers completely" do + Configuration.disable! + expect(Configuration.disabled?).to be true + end + + it "returns a noop config when disabled" do + Configuration.disable! + config = Configuration.send(:default_config) + Configuration::CONFIG_ATTRIBUTES.each do |attr| + expect(config.instance_variable_get("@#{attr}")).to eq(OPT_OUT) + end + end + + it "does not raise NotYetConfiguredError when disabled without default config" do + Configuration.disable! + expect { Configuration.send(:default_config) }.not_to raise_error + end + + it "registers the NOOP_OVERRIDE when disabled without calling default" do + Configuration.disable! + expect(Configuration.overrides(Configuration::NOOP_OVERRIDE)).to_not be_nil + end + + it "raises AlreadyConfiguredError when called after default" do + Configuration.default do |config| + config.csp = { default_src: %w('self'), script_src: %w('self') } + end + + expect { + Configuration.disable! + }.to raise_error(Configuration::AlreadyConfiguredError, "Configuration already set, cannot disable") + end + + it "raises AlreadyConfiguredError when default is called after disable!" do + Configuration.disable! + + expect { + Configuration.default do |config| + config.csp = { default_src: %w('self'), script_src: %w('self') } + end + }.to raise_error(Configuration::AlreadyConfiguredError, "Configuration has been disabled, cannot set default") + end + + it "allows default to be called after disable! and reset_config" do + Configuration.disable! + reset_config + + expect { + Configuration.default do |config| + config.csp = { default_src: %w('self'), script_src: %w('self') } + end + }.not_to raise_error + + # After reset_config, disabled? returns nil (not false) because @disabled is removed + expect(Configuration.disabled?).to be_falsy + expect(Configuration.instance_variable_defined?(:@default_config)).to be true + end + + it "works correctly with dup when library is disabled" do + Configuration.disable! + config = Configuration.dup + + Configuration::CONFIG_ATTRIBUTES.each do |attr| + expect(config.instance_variable_get("@#{attr}")).to eq(OPT_OUT) + end + end + + it "does not interfere with override mechanism" do + Configuration.disable! + + # Should be able to use opt_out_of_all_protection without error + request = Rack::Request.new("HTTP_X_FORWARDED_SSL" => "on") + expect { + SecureHeaders.opt_out_of_all_protection(request) + }.not_to raise_error + end + + it "interacts correctly with named overrides when disabled" do + Configuration.disable! + + Configuration.override(:test_override) do |config| + config.x_frame_options = "DENY" + end + + expect(Configuration.overrides(:test_override)).to_not be_nil + end + 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 c16e70a2..fc6ae5db 100644 --- a/spec/lib/secure_headers/headers/content_security_policy_spec.rb +++ b/spec/lib/secure_headers/headers/content_security_policy_spec.rb @@ -48,12 +48,20 @@ module SecureHeaders expect(csp.value).to eq("default-src * 'unsafe-inline' 'unsafe-eval' data: blob:") end + it "normalizes source expressions that end with a trailing /" do + config = { + default_src: %w(a.example.org/ b.example.com/ wss://c.example.com/ c.example.net/foo/ b.example.co/bar wss://b.example.co/) + } + csp = ContentSecurityPolicy.new(config) + expect(csp.value).to eq("default-src a.example.org b.example.com wss://c.example.com c.example.net/foo/ b.example.co/bar wss://b.example.co") + end + it "does not minify source expressions based on overlapping wildcards" do config = { - default_src: %w(a.example.org b.example.org *.example.org https://*.example.org) + default_src: %w(a.example.org b.example.org *.example.org https://*.example.org c.example.org/) } csp = ContentSecurityPolicy.new(config) - expect(csp.value).to eq("default-src a.example.org b.example.org *.example.org") + expect(csp.value).to eq("default-src a.example.org b.example.org *.example.org c.example.org") end it "removes http/s schemes from hosts" do @@ -210,6 +218,31 @@ module SecureHeaders 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/policy_management_spec.rb b/spec/lib/secure_headers/headers/policy_management_spec.rb index 99065744..626a1a82 100644 --- a/spec/lib/secure_headers/headers/policy_management_spec.rb +++ b/spec/lib/secure_headers/headers/policy_management_spec.rb @@ -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 f019b597..c3478641 100644 --- a/spec/lib/secure_headers/middleware_spec.rb +++ b/spec/lib/secure_headers/middleware_spec.rb @@ -83,7 +83,7 @@ module SecureHeaders end 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"] }) + cookie_middleware = Middleware.new(lambda { |env| [200, env.merge("Set-Cookie" => "_session=foobar\n_guest=true"), "app"] }) Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } }, httponly: OPT_OUT, secure: OPT_OUT } } request = Rack::Request.new("HTTPS" => "on") @@ -93,6 +93,16 @@ module SecureHeaders expect(env["Set-Cookie"]).to match("_guest=true; SameSite=Lax") end + it "keeps cookies as array after flagging if they are already an array" 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 } } + request = Rack::Request.new("HTTPS" => "on") + _, env = cookie_middleware.call request.env + + expect(env["Set-Cookie"]).to match_array(["_session=foobar; SameSite=Strict", "_guest=true; SameSite=Lax"]) + end + it "disables secure cookies for non-https requests" do Configuration.default { |config| config.cookies = { secure: true, httponly: OPT_OUT, samesite: OPT_OUT } } @@ -113,5 +123,33 @@ module SecureHeaders expect(env["Set-Cookie"]).to eq("foo=bar; secure") end end + + context "when disabled" do + before(:each) do + reset_config + Configuration.disable! + end + + it "does not set any headers" do + _, env = middleware.call(Rack::MockRequest.env_for("https://localhost", {})) + + # Verify no security headers are set by checking all configured header classes + Configuration::HEADERABLE_ATTRIBUTES.each do |attr| + klass = Configuration::CONFIG_ATTRIBUTES_TO_HEADER_CLASSES[attr] + # Handle CSP specially since it has multiple classes + if attr == :csp + expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to be_nil + expect(env[ContentSecurityPolicyReportOnlyConfig::HEADER_NAME]).to be_nil + elsif klass.const_defined?(:HEADER_NAME) + expect(env[klass::HEADER_NAME]).to be_nil + end + end + end + + it "does not flag cookies" do + _, env = cookie_middleware.call(Rack::MockRequest.env_for("https://localhost", {})) + expect(env["Set-Cookie"]).to eq("foo=bar") + end + end end end diff --git a/spec/lib/secure_headers/task_helper_spec.rb b/spec/lib/secure_headers/task_helper_spec.rb new file mode 100644 index 00000000..8be633f5 --- /dev/null +++ b/spec/lib/secure_headers/task_helper_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true +require "spec_helper" +require "secure_headers/task_helper" + +class TestHelper + include SecureHeaders::TaskHelper +end + +module SecureHeaders + describe TaskHelper do + subject { TestHelper.new } + + let(:template) do + < + + + + <%= hashed_javascript_tag do %> + alert("Using the helper tag!") + <% end %> + <%= hashed_style_tag do %> + p { text-decoration: underline; } + <% end %> + + +

Testing

+ + +EOT + end + + let(:template_unindented) do + < + + + + <%= hashed_javascript_tag do %> + alert("Using the helper tag!") +<% end %> + <%= hashed_style_tag do %> + p { text-decoration: underline; } +<% end %> + + +

Testing

+ + +EOT + end + + describe "#generate_inline_script_hashes" do + let(:expected_hashes) do + [ + "'sha256-EE/znQZ7BcfM3LbsqxUc5JlCtE760Pc2RV18tW90DCo='", + "'sha256-64ro9ciexeO5JqSZcAnhmJL4wbzCrpsZJLWl5H6mrkA='" + ] + end + + it "returns an array of found script hashes" do + Tempfile.create("script") do |f| + f.write template + f.flush + expect(subject.generate_inline_script_hashes(f.path)).to eq expected_hashes + end + end + it "returns the same array no matter the indentation of helper end tags" do + Tempfile.create("script") do |f| + f.write template_unindented + f.flush + expect(subject.generate_inline_script_hashes(f.path)).to eq expected_hashes + end + end + end + + describe "#generate_inline_style_hashes" do + let(:expected_hashes) do + [ + "'sha256-pckGv9YvNcB5xy+Y4fbqhyo+ib850wyiuWeNbZvLi00='", + "'sha256-d374zYt40cLTr8J7Cvm/l4oDY4P9UJ8TWhYG0iEglU4='" + ] + end + + it "returns an array of found style hashes" do + Tempfile.create("style") do |f| + f.write template + f.flush + expect(subject.generate_inline_style_hashes(f.path)).to eq expected_hashes + end + end + it "returns the same array no matter the indentation of helper end tags" do + Tempfile.create("style") do |f| + f.write template_unindented + f.flush + expect(subject.generate_inline_style_hashes(f.path)).to eq expected_hashes + end + end + end + + describe "#dynamic_content?" do + context "mustache file" do + it "finds mustache templating tokens" do + expect(subject.dynamic_content?("file.mustache", "var test = {{ dynamic_value }};")).to be true + end + + it "returns false when not finding any templating tokens" do + expect(subject.dynamic_content?("file.mustache", "var test = 'static value';")).to be false + end + end + + context "erb file" do + it "finds erb templating tokens" do + expect(subject.dynamic_content?("file.erb", "var test = <%= dynamic_value %>;")).to be true + end + + it "returns false when not finding any templating tokens" do + expect(subject.dynamic_content?("file.erb", "var test = 'static value';")).to be false + end + end + end + end +end diff --git a/spec/lib/secure_headers_spec.rb b/spec/lib/secure_headers_spec.rb index 843b9f1e..4cc5255b 100644 --- a/spec/lib/secure_headers_spec.rb +++ b/spec/lib/secure_headers_spec.rb @@ -112,6 +112,12 @@ module SecureHeaders expect(hash.count).to eq(0) end + it "allows you to disable secure_headers entirely via Configuration.disable!" do + Configuration.disable! + hash = SecureHeaders.header_hash_for(request) + expect(hash.count).to eq(0) + end + it "allows you to override x-frame-options settings" do Configuration.default SecureHeaders.override_x_frame_options(request, XFrameOptions::DENY) @@ -436,6 +442,68 @@ module SecureHeaders end end + + it "does not set upgrade-insecure-requests if request is over HTTP" do + reset_config + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + upgrade_insecure_requests: true + } + end + + plaintext_request = Rack::Request.new({}) + hash = SecureHeaders.header_hash_for(plaintext_request) + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self'; script-src 'self'") + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).not_to include("upgrade-insecure-requests") + end + + it "sets upgrade-insecure-requests if request is over HTTPS" do + reset_config + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + upgrade_insecure_requests: true + } + end + + https_request = Rack::Request.new("HTTPS" => "on") + hash = SecureHeaders.header_hash_for(https_request) + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self'; script-src 'self'; upgrade-insecure-requests") + end + + it "does not set upgrade-insecure-requests in report-only mode if request is over HTTP" do + reset_config + Configuration.default do |config| + config.csp_report_only = { + default_src: %w('self'), + script_src: %w('self'), + upgrade_insecure_requests: true + } + end + + plaintext_request = Rack::Request.new({}) + hash = SecureHeaders.header_hash_for(plaintext_request) + expect(hash[ContentSecurityPolicyReportOnlyConfig::HEADER_NAME]).to eq("default-src 'self'; script-src 'self'") + expect(hash[ContentSecurityPolicyReportOnlyConfig::HEADER_NAME]).not_to include("upgrade-insecure-requests") + end + + it "sets upgrade-insecure-requests in report-only mode if request is over HTTPS" do + reset_config + Configuration.default do |config| + config.csp_report_only = { + default_src: %w('self'), + script_src: %w('self'), + upgrade_insecure_requests: true + } + end + + https_request = Rack::Request.new("HTTPS" => "on") + hash = SecureHeaders.header_hash_for(https_request) + expect(hash[ContentSecurityPolicyReportOnlyConfig::HEADER_NAME]).to eq("default-src 'self'; script-src 'self'; upgrade-insecure-requests") + end end end @@ -527,6 +595,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 65627eec..0685312d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -52,6 +52,11 @@ def self.clear_overrides def self.clear_appends remove_instance_variable(:@appends) if defined?(@appends) end + + def self.clear_disabled + remove_instance_variable(:@disabled) if defined?(@disabled) + remove_instance_variable(:@noop_config) if defined?(@noop_config) + end end end @@ -59,4 +64,5 @@ def reset_config SecureHeaders::Configuration.clear_default_config SecureHeaders::Configuration.clear_overrides SecureHeaders::Configuration.clear_appends + SecureHeaders::Configuration.clear_disabled end