Skip to content

Commit 7443a2d

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 <[email protected]>
1 parent 8b1029c commit 7443a2d

File tree

8 files changed

+198
-4
lines changed

8 files changed

+198
-4
lines changed

README.md

Lines changed: 59 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({
@@ -108,6 +113,59 @@ x-permitted-cross-domain-policies: none
108113
x-xss-protection: 0
109114
```
110115

116+
## W3C Reporting API
117+
118+
The [W3C Reporting API](https://w3c.github.io/reporting/) provides a standardized way to receive browser reports about security violations, deprecations, and other issues. To use it, you need to configure two things:
119+
120+
### 1. Reporting-Endpoints Header
121+
122+
Define named endpoints where reports should be sent:
123+
124+
```ruby
125+
SecureHeaders::Configuration.default do |config|
126+
config.reporting_endpoints = {
127+
default: "https://example.com/reports",
128+
csp_endpoint: "https://example.com/csp-reports"
129+
}
130+
end
131+
```
132+
133+
This generates the header:
134+
```
135+
Reporting-Endpoints: default="https://example.com/reports", csp_endpoint="https://example.com/csp-reports"
136+
```
137+
138+
### 2. CSP report-to Directive
139+
140+
Reference the endpoint name in your CSP configuration using the `report_to` directive:
141+
142+
```ruby
143+
config.csp = {
144+
default_src: %w('self'),
145+
script_src: %w('self'),
146+
report_to: 'default' # References the 'default' endpoint
147+
}
148+
```
149+
150+
### Browser Compatibility
151+
152+
For maximum browser compatibility, use both modern (`report_to`) and legacy (`report_uri`) reporting:
153+
154+
```ruby
155+
config.reporting_endpoints = {
156+
default: "https://example.com/reports"
157+
}
158+
159+
config.csp = {
160+
default_src: %w('self'),
161+
script_src: %w('self'),
162+
report_to: 'default', # Modern browsers (W3C Reporting API)
163+
report_uri: %w(https://example.com/reports) # Legacy browsers
164+
}
165+
```
166+
167+
**Note:** Modern browsers using the Reporting API will send reports in a different format than legacy `report-uri`. Your reporting endpoint should be able to handle both formats.
168+
111169
## API configurations
112170

113171
Which headers you decide to use for API responses is entirely a personal choice. Things like X-Frame-Options seem to have no place in an API response and would be wasting bytes. While this is true, browsers can do funky things with non-html responses. At the minimum, we suggest CSP:

lib/secure_headers.rb

Lines changed: 1 addition & 0 deletions
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"

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/content_security_policy.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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/policy_management.rb

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

4344
DIRECTIVES_1_0 = [
4445
DEFAULT_SRC,
@@ -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
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
class << self
12+
# Public: Generate a Reporting-Endpoints header.
13+
#
14+
# Returns nil if not configured, returns header name and value if
15+
# configured.
16+
def make_header(config, user_agent = nil)
17+
return if config.nil? || config == OPT_OUT
18+
19+
header = new(config)
20+
[HEADER_NAME, header.value]
21+
end
22+
23+
def validate_config!(config)
24+
return if config.nil? || config == OPT_OUT
25+
raise ReportingEndpointsConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a?(Hash)
26+
27+
config.each do |name, url|
28+
unless name.is_a?(String) || name.is_a?(Symbol)
29+
raise ReportingEndpointsConfigError.new(INVALID_ENDPOINT_NAME_ERROR)
30+
end
31+
32+
unless url.is_a?(String)
33+
raise ReportingEndpointsConfigError.new(INVALID_ENDPOINT_URL_ERROR)
34+
end
35+
end
36+
end
37+
end
38+
39+
def initialize(config)
40+
@endpoints = config
41+
end
42+
43+
def value
44+
@endpoints.map { |name, url| "#{name}=\"#{url}\"" }.join(", ")
45+
end
46+
end
47+
end

spec/lib/secure_headers/headers/content_security_policy_spec.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ module SecureHeaders
7171
expect(csp.value).to eq("default-src https:; report-uri https://example.org")
7272
end
7373

74+
it "includes report-to directive with string value" do
75+
csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'default')
76+
expect(csp.value).to eq("default-src 'self'; script-src 'self'; report-to default")
77+
end
78+
79+
it "includes both report-to and report-uri when both are specified" do
80+
csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'default', report_uri: %w(https://example.org))
81+
expect(csp.value).to eq("default-src 'self'; script-src 'self'; report-to default; report-uri https://example.org")
82+
end
83+
84+
it "positions report-to before report-uri" do
85+
csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'endpoint', report_uri: %w(/report))
86+
expect(csp.value).to match(/report-to endpoint.*report-uri/)
87+
end
88+
7489
it "does not remove schemes when :preserve_schemes is true" do
7590
csp = ContentSecurityPolicy.new(default_src: %w(https://example.org), preserve_schemes: true)
7691
expect(csp.value).to eq("default-src https://example.org")
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
require "spec_helper"
3+
4+
module SecureHeaders
5+
describe ReportingEndpoints do
6+
specify { expect(ReportingEndpoints.new(default: "https://example.com/reports").value).to eq('default="https://example.com/reports"') }
7+
specify do
8+
config = { default: "https://example.com/reports", csp: "https://example.com/csp" }
9+
header_value = 'default="https://example.com/reports", csp="https://example.com/csp"'
10+
expect(ReportingEndpoints.new(config).value).to eq(header_value)
11+
end
12+
specify do
13+
config = { endpoint1: "https://example.com/1", endpoint2: "https://example.com/2", endpoint3: "https://example.com/3" }
14+
header_value = 'endpoint1="https://example.com/1", endpoint2="https://example.com/2", endpoint3="https://example.com/3"'
15+
expect(ReportingEndpoints.new(config).value).to eq(header_value)
16+
end
17+
18+
context "with an invalid configuration" do
19+
it "raises an exception when configuration isn't a hash" do
20+
expect do
21+
ReportingEndpoints.validate_config!(%w(a))
22+
end.to raise_error(ReportingEndpointsConfigError)
23+
end
24+
25+
it "raises an exception when configuration is a string" do
26+
expect do
27+
ReportingEndpoints.validate_config!("https://example.com")
28+
end.to raise_error(ReportingEndpointsConfigError)
29+
end
30+
31+
it "raises an exception when endpoint name is not a string or symbol" do
32+
expect do
33+
ReportingEndpoints.validate_config!(123 => "https://example.com")
34+
end.to raise_error(ReportingEndpointsConfigError)
35+
end
36+
37+
it "raises an exception when endpoint URL is not a string" do
38+
expect do
39+
ReportingEndpoints.validate_config!(default: 123)
40+
end.to raise_error(ReportingEndpointsConfigError)
41+
end
42+
end
43+
44+
context "with OPT_OUT" do
45+
it "does not produce a header" do
46+
expect(ReportingEndpoints.make_header(OPT_OUT)).to be_nil
47+
end
48+
end
49+
50+
context "with nil config" do
51+
it "does not produce a header" do
52+
expect(ReportingEndpoints.make_header(nil)).to be_nil
53+
end
54+
end
55+
end
56+
end

0 commit comments

Comments
 (0)