diff --git a/README.md b/README.md index 4682249d..cedec61d 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,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..5b534f21 100644 --- a/lib/secure_headers.rb +++ b/lib/secure_headers.rb @@ -133,6 +133,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 +147,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 +248,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..170872da 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 = { diff --git a/lib/secure_headers/headers/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index 055771f0..06c81b30 100644 --- a/lib/secure_headers/headers/content_security_policy.rb +++ b/lib/secure_headers/headers/content_security_policy.rb @@ -129,6 +129,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 +152,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) 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 = /( + + <%= hashed_javascript_tag do %> + alert("Using the helper tag!") + <% end %> + <%= hashed_style_tag do %> + p { text-decoration: underline; } + <% end %> + +
+Testing
+ +