diff --git a/README.md b/README.md index 601a9dbc..26e85f63 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. @@ -98,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 6426e538..af6359b8 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 2ebbf487..adef0f0a 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/reporting_endpoints.rb b/lib/secure_headers/headers/reporting_endpoints.rb new file mode 100644 index 00000000..ff1cffae --- /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/reporting_endpoints_spec.rb b/spec/lib/secure_headers/headers/reporting_endpoints_spec.rb new file mode 100644 index 00000000..7d86f1c1 --- /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