diff --git a/README.md b/README.md index 1683c84..26e85f6 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 - [Reporting-Endpoints header specification](https://w3c.github.io/reporting/#header) 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,7 @@ 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 = {'example-csp': 'https://report-uri.io/example-csp'} 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,12 +83,14 @@ 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_uri: %w(https://report-uri.io/example-csp), + report_to: %w(example-csp) } # 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({ img_src: %w(somewhereelse.com), - report_uri: %w(https://report-uri.io/example-csp-report-only) + report_uri: %w(https://report-uri.io/example-csp-report-only), + report_to: %w(example-csp-report-only) }) end ``` @@ -96,7 +100,7 @@ end ## Default values -All headers except for PublicKeyPins and ClearSiteData have a default value. The default set of headers is: +All headers except for PublicKeyPins, ClearSiteData and ReportingEndpoints have a default value. The default set of headers is: ``` 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' diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index 6426e53..af6359b 100644 --- a/lib/secure_headers.rb +++ b/lib/secure_headers.rb @@ -9,6 +9,7 @@ require "secure_headers/headers/x_download_options" require "secure_headers/headers/x_permitted_cross_domain_policies" require "secure_headers/headers/referrer_policy" +require "secure_headers/headers/reporting_endpoints" require "secure_headers/headers/clear_site_data" require "secure_headers/headers/expect_certificate_transparency" require "secure_headers/middleware" diff --git a/lib/secure_headers/configuration.rb b/lib/secure_headers/configuration.rb index 2ebbf48..adef0f0 100644 --- a/lib/secure_headers/configuration.rb +++ b/lib/secure_headers/configuration.rb @@ -131,6 +131,7 @@ def deep_copy_if_hash(value) csp: ContentSecurityPolicy, csp_report_only: ContentSecurityPolicy, cookies: Cookie, + reporting_endpoints: ReportingEndpoints, }.freeze CONFIG_ATTRIBUTES = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.keys.freeze @@ -167,6 +168,7 @@ def initialize(&block) @x_permitted_cross_domain_policies = nil @x_xss_protection = nil @expect_certificate_transparency = nil + @reporting_endpoints = nil self.referrer_policy = OPT_OUT self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT) @@ -192,6 +194,7 @@ def dup copy.clear_site_data = @clear_site_data copy.expect_certificate_transparency = @expect_certificate_transparency copy.referrer_policy = @referrer_policy + copy.reporting_endpoints = @reporting_endpoints copy end diff --git a/lib/secure_headers/headers/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index 4a7b0d7..69e1300 100644 --- a/lib/secure_headers/headers/content_security_policy.rb +++ b/lib/secure_headers/headers/content_security_policy.rb @@ -46,8 +46,8 @@ 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 - # others are presented in alphabetical order. + # Places default-src at the first directive, report-uri second to last and report-to as the + # last. All others are presented in alphabetical order. # # Returns a content security policy header value. def build_value @@ -130,7 +130,7 @@ def minify_source_list(directive, source_list) source_list = populate_nonces(directive, source_list) source_list = reject_all_values_if_none(source_list) - unless directive == REPORT_URI || @preserve_schemes + unless [REPORT_URI, REPORT_TO].include?(directive) || @preserve_schemes source_list = strip_source_schemes(source_list) end source_list.uniq @@ -185,6 +185,7 @@ def directives DEFAULT_SRC, BODY_DIRECTIVES, REPORT_URI, + REPORT_TO ].flatten end diff --git a/lib/secure_headers/headers/policy_management.rb b/lib/secure_headers/headers/policy_management.rb index 668e79a..15d7054 100644 --- a/lib/secure_headers/headers/policy_management.rb +++ b/lib/secure_headers/headers/policy_management.rb @@ -81,6 +81,7 @@ def self.included(base) SCRIPT_SRC_ATTR = :script_src_attr STYLE_SRC_ELEM = :style_src_elem STYLE_SRC_ATTR = :style_src_attr + REPORT_TO = :report_to DIRECTIVES_3_0 = [ DIRECTIVES_2_0, @@ -93,7 +94,8 @@ def self.included(base) SCRIPT_SRC_ELEM, SCRIPT_SRC_ATTR, STYLE_SRC_ELEM, - STYLE_SRC_ATTR + STYLE_SRC_ATTR, + REPORT_TO ].flatten.freeze # Experimental directives - these vary greatly in support @@ -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, report-uri and report-to as the end, # everything else is in between. - BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI] + BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI, REPORT_TO] 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 => :source_list, REPORT_URI => :source_list, PREFETCH_SRC => :source_list, SANDBOX => :sandbox_list, diff --git a/lib/secure_headers/headers/reporting_endpoints.rb b/lib/secure_headers/headers/reporting_endpoints.rb new file mode 100644 index 0000000..ff1cffa --- /dev/null +++ b/lib/secure_headers/headers/reporting_endpoints.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +module SecureHeaders + class ReportingEndpointsConfigError < StandardError; end + class ReportingEndpoints + HEADER_NAME = "Reporting-Endpoints".freeze + + class << self + # Public: generate an Reporting-Endpoints header. + # + # Returns nil if not configured or opted out, returns an empty string if configuration + # is empty, returns header name and value if configured. + def make_header(config = nil, user_agent = nil) + case config + when nil, OPT_OUT + # noop + when Hash + [HEADER_NAME, make_header_value(config)] + end + end + + def validate_config!(config) + case config + when nil, OPT_OUT, {} + # valid + when Hash + unless config.values.all? { |endpoint| endpoint.is_a?(String) } + raise ReportingEndpointsConfigError.new("endpoints must be Strings") + end + else + raise ReportingEndpointsConfigError.new("config must be a Hash") + end + end + + def make_header_value(endpoints) + endpoints.map { |name, endpoint| "#{name}=\"#{endpoint}\"" }.join(",") + end + 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 37cb62a..401909d 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,11 @@ module SecureHeaders expect(csp.value).to eq("default-src https:; report-uri https://example.org") end + it "does not remove schemes from report-to values" do + csp = ContentSecurityPolicy.new(default_src: %w(https:), report_to: %w(https://example.org)) + expect(csp.value).to eq("default-src https:; report-to https://example.org") + 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/policy_management_spec.rb b/spec/lib/secure_headers/headers/policy_management_spec.rb index c621e88..d001416 100644 --- a/spec/lib/secure_headers/headers/policy_management_spec.rb +++ b/spec/lib/secure_headers/headers/policy_management_spec.rb @@ -55,6 +55,7 @@ module SecureHeaders style_src_attr: %w(example.com), trusted_types: %w(abcpolicy), + report_to: %w(https://example.com/uri-directive), report_uri: %w(https://example.com/uri-directive), } 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 0000000..7d86f1c --- /dev/null +++ b/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +require "spec_helper" + +module SecureHeaders + describe ReportingEndpoints do + describe "make_header" do + it "returns nil with nil config" do + expect(described_class.make_header).to be_nil + end + + it "returns nil with opt-out config" do + expect(described_class.make_header(OPT_OUT)).to be_nil + end + + it "returns an empty string with empty config" do + name, value = described_class.make_header({}) + expect(name).to eq(ReportingEndpoints::HEADER_NAME) + expect(value).to eq("") + end + + it "builds a valid header with correct configuration" do + name, value = described_class.make_header({endpoint: "https://report-endpoint-example.io/"}) + expect(name).to eq(ReportingEndpoints::HEADER_NAME) + expect(value).to eq("endpoint=\"https://report-endpoint-example.io/\"") + end + + it "supports multiple endpoints" do + name, value = described_class.make_header({ + endpoint: "https://report-endpoint-example.io/", + 'csp-endpoint': "https://csp-report-endpoint-example.io/" + }) + expect(name).to eq(ReportingEndpoints::HEADER_NAME) + expect(value).to eq("endpoint=\"https://report-endpoint-example.io/\",csp-endpoint=\"https://csp-report-endpoint-example.io/\"") + end + end + + describe "validate_config!" do + it "raises an exception when configuration is not a hash" do + expect do + described_class.validate_config!(["invalid-configuration"]) + end.to raise_error(ReportingEndpointsConfigError) + end + + it "raises an exception when all hash elements are not a string" do + expect do + described_class.validate_config!({endpoint: 1234}) + end.to raise_error(ReportingEndpointsConfigError) + end + end + end +end