diff --git a/README.md b/README.md index 114cb7b4..b63d90e4 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/configuration.rb b/lib/secure_headers/configuration.rb index e96f4f9d..d6ee2bbb 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/spec/lib/secure_headers/configuration_spec.rb b/spec/lib/secure_headers/configuration_spec.rb index ea3e10e9..522e2869 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/middleware_spec.rb b/spec/lib/secure_headers/middleware_spec.rb index 1dedbc37..fb9e50f2 100644 --- a/spec/lib/secure_headers/middleware_spec.rb +++ b/spec/lib/secure_headers/middleware_spec.rb @@ -123,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_spec.rb b/spec/lib/secure_headers_spec.rb index fd66d487..b4bf9aa9 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) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b0c774d9..598bc11d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -53,6 +53,11 @@ def clear_overrides def clear_appends remove_instance_variable(:@appends) if defined?(@appends) end + + def clear_disabled + remove_instance_variable(:@disabled) if defined?(@disabled) + remove_instance_variable(:@noop_config) if defined?(@noop_config) + end end end end @@ -61,4 +66,5 @@ def reset_config SecureHeaders::Configuration.clear_default_config SecureHeaders::Configuration.clear_overrides SecureHeaders::Configuration.clear_appends + SecureHeaders::Configuration.clear_disabled end