Skip to content

Commit 7270ba0

Browse files
committed
add report-to directive, supported by reporting-endpoints header, account for backwards compatibility with report-uri
1 parent 8b1029c commit 7270ba0

File tree

9 files changed

+388
-4
lines changed

9 files changed

+388
-4
lines changed

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def deep_copy_if_hash(value)
131131
csp: ContentSecurityPolicy,
132132
csp_report_only: ContentSecurityPolicy,
133133
cookies: Cookie,
134+
reporting_endpoints: ReportingEndpoints,
134135
}.freeze
135136

136137
CONFIG_ATTRIBUTES = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.keys.freeze
@@ -167,6 +168,7 @@ def initialize(&block)
167168
@x_permitted_cross_domain_policies = nil
168169
@x_xss_protection = nil
169170
@expect_certificate_transparency = nil
171+
@reporting_endpoints = nil
170172

171173
self.referrer_policy = OPT_OUT
172174
self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
@@ -192,6 +194,7 @@ def dup
192194
copy.clear_site_data = @clear_site_data
193195
copy.expect_certificate_transparency = @expect_certificate_transparency
194196
copy.referrer_policy = @referrer_policy
197+
copy.reporting_endpoints = self.class.send(:deep_copy_if_hash, @reporting_endpoints)
195198
copy
196199
end
197200

lib/secure_headers/headers/content_security_policy.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def build_value
6363
build_sandbox_list_directive(directive_name)
6464
when :media_type_list
6565
build_media_type_list_directive(directive_name)
66+
when :report_to_endpoint
67+
build_report_to_directive(directive_name)
6668
end
6769
end.compact.join("; ")
6870
end
@@ -100,6 +102,13 @@ def build_media_type_list_directive(directive)
100102
end
101103
end
102104

105+
def build_report_to_directive(directive)
106+
return unless endpoint_name = @config.directive_value(directive)
107+
if endpoint_name && endpoint_name.is_a?(String) && !endpoint_name.empty?
108+
[symbol_to_hyphen_case(directive), endpoint_name].join(" ")
109+
end
110+
end
111+
103112
# Private: builds a string that represents one directive in a minified form.
104113
#
105114
# directive_name - a symbol representing the various ALL_DIRECTIVES
@@ -179,11 +188,12 @@ def append_nonce(source_list, nonce)
179188
end
180189

181190
# Private: return the list of directives,
182-
# starting with default-src and ending with report-uri.
191+
# starting with default-src and ending with reporting directives (alphabetically ordered).
183192
def directives
184193
[
185194
DEFAULT_SRC,
186195
BODY_DIRECTIVES,
196+
REPORT_TO,
187197
REPORT_URI,
188198
].flatten
189199
end

lib/secure_headers/headers/policy_management.rb

Lines changed: 21 additions & 3 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,
@@ -51,7 +52,8 @@ def self.included(base)
5152
SANDBOX,
5253
SCRIPT_SRC,
5354
STYLE_SRC,
54-
REPORT_URI
55+
REPORT_URI,
56+
REPORT_TO
5557
].freeze
5658

5759
BASE_URI = :base_uri
@@ -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 and report-uri/report-to as the beginning and end respectively,
114116
# everything else is in between.
115-
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
117+
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI, REPORT_TO]
116118

117119
DIRECTIVE_VALUE_TYPES = {
118120
BASE_URI => :source_list,
@@ -132,6 +134,7 @@ def self.included(base)
132134
REQUIRE_SRI_FOR => :require_sri_for_list,
133135
REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list,
134136
REPORT_URI => :source_list,
137+
REPORT_TO => :report_to_endpoint,
135138
PREFETCH_SRC => :source_list,
136139
SANDBOX => :sandbox_list,
137140
SCRIPT_SRC => :source_list,
@@ -159,6 +162,7 @@ def self.included(base)
159162
FRAME_ANCESTORS,
160163
NAVIGATE_TO,
161164
REPORT_URI,
165+
REPORT_TO,
162166
]
163167

