diff --git a/README.md b/README.md index 4682249d..39d2f9e3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index 4b288c8e..42d30779 100644 --- a/lib/secure_headers.rb +++ b/lib/secure_headers.rb @@ -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" diff --git a/lib/secure_headers/configuration.rb b/lib/secure_headers/configuration.rb index e96f4f9d..1c83263a 100644 --- a/lib/secure_headers/configuration.rb +++ b/lib/secure_headers/configuration.rb @@ -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 @@ -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) @@ -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 diff --git a/lib/secure_headers/headers/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index ae225e7c..9d4a0242 100644 --- a/lib/secure_headers/headers/content_security_policy.rb +++ b/lib/secure_headers/headers/content_security_policy.rb @@ -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 @@ -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 @@ -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 diff --git a/lib/secure_headers/headers/policy_management.rb b/lib/secure_headers/headers/policy_management.rb index 3129c0d3..48fe1bdf 100644 --- a/lib/secure_headers/headers/policy_management.rb +++ b/lib/secure_headers/headers/policy_management.rb @@ -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, @@ -51,7 +52,8 @@ def self.included(base) SANDBOX, SCRIPT_SRC, STYLE_SRC, - REPORT_URI + REPORT_URI, + REPORT_TO ].freeze BASE_URI = :base_uri @@ -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, @@ -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, @@ -158,6 +161,7 @@ def self.included(base) FORM_ACTION, FRAME_ANCESTORS, NAVIGATE_TO, + REPORT_TO, REPORT_URI, ] @@ -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 @@ -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) diff --git a/lib/secure_headers/headers/reporting_endpoints.rb b/lib/secure_headers/headers/reporting_endpoints.rb new file mode 100644 index 00000000..c5b048ff --- /dev/null +++ b/lib/secure_headers/headers/reporting_endpoints.rb @@ -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 diff --git a/spec/lib/secure_headers/headers/content_security_policy_spec.rb b/spec/lib/secure_headers/headers/content_security_policy_spec.rb index c16e70a2..ed8d8e2b 100644 --- a/spec/lib/secure_headers/headers/content_security_policy_spec.rb +++ b/spec/lib/secure_headers/headers/content_security_policy_spec.rb @@ -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 diff --git a/spec/lib/secure_headers/headers/policy_management_spec.rb b/spec/lib/secure_headers/headers/policy_management_spec.rb index 99065744..626a1a82 100644 --- a/spec/lib/secure_headers/headers/policy_management_spec.rb +++ b/spec/lib/secure_headers/headers/policy_management_spec.rb @@ -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 diff --git a/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb b/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb new file mode 100644 index 00000000..286e204c --- /dev/null +++ b/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true +require "spec_helper" + +module SecureHeaders + describe ReportingEndpoints do + describe "#make_header" do + it "returns nil when config is nil" do + expect(ReportingEndpoints.make_header(nil)).to be_nil + end + + it "returns nil when config is OPT_OUT" do + expect(ReportingEndpoints.make_header(OPT_OUT)).to be_nil + end + + it "formats a single endpoint" do + config = { "csp-endpoint" => "https://example.com/csp-reports" } + header_name, value = ReportingEndpoints.make_header(config) + expect(header_name).to eq("reporting-endpoints") + expect(value).to eq('csp-endpoint="https://example.com/csp-reports"') + end + + it "formats multiple endpoints" do + config = { + "csp-endpoint" => "https://example.com/csp-reports", + "permissions-endpoint" => "https://example.com/permissions-reports" + } + header_name, value = ReportingEndpoints.make_header(config) + expect(header_name).to eq("reporting-endpoints") + # Order may vary, so check both endpoints are present + expect(value).to include('csp-endpoint="https://example.com/csp-reports"') + expect(value).to include('permissions-endpoint="https://example.com/permissions-reports"') + expect(value).to include(",") + end + + it "validates that endpoints are present" do + expect do + ReportingEndpoints.validate_config!({}) + end.to_not raise_error + end + end + + describe "#validate_config!" do + it "accepts nil" do + expect do + ReportingEndpoints.validate_config!(nil) + end.to_not raise_error + end + + it "accepts OPT_OUT" do + expect do + ReportingEndpoints.validate_config!(OPT_OUT) + end.to_not raise_error + end + + it "accepts valid endpoint configuration" do + expect do + ReportingEndpoints.validate_config!({ + "csp-violations" => "https://example.com/reports" + }) + end.to_not raise_error + end + + it "rejects non-hash config" do + expect do + ReportingEndpoints.validate_config!("not a hash") + end.to raise_error(TypeError) + end + + it "rejects empty endpoint name" do + expect do + ReportingEndpoints.validate_config!({ + "" => "https://example.com/reports" + }) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "rejects non-string endpoint name" do + expect do + ReportingEndpoints.validate_config!({ + 123 => "https://example.com/reports" + }) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "rejects empty endpoint URL" do + expect do + ReportingEndpoints.validate_config!({ + "csp-endpoint" => "" + }) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "rejects non-string endpoint URL" do + expect do + ReportingEndpoints.validate_config!({ + "csp-endpoint" => 123 + }) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "rejects non-https URLs" do + expect do + ReportingEndpoints.validate_config!({ + "csp-endpoint" => "http://example.com/reports" + }) + end.to raise_error(ReportingEndpointsConfigError, /must use https/) + end + end + end +end diff --git a/spec/lib/secure_headers_spec.rb b/spec/lib/secure_headers_spec.rb index 76d51baf..5de8409a 100644 --- a/spec/lib/secure_headers_spec.rb +++ b/spec/lib/secure_headers_spec.rb @@ -527,6 +527,146 @@ module SecureHeaders end end.to raise_error(CookiesConfigError) end + + it "validates report_to directive on configuration" do + expect do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + report_to: ["not_a_string"] + } + end + end.to raise_error(ContentSecurityPolicyConfigError) + end + + it "allows report_to directive with string endpoint" do + expect do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + report_to: "csp-endpoint" + } + end + end.to_not raise_error + end + end + + describe "report_to with overrides and appends" do + let(:request) { double("Request", scheme: "https", env: {}) } + + it "overrides the report_to directive" do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + report_to: "endpoint-1" + } + end + + SecureHeaders.override_content_security_policy_directives(request, report_to: "endpoint-2") + headers = SecureHeaders.header_hash_for(request) + csp_header = headers[ContentSecurityPolicyConfig::HEADER_NAME] + expect(csp_header).to include("report-to endpoint-2") + end + + it "includes report_to when appending CSP directives" do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self') + } + end + + SecureHeaders.append_content_security_policy_directives(request, report_to: "new-endpoint") + headers = SecureHeaders.header_hash_for(request) + csp_header = headers[ContentSecurityPolicyConfig::HEADER_NAME] + expect(csp_header).to include("report-to new-endpoint") + end + + it "handles report_to with report_uri together" do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self'), + report_uri: %w(/csp-report), + report_to: "reporting-endpoint" + } + end + + headers = SecureHeaders.header_hash_for(request) + csp_header = headers[ContentSecurityPolicyConfig::HEADER_NAME] + # Both should be present + expect(csp_header).to include("report-to reporting-endpoint") + expect(csp_header).to include("report-uri /csp-report") + # report-to should come before report-uri (alphabetical order) + expect(csp_header.index("report-to")).to be < csp_header.index("report-uri") + end + end + + describe "reporting_endpoints header generation" do + let(:request) { double("Request", scheme: "https", env: {}) } + + before(:each) do + reset_config + end + + it "includes reporting_endpoints header in generated headers" do + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self') + } + config.reporting_endpoints = { + "csp-endpoint" => "https://example.com/reports" + } + end + + headers = SecureHeaders.header_hash_for(request) + expect(headers["reporting-endpoints"]).to eq('csp-endpoint="https://example.com/reports"') + end + + it "includes reporting_endpoints after config.dup() is called" do + # This test specifically validates that reporting_endpoints survives + # the .dup() call made by the middleware + Configuration.default do |config| + config.csp = { + default_src: %w('self'), + script_src: %w('self') + } + config.reporting_endpoints = { + "csp-violations" => "https://api.example.com/reports?enforcement=enforce", + "csp-violations-report-only" => "https://api.example.com/reports?enforcement=report-only" + } + end + + # Simulate what the middleware does internally + config = Configuration.dup # ← This calls .dup() which must preserve reporting_endpoints + headers = config.generate_headers + + expect(headers["reporting-endpoints"]).to include('csp-violations="https://api.example.com/reports?enforcement=enforce"') + expect(headers["reporting-endpoints"]).to include('csp-violations-report-only="https://api.example.com/reports?enforcement=report-only"') + end + + it "does not include reporting_endpoints header when OPT_OUT" do + Configuration.default do |config| + config.csp = { default_src: %w('self'), script_src: %w('self') } + config.reporting_endpoints = OPT_OUT + end + + headers = SecureHeaders.header_hash_for(request) + expect(headers["reporting-endpoints"]).to be_nil + end + + it "does not include reporting_endpoints header when not configured" do + Configuration.default do |config| + config.csp = { default_src: %w('self'), script_src: %w('self') } + end + + headers = SecureHeaders.header_hash_for(request) + expect(headers["reporting-endpoints"]).to be_nil + end end end end