Skip to content
Draft
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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions lib/secure_headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
57 changes: 52 additions & 5 deletions lib/secure_headers/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
33 changes: 32 additions & 1 deletion lib/secure_headers/headers/content_security_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions lib/secure_headers/headers/policy_management.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -51,7 +52,8 @@ def self.included(base)
SANDBOX,
SCRIPT_SRC,
STYLE_SRC,
REPORT_URI
REPORT_URI,
REPORT_TO
].freeze

BASE_URI = :base_uri
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -158,6 +161,7 @@ def self.included(base)
FORM_ACTION,
FRAME_ANCESTORS,
NAVIGATE_TO,
REPORT_TO,
REPORT_URI,
]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading