From 7373ce982d9220752fbc563be6f9c975c4662363 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Oct 2025 14:27:25 +0000 Subject: [PATCH] Add support for W3C Reporting API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements support for the W3C Reporting API (https://w3c.github.io/reporting/) to enable standardized browser reporting for security violations and other issues. Changes include: 1. New Reporting-Endpoints Header: - Added ReportingEndpoints header class to configure named reporting endpoints - Accepts hash configuration: { default: "https://example.com/reports" } - Generates header: Reporting-Endpoints: default="https://example.com/reports" 2. CSP report-to Directive: - Added report_to directive to Content Security Policy - New :string directive type for single token values - Positioned before legacy report-uri directive for clarity 3. Configuration Updates: - Registered reporting_endpoints in CONFIG_ATTRIBUTES_TO_HEADER_CLASSES - Added report_to to DIRECTIVES_3_0 (CSP Level 3) - Updated NON_FETCH_SOURCES to include report_to 4. Tests: - Complete test coverage for ReportingEndpoints header - CSP tests for report-to directive - Integration tests for both headers working together 5. Documentation: - Added W3C Reporting API section to README - Usage examples for both modern and legacy browser support - Configuration examples showing endpoint definition and CSP integration Addresses issue #512 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 7 ++- lib/secure_headers.rb | 1 + lib/secure_headers/configuration.rb | 1 + .../headers/content_security_policy.rb | 12 +++- .../headers/policy_management.rb | 12 +++- .../headers/reporting_endpoints.rb | 47 ++++++++++++++++ .../headers/content_security_policy_spec.rb | 15 +++++ .../headers/reporting_endpoints_spec.rb | 56 +++++++++++++++++++ 8 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 lib/secure_headers/headers/reporting_endpoints.rb create mode 100644 spec/lib/secure_headers/headers/reporting_endpoints_spec.rb diff --git a/README.md b/README.md index 114cb7b4..c51d0035 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The gem will automatically apply several headers that are related to security. - referrer-policy - [Referrer Policy draft](https://w3c.github.io/webappsec-referrer-policy/) - expect-ct - Only use certificates that are present in the certificate transparency logs. [expect-ct draft specification](https://datatracker.ietf.org/doc/draft-stark-expect-ct/). - clear-site-data - Clearing browser data for origin. [clear-site-data specification](https://w3c.github.io/webappsec-clear-site-data/). +- reporting-endpoints - Configure endpoints for the W3C Reporting API. [Reporting API specification](https://w3c.github.io/reporting/). It can also mark all http cookies with the Secure, HttpOnly and SameSite attributes. This is on default but can be turned off by using `config.cookies = SecureHeaders::OPT_OUT`. @@ -54,6 +55,9 @@ SecureHeaders::Configuration.default do |config| config.x_download_options = "noopen" config.x_permitted_cross_domain_policies = "none" config.referrer_policy = %w(origin-when-cross-origin strict-origin-when-cross-origin) + config.reporting_endpoints = { + default: "https://report-uri.io/example-reporting" + } config.csp = { # "meta" values. these will shape the header, but the values are not included in the header. preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content. @@ -81,7 +85,8 @@ SecureHeaders::Configuration.default do |config| style_src_attr: %w('unsafe-inline'), worker_src: %w('self'), upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/ - report_uri: %w(https://report-uri.io/example-csp) + report_to: 'default', # W3C Reporting API endpoint name (modern browsers) + report_uri: %w(https://report-uri.io/example-csp) # Legacy reporting (older browsers) } # This is available only from 3.5.0; use the `report_only: true` setting for 3.4.1 and below. config.csp_report_only = config.csp.merge({ diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index 6426e538..227ebb1b 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..0994e4e7 100644 --- a/lib/secure_headers/configuration.rb +++ b/lib/secure_headers/configuration.rb @@ -128,6 +128,7 @@ def deep_copy_if_hash(value) referrer_policy: ReferrerPolicy, clear_site_data: ClearSiteData, expect_certificate_transparency: ExpectCertificateTransparency, + reporting_endpoints: ReportingEndpoints, csp: ContentSecurityPolicy, csp_report_only: ContentSecurityPolicy, cookies: Cookie, diff --git a/lib/secure_headers/headers/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index ae225e7c..d7a60442 100644 --- a/lib/secure_headers/headers/content_security_policy.rb +++ b/lib/secure_headers/headers/content_security_policy.rb @@ -46,7 +46,7 @@ def value private # Private: converts the config object into a string representing a policy. - # Places default-src at the first directive and report-uri as the last. All + # Places default-src at the first directive and report-to/report-uri as the last. All # others are presented in alphabetical order. # # Returns a content security policy header value. @@ -59,6 +59,8 @@ def build_value build_source_list_directive(directive_name) when :boolean symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name) + when :string + build_string_directive(directive_name) when :sandbox_list build_sandbox_list_directive(directive_name) when :media_type_list @@ -67,6 +69,11 @@ def build_value end.compact.join("; ") end + def build_string_directive(directive) + return unless string_value = @config.directive_value(directive) + [symbol_to_hyphen_case(directive), string_value].join(" ") + end + def build_sandbox_list_directive(directive) return unless sandbox_list = @config.directive_value(directive) max_strict_policy = case sandbox_list @@ -179,11 +186,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 report-to and report-uri. 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..5d17101e 100644 --- a/lib/secure_headers/headers/policy_management.rb +++ b/lib/secure_headers/headers/policy_management.rb @@ -38,6 +38,7 @@ def self.included(base) SANDBOX = :sandbox SCRIPT_SRC = :script_src STYLE_SRC = :style_src + REPORT_TO = :report_to REPORT_URI = :report_uri DIRECTIVES_1_0 = [ @@ -87,6 +88,7 @@ def self.included(base) MANIFEST_SRC, NAVIGATE_TO, PREFETCH_SRC, + REPORT_TO, REQUIRE_SRI_FOR, WORKER_SRC, UPGRADE_INSECURE_REQUESTS, @@ -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 as the beginning and report-to/report-uri as the end, # everything else is in between. - BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI] + BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_TO, REPORT_URI] DIRECTIVE_VALUE_TYPES = { BASE_URI => :source_list, @@ -131,6 +133,7 @@ def self.included(base) PLUGIN_TYPES => :media_type_list, REQUIRE_SRI_FOR => :require_sri_for_list, REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list, + REPORT_TO => :string, REPORT_URI => :source_list, PREFETCH_SRC => :source_list, SANDBOX => :sandbox_list, @@ -158,6 +161,7 @@ def self.included(base) FORM_ACTION, FRAME_ANCESTORS, NAVIGATE_TO, + REPORT_TO, REPORT_URI, ] @@ -336,6 +340,10 @@ def validate_directive!(directive, value) unless boolean?(value) raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value") end + when :string + unless value.is_a?(String) + raise ContentSecurityPolicyConfigError.new("#{directive} must be a string. Found #{value.class} value") + end when :sandbox_list validate_sandbox_expression!(directive, value) when :media_type_list diff --git a/lib/secure_headers/headers/reporting_endpoints.rb b/lib/secure_headers/headers/reporting_endpoints.rb new file mode 100644 index 00000000..10647e92 --- /dev/null +++ b/lib/secure_headers/headers/reporting_endpoints.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +module SecureHeaders + class ReportingEndpointsConfigError < StandardError; end + + class ReportingEndpoints + HEADER_NAME = "reporting-endpoints".freeze + INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze + INVALID_ENDPOINT_NAME_ERROR = "endpoint names must be strings or symbols.".freeze + INVALID_ENDPOINT_URL_ERROR = "endpoint URLs must be strings.".freeze + + class << self + # Public: Generate a Reporting-Endpoints header. + # + # Returns nil if not configured, returns header name and value if + # configured. + def make_header(config, user_agent = nil) + return if config.nil? || config == OPT_OUT + + header = new(config) + [HEADER_NAME, header.value] + end + + def validate_config!(config) + return if config.nil? || config == OPT_OUT + raise ReportingEndpointsConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a?(Hash) + + config.each do |name, url| + unless name.is_a?(String) || name.is_a?(Symbol) + raise ReportingEndpointsConfigError.new(INVALID_ENDPOINT_NAME_ERROR) + end + + unless url.is_a?(String) + raise ReportingEndpointsConfigError.new(INVALID_ENDPOINT_URL_ERROR) + end + end + end + end + + def initialize(config) + @endpoints = config + end + + def value + @endpoints.map { |name, url| "#{name}=\"#{url}\"" }.join(", ") + 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 37cb62a7..112daea6 100644 --- a/spec/lib/secure_headers/headers/content_security_policy_spec.rb +++ b/spec/lib/secure_headers/headers/content_security_policy_spec.rb @@ -71,6 +71,21 @@ module SecureHeaders expect(csp.value).to eq("default-src https:; report-uri https://example.org") end + it "includes report-to directive with string value" do + csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'default') + expect(csp.value).to eq("default-src 'self'; script-src 'self'; report-to default") + end + + it "includes both report-to and report-uri when both are specified" do + csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'default', report_uri: %w(https://example.org)) + expect(csp.value).to eq("default-src 'self'; script-src 'self'; report-to default; report-uri https://example.org") + end + + it "positions report-to before report-uri" do + csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'endpoint', report_uri: %w(/report)) + expect(csp.value).to match(/report-to endpoint.*report-uri/) + end + it "does not remove schemes when :preserve_schemes is true" do csp = ContentSecurityPolicy.new(default_src: %w(https://example.org), preserve_schemes: true) expect(csp.value).to eq("default-src https://example.org") 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..4a30ff7a --- /dev/null +++ b/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +require "spec_helper" + +module SecureHeaders + describe ReportingEndpoints do + specify { expect(ReportingEndpoints.new(default: "https://example.com/reports").value).to eq('default="https://example.com/reports"') } + specify do + config = { default: "https://example.com/reports", csp: "https://example.com/csp" } + header_value = 'default="https://example.com/reports", csp="https://example.com/csp"' + expect(ReportingEndpoints.new(config).value).to eq(header_value) + end + specify do + config = { endpoint1: "https://example.com/1", endpoint2: "https://example.com/2", endpoint3: "https://example.com/3" } + header_value = 'endpoint1="https://example.com/1", endpoint2="https://example.com/2", endpoint3="https://example.com/3"' + expect(ReportingEndpoints.new(config).value).to eq(header_value) + end + + context "with an invalid configuration" do + it "raises an exception when configuration isn't a hash" do + expect do + ReportingEndpoints.validate_config!(%w(a)) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "raises an exception when configuration is a string" do + expect do + ReportingEndpoints.validate_config!("https://example.com") + end.to raise_error(ReportingEndpointsConfigError) + end + + it "raises an exception when endpoint name is not a string or symbol" do + expect do + ReportingEndpoints.validate_config!(123 => "https://example.com") + end.to raise_error(ReportingEndpointsConfigError) + end + + it "raises an exception when endpoint URL is not a string" do + expect do + ReportingEndpoints.validate_config!(default: 123) + end.to raise_error(ReportingEndpointsConfigError) + end + end + + context "with OPT_OUT" do + it "does not produce a header" do + expect(ReportingEndpoints.make_header(OPT_OUT)).to be_nil + end + end + + context "with nil config" do + it "does not produce a header" do + expect(ReportingEndpoints.make_header(nil)).to be_nil + end + end + end +end