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
41 changes: 41 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
3 changes: 2 additions & 1 deletion 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 @@ -208,7 +209,7 @@ def raise_on_unknown_target(target)

def config_and_target(request, target)
config = config_for(request)
target = guess_target(config) unless target
target ||= guess_target(config)
raise_on_unknown_target(target)
[config, target]
end
Expand Down
3 changes: 3 additions & 0 deletions lib/secure_headers/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,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 +168,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 +194,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
62 changes: 30 additions & 32 deletions lib/secure_headers/headers/clear_site_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,41 @@ class ClearSiteData
EXECUTION_CONTEXTS = "executionContexts".freeze
ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]

class << self
# Public: make an clear-site-data header name, value pair
#
# Returns nil if not configured, returns header name and value if configured.
def make_header(config = nil, user_agent = nil)
case config
when nil, OPT_OUT, []
# noop
when Array
[HEADER_NAME, make_header_value(config)]
when true
[HEADER_NAME, make_header_value(ALL_TYPES)]
end
# Public: make an clear-site-data header name, value pair
#
# Returns nil if not configured, returns header name and value if configured.
def self.make_header(config = nil, user_agent = nil)
case config
when nil, OPT_OUT, []
# noop
when Array
[HEADER_NAME, make_header_value(config)]
when true
[HEADER_NAME, make_header_value(ALL_TYPES)]
end
end

def validate_config!(config)
case config
when nil, OPT_OUT, true
# valid
when Array
unless config.all? { |t| t.is_a?(String) }
raise ClearSiteDataConfigError.new("types must be Strings")
end
else
raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
def self.validate_config!(config)
case config
when nil, OPT_OUT, true
# valid
when Array
unless config.all? { |t| t.is_a?(String) }
raise ClearSiteDataConfigError.new("types must be Strings")
end
else
raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
end
end

# Public: Transform a clear-site-data config (an Array of Strings) into a
# String that can be used as the value for the clear-site-data header.
#
# types - An Array of String of types of data to clear.
#
# Returns a String of quoted values that are comma separated.
def make_header_value(types)
types.map { |t| %("#{t}") }.join(", ")
end
# Public: Transform a clear-site-data config (an Array of Strings) into a
# String that can be used as the value for the clear-site-data header.
#
# types - An Array of String of types of data to clear.
#
# Returns a String of quoted values that are comma separated.
def self.make_header_value(types)
types.map { |t| %("#{t}") }.join(", ")
end
end
end
12 changes: 11 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 @@ -179,11 +188,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
6 changes: 2 additions & 4 deletions lib/secure_headers/headers/cookie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ module SecureHeaders
class CookiesConfigError < StandardError; end
class Cookie

class << self
def validate_config!(config)
CookiesConfig.new(config).validate!
end
def self.validate_config!(config)
CookiesConfig.new(config).validate!
end

attr_reader :raw_cookie, :config
Expand Down
40 changes: 19 additions & 21 deletions lib/secure_headers/headers/expect_certificate_transparency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,29 @@ class ExpectCertificateTransparency
REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze

class << self
# Public: Generate a expect-ct header.
#
# Returns nil if not configured, returns header name and value if
# configured.
def make_header(config, use_agent = nil)
return if config.nil? || config == OPT_OUT
# Public: Generate a expect-ct header.
#
# Returns nil if not configured, returns header name and value if
# configured.
def self.make_header(config, use_agent = nil)
return if config.nil? || config == OPT_OUT

header = new(config)
[HEADER_NAME, header.value]
end
header = new(config)
[HEADER_NAME, header.value]
end

def validate_config!(config)
return if config.nil? || config == OPT_OUT
raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
def self.validate_config!(config)
return if config.nil? || config == OPT_OUT
raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash

unless [true, false, nil].include?(config[:enforce])
raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
end
unless [true, false, nil].include?(config[:enforce])
raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
end

if !config[:max_age]
raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
elsif config[:max_age].to_s !~ /\A\d+\z/
raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
end
if !config[:max_age]
raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
elsif config[:max_age].to_s !~ /\A\d+\z/
raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
end
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
40 changes: 19 additions & 21 deletions lib/secure_headers/headers/referrer_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,27 @@ class ReferrerPolicy
unsafe-url
)

class << self
# Public: generate an Referrer Policy header.
#
# Returns a default header if no configuration is provided, or a
# header name and value based on the config.
def make_header(config = nil, user_agent = nil)
return if config == OPT_OUT
config ||= DEFAULT_VALUE
[HEADER_NAME, Array(config).join(", ")]
end
# Public: generate an Referrer Policy header.
#
# Returns a default header if no configuration is provided, or a
# header name and value based on the config.
def self.make_header(config = nil, user_agent = nil)
return if config == OPT_OUT
config ||= DEFAULT_VALUE
[HEADER_NAME, Array(config).join(", ")]
end

def validate_config!(config)
case config
when nil, OPT_OUT
# valid
when String, Array
config = Array(config)
unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) }
raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}")
end
else
raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}")
def self.validate_config!(config)
case config
when nil, OPT_OUT
# valid
when String, Array
config = Array(config)
unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) }
raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}")
end
else
raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}")
end
end
end
Expand Down
Loading
Loading