secure_headers 7.0.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2 -2
- data/README.md +78 -14
- data/lib/secure_headers/configuration.rb +54 -7
- data/lib/secure_headers/headers/clear_site_data.rb +31 -33
- data/lib/secure_headers/headers/content_security_policy.rb +36 -5
- data/lib/secure_headers/headers/content_security_policy_config.rb +2 -2
- data/lib/secure_headers/headers/cookie.rb +2 -4
- data/lib/secure_headers/headers/expect_certificate_transparency.rb +20 -22
- data/lib/secure_headers/headers/policy_management.rb +27 -9
- data/lib/secure_headers/headers/referrer_policy.rb +20 -22
- data/lib/secure_headers/headers/reporting_endpoints.rb +54 -0
- data/lib/secure_headers/headers/strict_transport_security.rb +13 -15
- data/lib/secure_headers/headers/x_content_type_options.rb +14 -16
- data/lib/secure_headers/headers/x_download_options.rb +14 -16
- data/lib/secure_headers/headers/x_frame_options.rb +14 -16
- data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +14 -16
- data/lib/secure_headers/headers/x_xss_protection.rb +13 -15
- data/lib/secure_headers/middleware.rb +11 -7
- data/lib/secure_headers/railtie.rb +11 -8
- data/lib/secure_headers/task_helper.rb +65 -0
- data/lib/secure_headers/version.rb +1 -1
- data/lib/secure_headers.rb +26 -2
- data/lib/tasks/tasks.rake +4 -53
- data/secure_headers.gemspec +15 -3
- metadata +31 -67
- data/.github/ISSUE_TEMPLATE.md +0 -41
- data/.github/PULL_REQUEST_TEMPLATE.md +0 -20
- data/.github/dependabot.yml +0 -6
- data/.github/workflows/build.yml +0 -25
- data/.github/workflows/github-release.yml +0 -28
- data/.gitignore +0 -13
- data/.rspec +0 -3
- data/.rubocop.yml +0 -4
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/CODE_OF_CONDUCT.md +0 -46
- data/CONTRIBUTING.md +0 -41
- data/Guardfile +0 -13
- data/Rakefile +0 -32
- data/docs/cookies.md +0 -65
- data/docs/hashes.md +0 -64
- data/docs/named_overrides_and_appends.md +0 -104
- data/docs/per_action_configuration.md +0 -139
- data/docs/sinatra.md +0 -25
- data/docs/upgrading-to-3-0.md +0 -42
- data/docs/upgrading-to-4-0.md +0 -35
- data/docs/upgrading-to-5-0.md +0 -15
- data/docs/upgrading-to-6-0.md +0 -50
- data/docs/upgrading-to-7-0.md +0 -12
- data/spec/lib/secure_headers/configuration_spec.rb +0 -121
- data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +0 -87
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +0 -215
- data/spec/lib/secure_headers/headers/cookie_spec.rb +0 -179
- data/spec/lib/secure_headers/headers/expect_certificate_transparency_spec.rb +0 -42
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +0 -265
- data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +0 -91
- data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +0 -33
- data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +0 -31
- data/spec/lib/secure_headers/headers/x_download_options_spec.rb +0 -29
- data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +0 -36
- data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +0 -48
- data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +0 -47
- data/spec/lib/secure_headers/middleware_spec.rb +0 -117
- data/spec/lib/secure_headers/view_helpers_spec.rb +0 -192
- data/spec/lib/secure_headers_spec.rb +0 -516
- data/spec/spec_helper.rb +0 -64
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
module SecureHeaders
|
|
3
3
|
class ReferrerPolicyConfigError < StandardError; end
|
|
4
4
|
class ReferrerPolicy
|
|
5
|
-
HEADER_NAME = "
|
|
5
|
+
HEADER_NAME = "referrer-policy".freeze
|
|
6
6
|
DEFAULT_VALUE = "origin-when-cross-origin"
|
|
7
7
|
VALID_POLICIES = %w(
|
|
8
8
|
no-referrer
|
|
@@ -15,29 +15,27 @@ module SecureHeaders
|
|
|
15
15
|
unsafe-url
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
end
|
|
18
|
+
# Public: generate an Referrer Policy header.
|
|
19
|
+
#
|
|
20
|
+
# Returns a default header if no configuration is provided, or a
|
|
21
|
+
# header name and value based on the config.
|
|
22
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
23
|
+
return if config == OPT_OUT
|
|
24
|
+
config ||= DEFAULT_VALUE
|
|
25
|
+
[HEADER_NAME, Array(config).join(", ")]
|
|
26
|
+
end
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
38
|
-
else
|
|
39
|
-
raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}")
|
|
28
|
+
def self.validate_config!(config)
|
|
29
|
+
case config
|
|
30
|
+
when nil, OPT_OUT
|
|
31
|
+
# valid
|
|
32
|
+
when String, Array
|
|
33
|
+
config = Array(config)
|
|
34
|
+
unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) }
|
|
35
|
+
raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}")
|
|
40
36
|
end
|
|
37
|
+
else
|
|
38
|
+
raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}")
|
|
41
39
|
end
|
|
42
40
|
end
|
|
43
41
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
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 a Reporting-Endpoints header.
|
|
9
|
+
#
|
|
10
|
+
# The config should be a Hash of endpoint names to URLs.
|
|
11
|
+
# Example: { "csp-endpoint" => "https://example.com/reports" }
|
|
12
|
+
#
|
|
13
|
+
# Returns nil if config is OPT_OUT or nil, or a header name and
|
|
14
|
+
# formatted header value based on the config.
|
|
15
|
+
def make_header(config = nil)
|
|
16
|
+
return if config.nil? || config == OPT_OUT
|
|
17
|
+
validate_config!(config)
|
|
18
|
+
[HEADER_NAME, format_endpoints(config)]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_config!(config)
|
|
22
|
+
case config
|
|
23
|
+
when nil, OPT_OUT
|
|
24
|
+
# valid
|
|
25
|
+
when Hash
|
|
26
|
+
config.each_pair do |name, url|
|
|
27
|
+
if name.is_a?(Symbol)
|
|
28
|
+
name = name.to_s
|
|
29
|
+
end
|
|
30
|
+
unless name.is_a?(String) && !name.empty?
|
|
31
|
+
raise ReportingEndpointsConfigError.new("Endpoint name must be a non-empty string, got: #{name.inspect}")
|
|
32
|
+
end
|
|
33
|
+
unless url.is_a?(String) && !url.empty?
|
|
34
|
+
raise ReportingEndpointsConfigError.new("Endpoint URL must be a non-empty string, got: #{url.inspect}")
|
|
35
|
+
end
|
|
36
|
+
unless url.start_with?("https://")
|
|
37
|
+
raise ReportingEndpointsConfigError.new("Endpoint URLs must use https, got: #{url.inspect}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
raise TypeError.new("Must be a Hash of endpoint names to URLs. Found #{config.class}: #{config}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def format_endpoints(config)
|
|
48
|
+
config.map do |name, url|
|
|
49
|
+
%{#{name}="#{url}"}
|
|
50
|
+
end.join(", ")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -3,27 +3,25 @@ module SecureHeaders
|
|
|
3
3
|
class STSConfigError < StandardError; end
|
|
4
4
|
|
|
5
5
|
class StrictTransportSecurity
|
|
6
|
-
HEADER_NAME = "
|
|
6
|
+
HEADER_NAME = "strict-transport-security".freeze
|
|
7
7
|
HSTS_MAX_AGE = "631138519"
|
|
8
8
|
DEFAULT_VALUE = "max-age=" + HSTS_MAX_AGE
|
|
9
9
|
VALID_STS_HEADER = /\Amax-age=\d+(; includeSubdomains)?(; preload)?\z/i
|
|
10
10
|
MESSAGE = "The config value supplied for the HSTS header was invalid. Must match #{VALID_STS_HEADER}"
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
end
|
|
12
|
+
# Public: generate an hsts header name, value pair.
|
|
13
|
+
#
|
|
14
|
+
# Returns a default header if no configuration is provided, or a
|
|
15
|
+
# header name and value based on the config.
|
|
16
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
17
|
+
return if config == OPT_OUT
|
|
18
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
19
|
+
end
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
end
|
|
21
|
+
def self.validate_config!(config)
|
|
22
|
+
return if config.nil? || config == OPT_OUT
|
|
23
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config} #{config.class}") unless config.is_a?(String)
|
|
24
|
+
raise STSConfigError.new(MESSAGE) unless config =~ VALID_STS_HEADER
|
|
27
25
|
end
|
|
28
26
|
end
|
|
29
27
|
end
|
|
@@ -3,25 +3,23 @@ module SecureHeaders
|
|
|
3
3
|
class XContentTypeOptionsConfigError < StandardError; end
|
|
4
4
|
|
|
5
5
|
class XContentTypeOptions
|
|
6
|
-
HEADER_NAME = "
|
|
6
|
+
HEADER_NAME = "x-content-type-options".freeze
|
|
7
7
|
DEFAULT_VALUE = "nosniff"
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
9
|
+
# Public: generate an X-Content-Type-Options header.
|
|
10
|
+
#
|
|
11
|
+
# Returns a default header if no configuration is provided, or a
|
|
12
|
+
# header name and value based on the config.
|
|
13
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
14
|
+
return if config == OPT_OUT
|
|
15
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
18
|
+
def self.validate_config!(config)
|
|
19
|
+
return if config.nil? || config == OPT_OUT
|
|
20
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
21
|
+
unless config.casecmp(DEFAULT_VALUE) == 0
|
|
22
|
+
raise XContentTypeOptionsConfigError.new("Value can only be nil or 'nosniff'")
|
|
25
23
|
end
|
|
26
24
|
end
|
|
27
25
|
end
|
|
@@ -2,25 +2,23 @@
|
|
|
2
2
|
module SecureHeaders
|
|
3
3
|
class XDOConfigError < StandardError; end
|
|
4
4
|
class XDownloadOptions
|
|
5
|
-
HEADER_NAME = "
|
|
5
|
+
HEADER_NAME = "x-download-options".freeze
|
|
6
6
|
DEFAULT_VALUE = "noopen"
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
8
|
+
# Public: generate an x-download-options header.
|
|
9
|
+
#
|
|
10
|
+
# Returns a default header if no configuration is provided, or a
|
|
11
|
+
# header name and value based on the config.
|
|
12
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
13
|
+
return if config == OPT_OUT
|
|
14
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
15
|
+
end
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
17
|
+
def self.validate_config!(config)
|
|
18
|
+
return if config.nil? || config == OPT_OUT
|
|
19
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
20
|
+
unless config.casecmp(DEFAULT_VALUE) == 0
|
|
21
|
+
raise XDOConfigError.new("Value can only be nil or 'noopen'")
|
|
24
22
|
end
|
|
25
23
|
end
|
|
26
24
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
module SecureHeaders
|
|
3
3
|
class XFOConfigError < StandardError; end
|
|
4
4
|
class XFrameOptions
|
|
5
|
-
HEADER_NAME = "
|
|
5
|
+
HEADER_NAME = "x-frame-options".freeze
|
|
6
6
|
SAMEORIGIN = "sameorigin"
|
|
7
7
|
DENY = "deny"
|
|
8
8
|
ALLOW_FROM = "allow-from"
|
|
@@ -10,22 +10,20 @@ module SecureHeaders
|
|
|
10
10
|
DEFAULT_VALUE = SAMEORIGIN
|
|
11
11
|
VALID_XFO_HEADER = /\A(#{SAMEORIGIN}\z|#{DENY}\z|#{ALLOW_ALL}\z|#{ALLOW_FROM}[:\s])/i
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
end
|
|
13
|
+
# Public: generate an X-Frame-Options header.
|
|
14
|
+
#
|
|
15
|
+
# Returns a default header if no configuration is provided, or a
|
|
16
|
+
# header name and value based on the config.
|
|
17
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
18
|
+
return if config == OPT_OUT
|
|
19
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
20
|
+
end
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
22
|
+
def self.validate_config!(config)
|
|
23
|
+
return if config.nil? || config == OPT_OUT
|
|
24
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
25
|
+
unless config =~ VALID_XFO_HEADER
|
|
26
|
+
raise XFOConfigError.new("Value must be SAMEORIGIN|DENY|ALLOW-FROM:|ALLOWALL")
|
|
29
27
|
end
|
|
30
28
|
end
|
|
31
29
|
end
|
|
@@ -2,26 +2,24 @@
|
|
|
2
2
|
module SecureHeaders
|
|
3
3
|
class XPCDPConfigError < StandardError; end
|
|
4
4
|
class XPermittedCrossDomainPolicies
|
|
5
|
-
HEADER_NAME = "
|
|
5
|
+
HEADER_NAME = "x-permitted-cross-domain-policies".freeze
|
|
6
6
|
DEFAULT_VALUE = "none"
|
|
7
7
|
VALID_POLICIES = %w(all none master-only by-content-type by-ftp-filename)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
9
|
+
# Public: generate an x-permitted-cross-domain-policies header.
|
|
10
|
+
#
|
|
11
|
+
# Returns a default header if no configuration is provided, or a
|
|
12
|
+
# header name and value based on the config.
|
|
13
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
14
|
+
return if config == OPT_OUT
|
|
15
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
18
|
+
def self.validate_config!(config)
|
|
19
|
+
return if config.nil? || config == OPT_OUT
|
|
20
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
21
|
+
unless VALID_POLICIES.include?(config.downcase)
|
|
22
|
+
raise XPCDPConfigError.new("Value can only be one of #{VALID_POLICIES.join(', ')}")
|
|
25
23
|
end
|
|
26
24
|
end
|
|
27
25
|
end
|
|
@@ -2,25 +2,23 @@
|
|
|
2
2
|
module SecureHeaders
|
|
3
3
|
class XXssProtectionConfigError < StandardError; end
|
|
4
4
|
class XXssProtection
|
|
5
|
-
HEADER_NAME = "
|
|
5
|
+
HEADER_NAME = "x-xss-protection".freeze
|
|
6
6
|
DEFAULT_VALUE = "0".freeze
|
|
7
7
|
VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
9
|
+
# Public: generate an X-Xss-Protection header.
|
|
10
|
+
#
|
|
11
|
+
# Returns a default header if no configuration is provided, or a
|
|
12
|
+
# header name and value based on the config.
|
|
13
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
14
|
+
return if config == OPT_OUT
|
|
15
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
18
|
+
def self.validate_config!(config)
|
|
19
|
+
return if config.nil? || config == OPT_OUT
|
|
20
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
21
|
+
raise XXssProtectionConfigError.new("Invalid format (see VALID_X_XSS_HEADER)") unless config.to_s =~ VALID_X_XSS_HEADER
|
|
24
22
|
end
|
|
25
23
|
end
|
|
26
24
|
end
|
|
@@ -10,6 +10,12 @@ module SecureHeaders
|
|
|
10
10
|
req = Rack::Request.new(env)
|
|
11
11
|
status, headers, response = @app.call(env)
|
|
12
12
|
|
|
13
|
+
# Rack::Headers is available in Rack 3.x and later
|
|
14
|
+
# So we should pull the headers into that structure if possible
|
|
15
|
+
if defined?(Rack::Headers)
|
|
16
|
+
headers = Rack::Headers[headers]
|
|
17
|
+
end
|
|
18
|
+
|
|
13
19
|
config = SecureHeaders.config_for(req)
|
|
14
20
|
flag_cookies!(headers, override_secure(env, config.cookies)) unless config.cookies == OPT_OUT
|
|
15
21
|
headers.merge!(SecureHeaders.header_hash_for(req))
|
|
@@ -20,14 +26,12 @@ module SecureHeaders
|
|
|
20
26
|
|
|
21
27
|
# inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
|
|
22
28
|
def flag_cookies!(headers, config)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
cookies = cookies.split("\n") unless cookies.is_a?(Array)
|
|
29
|
+
cookies = headers["Set-Cookie"]
|
|
30
|
+
return unless cookies
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
end
|
|
32
|
+
cookies_array = cookies.is_a?(Array) ? cookies : cookies.split("\n")
|
|
33
|
+
secured_cookies = cookies_array.map { |cookie| SecureHeaders::Cookie.new(cookie, config).to_s }
|
|
34
|
+
headers["Set-Cookie"] = cookies.is_a?(Array) ? secured_cookies : secured_cookies.join("\n")
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
# disable Secure cookies for non-https requests
|
|
@@ -4,11 +4,11 @@ if defined?(Rails::Railtie)
|
|
|
4
4
|
module SecureHeaders
|
|
5
5
|
class Railtie < Rails::Railtie
|
|
6
6
|
isolate_namespace SecureHeaders if defined? isolate_namespace # rails 3.0
|
|
7
|
-
conflicting_headers = ["
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
7
|
+
conflicting_headers = ["x-frame-options", "x-xss-protection",
|
|
8
|
+
"x-permitted-cross-domain-policies", "x-download-options",
|
|
9
|
+
"x-content-type-options", "strict-transport-security",
|
|
10
|
+
"content-security-policy", "content-security-policy-report-only",
|
|
11
|
+
"public-key-pins", "public-key-pins-report-only", "referrer-policy"]
|
|
12
12
|
|
|
13
13
|
initializer "secure_headers.middleware" do
|
|
14
14
|
Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
|
|
@@ -22,9 +22,12 @@ if defined?(Rails::Railtie)
|
|
|
22
22
|
ActiveSupport.on_load(:action_controller) do
|
|
23
23
|
include SecureHeaders
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
default_headers = Rails.application.config.action_dispatch.default_headers
|
|
26
|
+
unless default_headers.nil?
|
|
27
|
+
default_headers.each_key do |header|
|
|
28
|
+
if conflicting_headers.include?(header.downcase)
|
|
29
|
+
default_headers.delete(header)
|
|
30
|
+
end
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecureHeaders
|
|
4
|
+
module TaskHelper
|
|
5
|
+
include SecureHeaders::HashHelper
|
|
6
|
+
|
|
7
|
+
INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx
|
|
8
|
+
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx
|
|
9
|
+
INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
|
|
10
|
+
INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
|
|
11
|
+
|
|
12
|
+
def generate_inline_script_hashes(filename)
|
|
13
|
+
hashes = []
|
|
14
|
+
|
|
15
|
+
hashes.concat find_inline_content(filename, INLINE_SCRIPT_REGEX, false)
|
|
16
|
+
hashes.concat find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, true)
|
|
17
|
+
|
|
18
|
+
hashes
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def generate_inline_style_hashes(filename)
|
|
22
|
+
hashes = []
|
|
23
|
+
|
|
24
|
+
hashes.concat find_inline_content(filename, INLINE_STYLE_REGEX, false)
|
|
25
|
+
hashes.concat find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, true)
|
|
26
|
+
|
|
27
|
+
hashes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dynamic_content?(filename, inline_script)
|
|
31
|
+
!!(
|
|
32
|
+
(is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
|
|
33
|
+
(is_erb?(filename) && inline_script =~ /<%.*%>/)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def find_inline_content(filename, regex, strip_trailing_whitespace)
|
|
40
|
+
hashes = []
|
|
41
|
+
file = File.read(filename)
|
|
42
|
+
file.scan(regex) do # TODO don't use gsub
|
|
43
|
+
inline_script = Regexp.last_match.captures.last
|
|
44
|
+
inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace
|
|
45
|
+
if dynamic_content?(filename, inline_script)
|
|
46
|
+
puts "Looks like there's some dynamic content inside of a tag :-/"
|
|
47
|
+
puts "That pretty much means the hash value will never match."
|
|
48
|
+
puts "Code: " + inline_script
|
|
49
|
+
puts "=" * 20
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
hashes << hash_source(inline_script)
|
|
53
|
+
end
|
|
54
|
+
hashes
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def is_erb?(filename)
|
|
58
|
+
filename =~ /\.erb\Z/
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def is_mustache?(filename)
|
|
62
|
+
filename =~ /\.mustache\Z/
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/secure_headers.rb
CHANGED
|
@@ -11,6 +11,7 @@ require "secure_headers/headers/x_permitted_cross_domain_policies"
|
|
|
11
11
|
require "secure_headers/headers/referrer_policy"
|
|
12
12
|
require "secure_headers/headers/clear_site_data"
|
|
13
13
|
require "secure_headers/headers/expect_certificate_transparency"
|
|
14
|
+
require "secure_headers/headers/reporting_endpoints"
|
|
14
15
|
require "secure_headers/middleware"
|
|
15
16
|
require "secure_headers/railtie"
|
|
16
17
|
require "secure_headers/view_helper"
|
|
@@ -133,6 +134,7 @@ module SecureHeaders
|
|
|
133
134
|
# request.
|
|
134
135
|
#
|
|
135
136
|
# StrictTransportSecurity is not applied to http requests.
|
|
137
|
+
# upgrade_insecure_requests is not applied to http requests.
|
|
136
138
|
# See #config_for to determine which config is used for a given request.
|
|
137
139
|
#
|
|
138
140
|
# Returns a hash of header names => header values. The value
|
|
@@ -146,6 +148,11 @@ module SecureHeaders
|
|
|
146
148
|
|
|
147
149
|
if request.scheme != HTTPS
|
|
148
150
|
headers.delete(StrictTransportSecurity::HEADER_NAME)
|
|
151
|
+
|
|
152
|
+
# Remove upgrade_insecure_requests from CSP headers for HTTP requests
|
|
153
|
+
# as it doesn't make sense to upgrade requests when the page itself is served over HTTP
|
|
154
|
+
remove_upgrade_insecure_requests_from_csp!(headers, config.csp)
|
|
155
|
+
remove_upgrade_insecure_requests_from_csp!(headers, config.csp_report_only)
|
|
149
156
|
end
|
|
150
157
|
headers
|
|
151
158
|
end
|
|
@@ -178,7 +185,7 @@ module SecureHeaders
|
|
|
178
185
|
content_security_policy_nonce(request, ContentSecurityPolicy::STYLE_SRC)
|
|
179
186
|
end
|
|
180
187
|
|
|
181
|
-
# Public:
|
|
188
|
+
# Public: Retrieves the config for a given header type:
|
|
182
189
|
#
|
|
183
190
|
# Checks to see if there is an override for this request, then
|
|
184
191
|
# Checks to see if a named override is used for this request, then
|
|
@@ -208,7 +215,7 @@ module SecureHeaders
|
|
|
208
215
|
|
|
209
216
|
def config_and_target(request, target)
|
|
210
217
|
config = config_for(request)
|
|
211
|
-
target
|
|
218
|
+
target ||= guess_target(config)
|
|
212
219
|
raise_on_unknown_target(target)
|
|
213
220
|
[config, target]
|
|
214
221
|
end
|
|
@@ -242,6 +249,23 @@ module SecureHeaders
|
|
|
242
249
|
def override_secure_headers_request_config(request, config)
|
|
243
250
|
request.env[SECURE_HEADERS_CONFIG] = config
|
|
244
251
|
end
|
|
252
|
+
|
|
253
|
+
# Private: removes upgrade_insecure_requests directive from a CSP config
|
|
254
|
+
# if it's present, and updates the headers hash with the modified CSP.
|
|
255
|
+
#
|
|
256
|
+
# headers - the headers hash to update
|
|
257
|
+
# csp_config - the CSP config to check and potentially modify
|
|
258
|
+
#
|
|
259
|
+
# Returns nothing (modifies headers in place)
|
|
260
|
+
def remove_upgrade_insecure_requests_from_csp!(headers, csp_config)
|
|
261
|
+
return if csp_config.opt_out?
|
|
262
|
+
return unless csp_config.directive_value(ContentSecurityPolicy::UPGRADE_INSECURE_REQUESTS)
|
|
263
|
+
|
|
264
|
+
modified_config = csp_config.dup
|
|
265
|
+
modified_config.update_directive(ContentSecurityPolicy::UPGRADE_INSECURE_REQUESTS, false)
|
|
266
|
+
header_name, value = ContentSecurityPolicy.make_header(modified_config)
|
|
267
|
+
headers[header_name] = value if header_name && value
|
|
268
|
+
end
|
|
245
269
|
end
|
|
246
270
|
|
|
247
271
|
# These methods are mixed into controllers and delegate to the class method
|
data/lib/tasks/tasks.rake
CHANGED
|
@@ -1,58 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx unless defined? INLINE_STYLE_REGEX
|
|
4
|
-
INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_SCRIPT_HELPER_REGEX
|
|
5
|
-
INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_STYLE_HELPER_REGEX
|
|
2
|
+
require "secure_headers/task_helper"
|
|
6
3
|
|
|
7
4
|
namespace :secure_headers do
|
|
8
|
-
include SecureHeaders::
|
|
9
|
-
|
|
10
|
-
def is_erb?(filename)
|
|
11
|
-
filename =~ /\.erb\Z/
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def is_mustache?(filename)
|
|
15
|
-
filename =~ /\.mustache\Z/
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def dynamic_content?(filename, inline_script)
|
|
19
|
-
(is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
|
|
20
|
-
(is_erb?(filename) && inline_script =~ /<%.*%>/)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def find_inline_content(filename, regex, hashes, strip_trailing_whitespace)
|
|
24
|
-
file = File.read(filename)
|
|
25
|
-
file.scan(regex) do # TODO don't use gsub
|
|
26
|
-
inline_script = Regexp.last_match.captures.last
|
|
27
|
-
inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace
|
|
28
|
-
if dynamic_content?(filename, inline_script)
|
|
29
|
-
puts "Looks like there's some dynamic content inside of a tag :-/"
|
|
30
|
-
puts "That pretty much means the hash value will never match."
|
|
31
|
-
puts "Code: " + inline_script
|
|
32
|
-
puts "=" * 20
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
hashes << hash_source(inline_script)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def generate_inline_script_hashes(filename)
|
|
40
|
-
hashes = []
|
|
41
|
-
|
|
42
|
-
find_inline_content(filename, INLINE_SCRIPT_REGEX, hashes, false)
|
|
43
|
-
find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, hashes, true)
|
|
44
|
-
|
|
45
|
-
hashes
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def generate_inline_style_hashes(filename)
|
|
49
|
-
hashes = []
|
|
50
|
-
|
|
51
|
-
find_inline_content(filename, INLINE_STYLE_REGEX, hashes, false)
|
|
52
|
-
find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, hashes, true)
|
|
53
|
-
|
|
54
|
-
hashes
|
|
55
|
-
end
|
|
5
|
+
include SecureHeaders::TaskHelper
|
|
56
6
|
|
|
57
7
|
desc "Generate #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
|
|
58
8
|
task :generate_hashes do |t, args|
|
|
@@ -77,6 +27,7 @@ namespace :secure_headers do
|
|
|
77
27
|
file.write(script_hashes.to_yaml)
|
|
78
28
|
end
|
|
79
29
|
|
|
80
|
-
|
|
30
|
+
file_count = (script_hashes["scripts"].keys + script_hashes["styles"].keys).uniq.count
|
|
31
|
+
puts "Script and style hashes from #{file_count} files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
|
|
81
32
|
end
|
|
82
33
|
end
|