Skip to content

Commit 6da4ee9

Browse files
committed
Add support for W3C Reporting API
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 <noreply@anthropic.com>
1 parent 8b1029c commit 6da4ee9

24 files changed

+335
-218
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The gem will automatically apply several headers that are related to security.
1616
- referrer-policy - [Referrer Policy draft](https://w3c.github.io/webappsec-referrer-policy/)
1717
- 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/).
1818
- clear-site-data - Clearing browser data for origin. [clear-site-data specification](https://w3c.github.io/webappsec-clear-site-data/).
19+
- reporting-endpoints - Configure endpoints for the W3C Reporting API. [Reporting API specification](https://w3c.github.io/reporting/).
1920

2021
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`.
2122

@@ -54,6 +55,9 @@ SecureHeaders::Configuration.default do |config|
5455
config.x_download_options = "noopen"
5556
config.x_permitted_cross_domain_policies = "none"
5657
config.referrer_policy = %w(origin-when-cross-origin strict-origin-when-cross-origin)
58+
config.reporting_endpoints = {
59+
default: "https://report-uri.io/example-reporting"
60+
}
5761
config.csp = {
5862
# "meta" values. these will shape the header, but the values are not included in the header.
5963
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|
8185
style_src_attr: %w('unsafe-inline'),
8286
worker_src: %w('self'),
8387
upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/
84-
report_uri: %w(https://report-uri.io/example-csp)
88+
report_to: 'default', # W3C Reporting API endpoint name (modern browsers)
89+
report_uri: %w(https://report-uri.io/example-csp) # Legacy reporting (older browsers)
8590
}
8691
# This is available only from 3.5.0; use the `report_only: true` setting for 3.4.1 and below.
8792
config.csp_report_only = config.csp.merge({

lib/secure_headers.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
require "secure_headers/headers/referrer_policy"
1212
require "secure_headers/headers/clear_site_data"
1313
require "secure_headers/headers/expect_certificate_transparency"
14+
require "secure_headers/headers/reporting_endpoints"
1415
require "secure_headers/middleware"
1516
require "secure_headers/railtie"
1617
require "secure_headers/view_helper"
@@ -208,7 +209,7 @@ def raise_on_unknown_target(target)
208209

209210
def config_and_target(request, target)
210211
config = config_for(request)
211-
target = guess_target(config) unless target
212+
target ||= guess_target(config)
212213
raise_on_unknown_target(target)
213214
[config, target]
214215
end

lib/secure_headers/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ def deep_copy_if_hash(value)
128128
referrer_policy: ReferrerPolicy,
129129
clear_site_data: ClearSiteData,
130130
expect_certificate_transparency: ExpectCertificateTransparency,
131+
reporting_endpoints: ReportingEndpoints,
131132
csp: ContentSecurityPolicy,
132133
csp_report_only: ContentSecurityPolicy,
133134
cookies: Cookie,

lib/secure_headers/headers/clear_site_data.rb

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,43 +11,41 @@ class ClearSiteData
1111
EXECUTION_CONTEXTS = "executionContexts".freeze
1212
ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]
1313

14-
class << self
15-
# Public: make an clear-site-data header name, value pair
16-
#
17-
# Returns nil if not configured, returns header name and value if configured.
18-
def make_header(config = nil, user_agent = nil)
19-
case config
20-
when nil, OPT_OUT, []
21-
# noop
22-
when Array
23-
[HEADER_NAME, make_header_value(config)]
24-
when true
25-
[HEADER_NAME, make_header_value(ALL_TYPES)]
26-
end
14+
# Public: make an clear-site-data header name, value pair
15+
#
16+
# Returns nil if not configured, returns header name and value if configured.
17+
def self.make_header(config = nil, user_agent = nil)
18+
case config
19+
when nil, OPT_OUT, []
20+
# noop
21+
when Array
22+
[HEADER_NAME, make_header_value(config)]
23+
when true
24+
[HEADER_NAME, make_header_value(ALL_TYPES)]
2725
end
26+
end
2827

29-
def validate_config!(config)
30-
case config
31-
when nil, OPT_OUT, true
32-
# valid
33-
when Array
34-
unless config.all? { |t| t.is_a?(String) }
35-
raise ClearSiteDataConfigError.new("types must be Strings")
36-
end
37-
else
38-
raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
28+
def self.validate_config!(config)
29+
case config
30+
when nil, OPT_OUT, true
31+
# valid
32+
when Array
33+
unless config.all? { |t| t.is_a?(String) }
34+
raise ClearSiteDataConfigError.new("types must be Strings")
3935
end
36+
else
37+
raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
4038
end
39+
end
4140

42-
# Public: Transform a clear-site-data config (an Array of Strings) into a
43-
# String that can be used as the value for the clear-site-data header.
44-
#
45-
# types - An Array of String of types of data to clear.
46-
#
47-
# Returns a String of quoted values that are comma separated.
48-
def make_header_value(types)
49-
types.map { |t| %("#{t}") }.join(", ")
50-
end
41+
# Public: Transform a clear-site-data config (an Array of Strings) into a
42+
# String that can be used as the value for the clear-site-data header.
43+
#
44+
# types - An Array of String of types of data to clear.
45+
#
46+
# Returns a String of quoted values that are comma separated.
47+
def self.make_header_value(types)
48+
types.map { |t| %("#{t}") }.join(", ")
5149
end
5250
end
5351
end

lib/secure_headers/headers/content_security_policy.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def value
4646
private
4747

4848
# Private: converts the config object into a string representing a policy.
49-
# Places default-src at the first directive and report-uri as the last. All
49+
# Places default-src at the first directive and report-to/report-uri as the last. All
5050
# others are presented in alphabetical order.
5151
#
5252
# Returns a content security policy header value.
@@ -59,6 +59,8 @@ def build_value
5959
build_source_list_directive(directive_name)
6060
when :boolean
6161
symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
62+
when :string
63+
build_string_directive(directive_name)
6264
when :sandbox_list
6365
build_sandbox_list_directive(directive_name)
6466
when :media_type_list
@@ -67,6 +69,11 @@ def build_value
6769
end.compact.join("; ")
6870
end
6971

72+
def build_string_directive(directive)
73+
return unless string_value = @config.directive_value(directive)
74+
[symbol_to_hyphen_case(directive), string_value].join(" ")
75+
end
76+
7077
def build_sandbox_list_directive(directive)
7178
return unless sandbox_list = @config.directive_value(directive)
7279
max_strict_policy = case sandbox_list
@@ -179,11 +186,12 @@ def append_nonce(source_list, nonce)
179186
end
180187

181188
# Private: return the list of directives,
182-
# starting with default-src and ending with report-uri.
189+
# starting with default-src and ending with report-to and report-uri.
183190
def directives
184191
[
185192
DEFAULT_SRC,
186193
BODY_DIRECTIVES,
194+
REPORT_TO,
187195
REPORT_URI,
188196
].flatten
189197
end

lib/secure_headers/headers/cookie.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@ module SecureHeaders
77
class CookiesConfigError < StandardError; end
88
class Cookie
99

10-
class << self
11-
def validate_config!(config)
12-
CookiesConfig.new(config).validate!
13-
end
10+
def self.validate_config!(config)
11+
CookiesConfig.new(config).validate!
1412
end
1513

1614
attr_reader :raw_cookie, :config

lib/secure_headers/headers/expect_certificate_transparency.rb

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,29 @@ class ExpectCertificateTransparency
99
REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
1010
INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze
1111

12-
class << self
13-
# Public: Generate a expect-ct header.
14-
#
15-
# Returns nil if not configured, returns header name and value if
16-
# configured.
17-
def make_header(config, use_agent = nil)
18-
return if config.nil? || config == OPT_OUT
12+
# Public: Generate a expect-ct header.
13+
#
14+
# Returns nil if not configured, returns header name and value if
15+
# configured.
16+
def self.make_header(config, use_agent = nil)
17+
return if config.nil? || config == OPT_OUT
1918

20-
header = new(config)
21-
[HEADER_NAME, header.value]
22-
end
19+
header = new(config)
20+
[HEADER_NAME, header.value]
21+
end
2322

24-
def validate_config!(config)
25-
return if config.nil? || config == OPT_OUT
26-
raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
23+
def self.validate_config!(config)
24+
return if config.nil? || config == OPT_OUT
25+
raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
2726

28-
unless [true, false, nil].include?(config[:enforce])
29-
raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
30-
end
27+
unless [true, false, nil].include?(config[:enforce])
28+
raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
29+
end
3130

32-
if !config[:max_age]
33-
raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
34-
elsif config[:max_age].to_s !~ /\A\d+\z/
35-
raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
36-
end
31+
if !config[:max_age]
32+
raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
33+
elsif config[:max_age].to_s !~ /\A\d+\z/
34+
raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
3735
end
3836
end
3937

lib/secure_headers/headers/policy_management.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def self.included(base)
3838
SANDBOX = :sandbox
3939
SCRIPT_SRC = :script_src
4040
STYLE_SRC = :style_src
41+
REPORT_TO = :report_to
4142
REPORT_URI = :report_uri
4243

4344
DIRECTIVES_1_0 = [
@@ -87,6 +88,7 @@ def self.included(base)
8788
MANIFEST_SRC,
8889
NAVIGATE_TO,
8990
PREFETCH_SRC,
91+
REPORT_TO,
9092
REQUIRE_SRI_FOR,
9193
WORKER_SRC,
9294
UPGRADE_INSECURE_REQUESTS,
@@ -110,9 +112,9 @@ def self.included(base)
110112

111113
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_EXPERIMENTAL).uniq.sort
112114

113-
# Think of default-src and report-uri as the beginning and end respectively,
115+
# Think of default-src as the beginning and report-to/report-uri as the end,
114116
# everything else is in between.
115-
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
117+
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_TO, REPORT_URI]
116118

117119
DIRECTIVE_VALUE_TYPES = {
118120
BASE_URI => :source_list,
@@ -131,6 +133,7 @@ def self.included(base)
131133
PLUGIN_TYPES => :media_type_list,
132134
REQUIRE_SRI_FOR => :require_sri_for_list,
133135
REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list,
136+
REPORT_TO => :string,
134137
REPORT_URI => :source_list,
135138
PREFETCH_SRC => :source_list,
136139
SANDBOX => :sandbox_list,
@@ -158,6 +161,7 @@ def self.included(base)
158161
FORM_ACTION,
159162
FRAME_ANCESTORS,
160163
NAVIGATE_TO,
164+
REPORT_TO,
161165
REPORT_URI,
162166
]
163167

@@ -336,6 +340,10 @@ def validate_directive!(directive, value)
336340
unless boolean?(value)
337341
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value")
338342
end
343+
when :string
344+
unless value.is_a?(String)
345+
raise ContentSecurityPolicyConfigError.new("#{directive} must be a string. Found #{value.class} value")
346+
end
339347
when :sandbox_list
340348
validate_sandbox_expression!(directive, value)
341349
when :media_type_list

lib/secure_headers/headers/referrer_policy.rb

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,27 @@ class ReferrerPolicy
1515
unsafe-url
1616
)
1717

18-
class << self
19-
# Public: generate an Referrer Policy header.
20-
#
21-
# Returns a default header if no configuration is provided, or a
22-
# header name and value based on the config.
23-
def make_header(config = nil, user_agent = nil)
24-
return if config == OPT_OUT
25-
config ||= DEFAULT_VALUE
26-
[HEADER_NAME, Array(config).join(", ")]
27-
end
18+
# Public: generate an Referrer Policy header.
19+
#
20+
# Returns a default header if no configuration is provided, or a
21+
# header name and value based on the config.
22+
def self.make_header(config = nil, user_agent = nil)
23+
return if config == OPT_OUT
24+
config ||= DEFAULT_VALUE
25+
[HEADER_NAME, Array(config).join(", ")]
26+
end
2827

29-
def validate_config!(config)
30-
case config
31-
when nil, OPT_OUT
32-
# valid
33-
when String, Array
34-
config = Array(config)
35-
unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) }
36-
raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}")
37-
end
38-
else
39-
raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}")
28+
def self.validate_config!(config)
29+
case config
30+
when nil, OPT_OUT
31+
# valid
32+
when String, Array
33+
config = Array(config)
34+
unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) }
35+
raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}")
4036
end
37+
else
38+
raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}")
4139
end
4240
end
4341
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
module SecureHeaders
3+
class ReportingEndpointsConfigError < StandardError; end
4+
5+
class ReportingEndpoints
6+
HEADER_NAME = "reporting-endpoints".freeze
7+
INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze
8+
INVALID_ENDPOINT_NAME_ERROR = "endpoint names must be strings or symbols.".freeze
9+
INVALID_ENDPOINT_URL_ERROR = "endpoint URLs must be strings.".freeze
10+
11+
# Public: Generate a Reporting-Endpoints header.
12+
#
13+
# Returns nil if not configured, returns header name and value if
14+
# configured.
15+
def self.make_header(config, user_agent = nil)
16+
return if config.nil? || config == OPT_OUT
17+
18+
header = new(config)
19+
[HEADER_NAME, header.value]
20+
end
21+
22+
def self.validate_config!(config)
23+
return if config.nil? || config == OPT_OUT
24+
raise ReportingEndpointsConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a?(Hash)
25+
26+
config.each do |name, url|
27+
unless name.is_a?(String) || name.is_a?(Symbol)
28+
raise ReportingEndpointsConfigError.new(INVALID_ENDPOINT_NAME_ERROR)
29+
end
30+
31+
unless url.is_a?(String)
32+
raise ReportingEndpointsConfigError.new(INVALID_ENDPOINT_URL_ERROR)
33+
end
34+
end
35+
end
36+
37+
def initialize(config)
38+
@endpoints = config
39+
end
40+
41+
def value
42+
@endpoints.map { |name, url| "#{name}=\"#{url}\"" }.join(", ")
43+
end
44+
end
45+
end

0 commit comments

Comments
 (0)