164168
FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES - NON_SOURCE_LIST_SOURCES
@@ -344,6 +348,8 @@ def validate_directive!(directive, value)
344348
validate_require_sri_source_expression!(directive, value)
345349
when :require_trusted_types_for_list
346350
validate_require_trusted_types_for_source_expression!(directive, value)
351+
when :report_to_endpoint
352+
validate_report_to_endpoint_expression!(directive, value)
347353
else
348354
raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
349355
end
@@ -398,6 +404,18 @@ def validate_require_trusted_types_for_source_expression!(directive, require_tru
398404
end
399405
end
400406

407+
# Private: validates that a report-to endpoint expression:
408+
# 1. is a string
409+
# 2. is not empty
410+
def validate_report_to_endpoint_expression!(directive, endpoint_name)
411+
unless endpoint_name.is_a?(String)
412+
raise ContentSecurityPolicyConfigError.new("#{directive} must be a string. Found #{endpoint_name.class} value")
413+
end
414+
if endpoint_name.empty?
415+
raise ContentSecurityPolicyConfigError.new("#{directive} must not be empty")
416+
end
417+
end
418+
401419
# Private: validates that a source expression:
402420
# 1. is an array of strings
403421
# 2. does not contain any deprecated, now invalid values (inline, eval, self, none)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
module SecureHeaders
3+
class ReportingEndpointsConfigError < StandardError; end
4+
class ReportingEndpoints
5+
HEADER_NAME = "reporting-endpoints".freeze
6+
7+
class << self
8+
# Public: generate a Reporting-Endpoints header.
9+
#
10+
# The config should be a Hash of endpoint names to URLs.
11+
# Example: { "csp-endpoint" => "https://example.com/reports" }
12+
#
13+
# Returns nil if config is OPT_OUT or nil, or a header name and
14+
# formatted header value based on the config.
15+
def make_header(config = nil)
16+
return if config.nil? || config == OPT_OUT
17+
validate_config!(config)
18+
[HEADER_NAME, format_endpoints(config)]
19+
end
20+
21+
def validate_config!(config)
22+
case config
23+
when nil, OPT_OUT
24+
# valid
25+
when Hash
26+
config.each_pair do |name, url|
27+
unless name.is_a?(String) && !name.empty?
28+
raise ReportingEndpointsConfigError.new("Endpoint name must be a non-empty string, got: #{name.inspect}")
29+
end
30+
unless url.is_a?(String) && !url.empty?
31+
raise ReportingEndpointsConfigError.new("Endpoint URL must be a non-empty string, got: #{url.inspect}")
32+
end
33+
unless url.start_with?("https://")
34+
raise ReportingEndpointsConfigError.new("Endpoint URLs must use https, got: #{url.inspect}")
35+
end
36+
end
37+
else
38+
raise TypeError.new("Must be a Hash of endpoint names to URLs. Found #{config.class}: #{config}")
39+
end
40+
end
41+
42+
private
43+
44+
def format_endpoints(config)
45+
config.map do |name, url|
46+
%{#{name}="#{url}"}
47+
end.join(", ")
48+
end
49+
end
50+
end
51+
end

spec/lib/secure_headers/headers/content_security_policy_spec.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,32 @@ module SecureHeaders
210210
csp = ContentSecurityPolicy.new({trusted_types: %w(blahblahpolicy 'allow-duplicates')})
211211
expect(csp.value).to eq("trusted-types blahblahpolicy 'allow-duplicates'")
212212
end
213+
214+
it "supports report-to directive with endpoint name" do
215+
csp = ContentSecurityPolicy.new({default_src: %w('self'), report_to: "csp-endpoint"})
216+
expect(csp.value).to eq("default-src 'self'; report-to csp-endpoint")
217+
end
218+
219+
it "includes report-to before report-uri in alphabetical order" do
220+
csp = ContentSecurityPolicy.new({default_src: %w('self'), report_uri: %w(/csp_report), report_to: "csp-endpoint"})
221+
expect(csp.value).to eq("default-src 'self'; report-to csp-endpoint; report-uri /csp_report")
222+
end
223+
224+
it "does not add report-to if the endpoint name is empty" do
225+
csp = ContentSecurityPolicy.new({default_src: %w('self'), report_to: ""})
226+
expect(csp.value).to eq("default-src 'self'")
227+
end
228+
229+
it "does not add report-to if not provided" do
230+
csp = ContentSecurityPolicy.new({default_src: %w('self')})
231+
expect(csp.value).not_to include("report-to")
232+
end
233+
234+
it "supports report-to without report-uri" do
235+
csp = ContentSecurityPolicy.new({default_src: %w('self'), report_to: "reporting-endpoint-name"})
236+
expect(csp.value).to eq("default-src 'self'; report-to reporting-endpoint-name")
237+
end
213238
end
214239
end
215240
end
241+

spec/lib/secure_headers/headers/policy_management_spec.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,30 @@ module SecureHeaders
169169
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyReportOnlyConfig.new(default_opts.merge(report_only: true)))
170170
end.to_not raise_error
171171
end
172+
173+
it "requires report_to to be a string" do
174+
expect do
175+
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: ["endpoint"])))
176+
end.to raise_error(ContentSecurityPolicyConfigError)
177+
end
178+
179+
it "rejects empty report_to endpoint names" do
180+
expect do
181+
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: "")))
182+
end.to raise_error(ContentSecurityPolicyConfigError)
183+
end
184+
185+
it "accepts valid report_to endpoint names" do
186+
expect do
187+
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: "csp-endpoint")))
188+
end.to_not raise_error
189+
end
190+
191+
it "accepts report_to with hyphens and underscores" do
192+
expect do
193+
ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_to: "csp-endpoint_name-123")))
194+
end.to_not raise_error
195+
end
172196
end
173197

