Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ coverage
pkg
rdoc
spec/reports
vendor/bundle
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ 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
```

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.
Expand Down
47 changes: 42 additions & 5 deletions lib/secure_headers/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ 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 should be called before Configuration.default. Calling it after
# Configuration.default has been set will clear the default configuration.
#
# Returns nothing
def disable!
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When disable! is called without ever calling default, the NOOP_OVERRIDE will not be registered (since the override registration happens in the default method at line 39). This means that calling SecureHeaders.opt_out_of_all_protection(request) when the library is disabled will raise an ArgumentError because the NOOP_OVERRIDE doesn't exist.

While this may be acceptable behavior (since the library is already disabled), it creates an inconsistent API. Consider either:

  1. Registering the NOOP_OVERRIDE in disable! as well
  2. Documenting this limitation in the method comments
  3. Having opt_out_of_all_protection check if the library is disabled first
Suggested change
def disable!
def disable!
# Ensure the built-in NOOP override is available even if `default` has never been called.
override(NOOP_OVERRIDE, &method(:create_noop_config_block)) unless defined?(named_append_or_override_exists?) && named_append_or_override_exists?(NOOP_OVERRIDE)

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disable! method can be called after Configuration.default has been set, which creates an inconsistent state. The @default_config instance variable will still be defined and set, but disabled? will return true, causing default_config to return @noop_config instead. This breaks the invariant that @default_config should be the single source of truth.

Consider either:

  1. Preventing disable! from being called after configuration has been set (raise an error similar to AlreadyConfiguredError)
  2. Clearing @default_config when disable! is called to maintain consistency
Suggested change
def disable!
def disable!
remove_instance_variable(:@default_config) if defined?(@default_config)

Copilot uses AI. Check for mistakes.
# Clear any existing default config to maintain consistency
remove_instance_variable(:@default_config) if defined?(@default_config)

@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.
Expand All @@ -21,11 +48,7 @@ def default(&block)

# 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!
Expand Down Expand Up @@ -101,6 +124,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
Expand All @@ -116,6 +140,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 = {
Expand Down
80 changes: 80 additions & 0 deletions spec/lib/secure_headers/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,85 @@ 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 "clears existing default config when called after default" do
Configuration.default do |config|
config.csp = { default_src: %w('self'), script_src: %w('self') }
end

Configuration.disable!

expect(Configuration.disabled?).to be true
expect(Configuration.instance_variable_defined?(:@default_config)).to be false
end

it "allows default to be called after disable! has been invoked" 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
28 changes: 28 additions & 0 deletions spec/lib/secure_headers/middleware_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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://looocalhost", {}))

# 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://looocalhost", {}))
expect(env["Set-Cookie"]).to eq("foo=bar")
end
end
end
end
6 changes: 6 additions & 0 deletions spec/lib/secure_headers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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