Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Expect-CT HTTP header #322

Merged
merged 11 commits into from
Aug 25, 2017
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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/)
- 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)
- Clear-Site-Data - Clearing browser data for origin. [Clear-Site-Data specification](https://www.w3.org/TR/clear-site-data/).
- 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/).

It can also mark all http cookies with the Secure, HttpOnly and SameSite attributes (when configured to do so).

Expand Down Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions lib/secure_headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -51,6 +52,7 @@ def opt_out?
CSP = ContentSecurityPolicy

ALL_HEADER_CLASSES = [
ExpectCertificateTransparency,
ClearSiteData,
ContentSecurityPolicyConfig,
ContentSecurityPolicyReportOnlyConfig,
Expand Down
4 changes: 3 additions & 1 deletion lib/secure_headers/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -151,6 +151,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
Expand Down Expand Up @@ -183,6 +184,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
Expand Down
69 changes: 69 additions & 0 deletions lib/secure_headers/headers/expect_certificate_transparency.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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
41 changes: 41 additions & 0 deletions spec/lib/secure_headers/headers/expect_certificate_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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