174198
describe "#combine_policies" do
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# frozen_string_literal: true
2+
require "spec_helper"
3+
4+
module SecureHeaders
5+
describe ReportingEndpoints do
6+
describe "#make_header" do
7+
it "returns nil when config is nil" do
8+
expect(ReportingEndpoints.make_header(nil)).to be_nil
9+
end
10+
11+
it "returns nil when config is OPT_OUT" do
12+
expect(ReportingEndpoints.make_header(OPT_OUT)).to be_nil
13+
end
14+
15+
it "formats a single endpoint" do
16+
config = { "csp-endpoint" => "https://example.com/csp-reports" }
17+
header_name, value = ReportingEndpoints.make_header(config)
18+
expect(header_name).to eq("reporting-endpoints")
19+
expect(value).to eq('csp-endpoint="https://example.com/csp-reports"')
20+
end
21+
22+
it "formats multiple endpoints" do
23+
config = {
24+
"csp-endpoint" => "https://example.com/csp-reports",
25+
"permissions-endpoint" => "https://example.com/permissions-reports"
26+
}
27+
header_name, value = ReportingEndpoints.make_header(config)
28+
expect(header_name).to eq("reporting-endpoints")
29+
# Order may vary, so check both endpoints are present
30+
expect(value).to include('csp-endpoint="https://example.com/csp-reports"')
31+
expect(value).to include('permissions-endpoint="https://example.com/permissions-reports"')
32+
expect(value).to include(",")
33+
end
34+
35+
it "validates that endpoints are present" do
36+
expect do
37+
ReportingEndpoints.validate_config!({})
38+
end.to_not raise_error
39+
end
40+
end
41+
42+
describe "#validate_config!" do
43+
it "accepts nil" do
44+
expect do
45+
ReportingEndpoints.validate_config!(nil)
46+
end.to_not raise_error
47+
end
48+
49+
it "accepts OPT_OUT" do
50+
expect do
51+
ReportingEndpoints.validate_config!(OPT_OUT)
52+
end.to_not raise_error
53+
end
54+
55+
it "accepts valid endpoint configuration" do
56+
expect do
57+
ReportingEndpoints.validate_config!({
58+
"csp-violations" => "https://example.com/reports"
59+
})
60+
end.to_not raise_error
61+
end
62+
63+
it "rejects non-hash config" do
64+
expect do
65+
ReportingEndpoints.validate_config!("not a hash")
66+
end.to raise_error(TypeError)
67+
end
68+
69+
it "rejects empty endpoint name" do
70+
expect do
71+
ReportingEndpoints.validate_config!({
72+
"" => "https://example.com/reports"
73+
})
74+
end.to raise_error(ReportingEndpointsConfigError)
75+
end
76+
77+
it "rejects non-string endpoint name" do
78+
expect do
79+
ReportingEndpoints.validate_config!({
80+
123 => "https://example.com/reports"
81+
})
82+
end.to raise_error(ReportingEndpointsConfigError)
83+
end
84+
85+
it "rejects empty endpoint URL" do
86+
expect do
87+
ReportingEndpoints.validate_config!({
88+
"csp-endpoint" => ""
89+
})
90+
end.to raise_error(ReportingEndpointsConfigError)
91+
end
92+
93+
it "rejects non-string endpoint URL" do
94+
expect do
95+
ReportingEndpoints.validate_config!({
96+
"csp-endpoint" => 123
97+
})
98+
end.to raise_error(ReportingEndpointsConfigError)
99+
end
100+
101+
it "rejects non-https URLs" do
102+
expect do
103+
ReportingEndpoints.validate_config!({
104+
"csp-endpoint" => "http://example.com/reports"
105+
})
106+
end.to raise_error(ReportingEndpointsConfigError, /must use https/)
107+
end
108+
end
109+
end
110+
end

0 commit comments

Comments
 (0)