diff --git a/README.md b/README.md index ba735b1c..ab952991 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The gem will automatically apply several headers that are related to security. - X-Permitted-Cross-Domain-Policies - [Restrict Adobe Flash Player's access to data](https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html) - Referrer-Policy - [Referrer Policy draft](https://w3c.github.io/webappsec-referrer-policy/) - Public Key Pinning - Pin certificate fingerprints in the browser to prevent man-in-the-middle attacks due to compromised Certificate Authorities. [Public Key Pinning Specification](https://tools.ietf.org/html/rfc7469) +- 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/). It can also mark all http cookies with the Secure, HttpOnly and SameSite attributes (when configured to do so). @@ -76,6 +77,11 @@ SecureHeaders::Configuration.default do |config| "storage", "executionContexts" ] + config.expect_certificate_transparency = { + enforce: false, + max_age: 1.day.to_i, + report_uri: "https://report-uri.io/example-ct" + } config.csp = { # "meta" values. these will shaped the header, but the values are not included in the header. # report_only: true, # default: false [DEPRECATED from 3.5.0: instead, configure csp_report_only] diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index 86971523..ad6f2bda 100644 --- a/lib/secure_headers.rb +++ b/lib/secure_headers.rb @@ -11,6 +11,7 @@ require "secure_headers/headers/x_permitted_cross_domain_policies" require "secure_headers/headers/referrer_policy" require "secure_headers/headers/clear_site_data" +require "secure_headers/headers/expect_certificate_transparency" require "secure_headers/middleware" require "secure_headers/railtie" require "secure_headers/view_helper" @@ -51,6 +52,7 @@ def opt_out? CSP = ContentSecurityPolicy ALL_HEADER_CLASSES = [ + ExpectCertificateTransparency, ClearSiteData, ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig, diff --git a/lib/secure_headers/configuration.rb b/lib/secure_headers/configuration.rb index 99777347..d4e4a3db 100644 --- a/lib/secure_headers/configuration.rb +++ b/lib/secure_headers/configuration.rb @@ -116,7 +116,7 @@ def deep_copy_if_hash(value) attr_writer :hsts, :x_frame_options, :x_content_type_options, :x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies, - :referrer_policy, :clear_site_data + :referrer_policy, :clear_site_data, :expect_certificate_transparency attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host @@ -168,6 +168,7 @@ def dup copy.x_download_options = @x_download_options copy.x_permitted_cross_domain_policies = @x_permitted_cross_domain_policies copy.clear_site_data = @clear_site_data + copy.expect_certificate_transparency = @expect_certificate_transparency copy.referrer_policy = @referrer_policy copy.hpkp = @hpkp copy.hpkp_report_host = @hpkp_report_host @@ -200,6 +201,7 @@ def validate_config! XDownloadOptions.validate_config!(@x_download_options) XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies) ClearSiteData.validate_config!(@clear_site_data) + ExpectCertificateTransparency.validate_config!(@expect_certificate_transparency) PublicKeyPins.validate_config!(@hpkp) Cookie.validate_config!(@cookies) end diff --git a/lib/secure_headers/headers/expect_certificate_transparency.rb b/lib/secure_headers/headers/expect_certificate_transparency.rb new file mode 100644 index 00000000..8086cfd6 --- /dev/null +++ b/lib/secure_headers/headers/expect_certificate_transparency.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +module SecureHeaders + class ExpectCertificateTransparencyConfigError < StandardError; end + + class ExpectCertificateTransparency + HEADER_NAME = "Expect-CT".freeze + CONFIG_KEY = :expect_certificate_transparency + INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze + INVALID_ENFORCE_VALUE_ERROR = "enforce must be a boolean".freeze + REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze + INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze + + class << self + # Public: Generate a Expect-CT header. + # + # Returns nil if not configured, returns header name and value if + # configured. + def make_header(config) + return if config.nil? + + header = new(config) + [HEADER_NAME, header.value] + end + + def validate_config!(config) + return if config.nil? || config == OPT_OUT + raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash + + unless [true, false, nil].include?(config[:enforce]) + raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR) + end + + if !config[:max_age] + raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR) + elsif config[:max_age].to_s !~ /\A\d+\z/ + raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR) + end + end + end + + def initialize(config) + @enforced = config.fetch(:enforce, nil) + @max_age = config.fetch(:max_age, nil) + @report_uri = config.fetch(:report_uri, nil) + end + + def value + header_value = [ + enforced_directive, + max_age_directive, + report_uri_directive + ].compact.join("; ").strip + end + + def enforced_directive + # Unfortunately `if @enforced` isn't enough here in case someone + # passes in a random string so let's be specific with it to prevent + # accidental enforcement. + "enforce" if @enforced == true + end + + def max_age_directive + "max-age=#{@max_age}" if @max_age + end + + def report_uri_directive + "report-uri=\"#{@report_uri}\"" if @report_uri + end + end +end diff --git a/spec/lib/secure_headers/headers/expect_certificate_spec.rb b/spec/lib/secure_headers/headers/expect_certificate_spec.rb new file mode 100644 index 00000000..86735d79 --- /dev/null +++ b/spec/lib/secure_headers/headers/expect_certificate_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +require "spec_helper" + +module SecureHeaders + describe ExpectCertificateTransparency do + specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: true).value).to eq("enforce; max-age=1234") } + specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: false).value).to eq("max-age=1234") } + specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: "yolocopter").value).to eq("max-age=1234") } + specify { expect(ExpectCertificateTransparency.new(max_age: 1234, report_uri: "https://report-uri.io/expect-ct").value).to eq("max-age=1234; report-uri=\"https://report-uri.io/expect-ct\"") } + specify do + config = { enforce: true, max_age: 1234, report_uri: "https://report-uri.io/expect-ct" } + header_value = "enforce; max-age=1234; report-uri=\"https://report-uri.io/expect-ct\"" + expect(ExpectCertificateTransparency.new(config).value).to eq(header_value) + end + + context "with an invalid configuration" do + it "raises an exception when configuration isn't a hash" do + expect do + ExpectCertificateTransparency.validate_config!(%w(a)) + end.to raise_error(ExpectCertificateTransparencyConfigError) + end + + it "raises an exception when max-age is not provided" do + expect do + ExpectCertificateTransparency.validate_config!(foo: "bar") + end.to raise_error(ExpectCertificateTransparencyConfigError) + end + + it "raises an exception with an invalid max-age" do + expect do + ExpectCertificateTransparency.validate_config!(max_age: "abc123") + end.to raise_error(ExpectCertificateTransparencyConfigError) + end + + it "raises an exception with an invalid enforce value" do + expect do + ExpectCertificateTransparency.validate_config!(enforce: "brokenstring") + end.to raise_error(ExpectCertificateTransparencyConfigError) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6842c9bc..565556da 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -34,6 +34,7 @@ def expect_default_values(hash) expect(hash[SecureHeaders::XContentTypeOptions::HEADER_NAME]).to eq(SecureHeaders::XContentTypeOptions::DEFAULT_VALUE) expect(hash[SecureHeaders::XPermittedCrossDomainPolicies::HEADER_NAME]).to eq(SecureHeaders::XPermittedCrossDomainPolicies::DEFAULT_VALUE) expect(hash[SecureHeaders::ReferrerPolicy::HEADER_NAME]).to be_nil + expect(hash[SecureHeaders::ExpectCertificateTransparency::HEADER_NAME]).to be_nil end module SecureHeaders