Skip to content

Commit 7a40427

Browse files
authored
Add Reporting-Endpoints header support (#5)
1 parent 4b07344 commit 7a40427

File tree

5 files changed

+97
-1
lines changed

5 files changed

+97
-1
lines changed

README.md

+3-1
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 - [Reporting-Endpoints header specification](https://w3c.github.io/reporting/#header)
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,7 @@ 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 = {'example-csp': 'https://report-uri.io/example-csp'}
5759
config.csp = {
5860
# "meta" values. these will shape the header, but the values are not included in the header.
5961
preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content.
@@ -98,7 +100,7 @@ end
98100

99101
## Default values
100102

101-
All headers except for PublicKeyPins and ClearSiteData have a default value. The default set of headers is:
103+
All headers except for PublicKeyPins, ClearSiteData and ReportingEndpoints have a default value. The default set of headers is:
102104

103105
```
104106
Content-Security-Policy: default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'

lib/secure_headers.rb

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require "secure_headers/headers/x_download_options"
1010
require "secure_headers/headers/x_permitted_cross_domain_policies"
1111
require "secure_headers/headers/referrer_policy"
12+
require "secure_headers/headers/reporting_endpoints"
1213
require "secure_headers/headers/clear_site_data"
1314
require "secure_headers/headers/expect_certificate_transparency"
1415
require "secure_headers/middleware"

lib/secure_headers/configuration.rb

+3
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 = @reporting_endpoints
195198
copy
196199
end
197200

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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 an Reporting-Endpoints header.
9+
#
10+
# Returns nil if not configured or opted out, returns an empty string if configuration
11+
# is empty, returns header name and value if configured.
12+
def make_header(config = nil, user_agent = nil)
13+
case config
14+
when nil, OPT_OUT
15+
# noop
16+
when Hash
17+
[HEADER_NAME, make_header_value(config)]
18+
end
19+
end
20+
21+
def validate_config!(config)
22+
case config
23+
when nil, OPT_OUT, {}
24+
# valid
25+
when Hash
26+
unless config.values.all? { |endpoint| endpoint.is_a?(String) }
27+
raise ReportingEndpointsConfigError.new("endpoints must be Strings")
28+
end
29+
else
30+
raise ReportingEndpointsConfigError.new("config must be a Hash")
31+
end
32+
end
33+
34+
def make_header_value(endpoints)
35+
endpoints.map { |name, endpoint| "#{name}=\"#{endpoint}\"" }.join(",")
36+
end
37+
end
38+
end
39+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 with nil config" do
8+
expect(described_class.make_header).to be_nil
9+
end
10+
11+
it "returns nil with opt-out config" do
12+
expect(described_class.make_header(OPT_OUT)).to be_nil
13+
end
14+
15+
it "returns an empty string with empty config" do
16+
name, value = described_class.make_header({})
17+
expect(name).to eq(ReportingEndpoints::HEADER_NAME)
18+
expect(value).to eq("")
19+
end
20+
21+
it "builds a valid header with correct configuration" do
22+
name, value = described_class.make_header({endpoint: "https://report-endpoint-example.io/"})
23+
expect(name).to eq(ReportingEndpoints::HEADER_NAME)
24+
expect(value).to eq("endpoint=\"https://report-endpoint-example.io/\"")
25+
end
26+
27+
it "supports multiple endpoints" do
28+
name, value = described_class.make_header({
29+
endpoint: "https://report-endpoint-example.io/",
30+
'csp-endpoint': "https://csp-report-endpoint-example.io/"
31+
})
32+
expect(name).to eq(ReportingEndpoints::HEADER_NAME)
33+
expect(value).to eq("endpoint=\"https://report-endpoint-example.io/\",csp-endpoint=\"https://csp-report-endpoint-example.io/\"")
34+
end
35+
end
36+
37+
describe "validate_config!" do
38+
it "raises an exception when configuration is not a hash" do
39+
expect do
40+
described_class.validate_config!(["invalid-configuration"])
41+
end.to raise_error(ReportingEndpointsConfigError)
42+
end
43+
44+
it "raises an exception when all hash elements are not a string" do
45+
expect do
46+
described_class.validate_config!({endpoint: 1234})
47+
end.to raise_error(ReportingEndpointsConfigError)
48+
end
49+
end
50+
end
51+
end

0 commit comments

Comments
 (0)