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
1 change: 1 addition & 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
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
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
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
51 changes: 51 additions & 0 deletions lib/secure_headers/headers/reporting_endpoints.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true
module SecureHeaders
class ReportingEndpointsConfigError < StandardError; end
class ReportingEndpoints
HEADER_NAME = "reporting-endpoints".freeze

class << self
# Public: generate a Reporting-Endpoints header.
#
# The config should be a Hash of endpoint names to URLs.
# Example: { "csp-endpoint" => "https://example.com/reports" }
#
# Returns nil if config is OPT_OUT or nil, or a header name and
# formatted header value based on the config.
def make_header(config = nil)
return if config.nil? || config == OPT_OUT
validate_config!(config)
[HEADER_NAME, format_endpoints(config)]
end

def validate_config!(config)
case config
when nil, OPT_OUT
# valid
when Hash
config.each_pair do |name, url|
unless name.is_a?(String) && !name.empty?
raise ReportingEndpointsConfigError.new("Endpoint name must be a non-empty string, got: #{name.inspect}")
end
unless url.is_a?(String) && !url.empty?
raise ReportingEndpointsConfigError.new("Endpoint URL must be a non-empty string, got: #{url.inspect}")
end
unless url.start_with?("https://")
raise ReportingEndpointsConfigError.new("Endpoint URLs must use https, got: #{url.inspect}")
end
end
else
raise TypeError.new("Must be a Hash of endpoint names to URLs. Found #{config.class}: #{config}")
end
end

private

def format_endpoints(config)
config.map do |name, url|
%{#{name}="#{url}"}
end.join(", ")
end
end
end
end
25 changes: 25 additions & 0 deletions spec/lib/secure_headers/headers/content_security_policy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,31 @@ module SecureHeaders
csp = ContentSecurityPolicy.new({ trusted_types: %w(blahblahpolicy 'allow-duplicates') })
expect(csp.value).to eq("trusted-types blahblahpolicy 'allow-duplicates'")
end

it "supports report-to directive with endpoint name" do
csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_to: "csp-endpoint" })
expect(csp.value).to eq("default-src 'self'; report-to csp-endpoint")
end

it "includes report-to before report-uri in alphabetical order" do
csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_uri: %w(/csp_report), report_to: "csp-endpoint" })
expect(csp.value).to eq("default-src 'self'; report-to csp-endpoint; report-uri /csp_report")
end

it "does not add report-to if the endpoint name is empty" do
csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_to: "" })
expect(csp.value).to eq("default-src 'self'")
end

it "does not add report-to if not provided" do
csp = ContentSecurityPolicy.new({ default_src: %w('self') })
expect(csp.value).not_to include("report-to")
end

it "supports report-to without report-uri" do
csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_to: "reporting-endpoint-name" })
expect(csp.value).to eq("default-src 'self'; report-to reporting-endpoint-name")
end
end
end
end
24 changes: 24 additions & 0 deletions spec/lib/secure_headers/headers/policy_management_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,30 @@ module SecureHeaders
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyReportOnlyConfig.new(default_opts.merge(report_only: true)))
end.to_not raise_error
end

it "requires report_to to be a string" do
expect do
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: ["endpoint"])))
end.to raise_error(ContentSecurityPolicyConfigError)
end

it "rejects empty report_to endpoint names" do
expect do
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: "")))
end.to raise_error(ContentSecurityPolicyConfigError)
end

it "accepts valid report_to endpoint names" do
expect do
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: "csp-endpoint")))
end.to_not raise_error
end

it "accepts report_to with hyphens and underscores" do
expect do
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: "csp-endpoint_name-123")))
end.to_not raise_error
end
end

describe "#combine_policies" do
Expand Down
Loading