secure_headers 6.7.0 → 7.3.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 +61 -13
- 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 +14 -16
- 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 +16 -4
- metadata +32 -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 -24
- 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/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
|
@@ -39,6 +39,7 @@ module SecureHeaders
|
|
|
39
39
|
SCRIPT_SRC = :script_src
|
|
40
40
|
STYLE_SRC = :style_src
|
|
41
41
|
REPORT_URI = :report_uri
|
|
42
|
+
REPORT_TO = :report_to
|
|
42
43
|
|
|
43
44
|
DIRECTIVES_1_0 = [
|
|
44
45
|
DEFAULT_SRC,
|
|
@@ -51,7 +52,8 @@ module SecureHeaders
|
|
|
51
52
|
SANDBOX,
|
|
52
53
|
SCRIPT_SRC,
|
|
53
54
|
STYLE_SRC,
|
|
54
|
-
REPORT_URI
|
|
55
|
+
REPORT_URI,
|
|
56
|
+
REPORT_TO
|
|
55
57
|
].freeze
|
|
56
58
|
|
|
57
59
|
BASE_URI = :base_uri
|
|
@@ -98,9 +100,9 @@ module SecureHeaders
|
|
|
98
100
|
|
|
99
101
|
# Experimental directives - these vary greatly in support
|
|
100
102
|
# See MDN for details.
|
|
101
|
-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/
|
|
103
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/content-security-policy/trusted-types
|
|
102
104
|
TRUSTED_TYPES = :trusted_types
|
|
103
|
-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/
|
|
105
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/content-security-policy/require-trusted-types-for
|
|
104
106
|
REQUIRE_TRUSTED_TYPES_FOR = :require_trusted_types_for
|
|
105
107
|
|
|
106
108
|
DIRECTIVES_EXPERIMENTAL = [
|
|
@@ -110,9 +112,9 @@ module SecureHeaders
|
|
|
110
112
|
|
|
111
113
|
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_EXPERIMENTAL).uniq.sort
|
|
112
114
|
|
|
113
|
-
# Think of default-src and report-uri as the beginning and end respectively,
|
|
115
|
+
# Think of default-src and report-uri/report-to as the beginning and end respectively,
|
|
114
116
|
# everything else is in between.
|
|
115
|
-
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
|
|
117
|
+
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI, REPORT_TO]
|
|
116
118
|
|
|
117
119
|
DIRECTIVE_VALUE_TYPES = {
|
|
118
120
|
BASE_URI => :source_list,
|
|
@@ -129,10 +131,11 @@ module SecureHeaders
|
|
|
129
131
|
NAVIGATE_TO => :source_list,
|
|
130
132
|
OBJECT_SRC => :source_list,
|
|
131
133
|
PLUGIN_TYPES => :media_type_list,
|
|
134
|
+
PREFETCH_SRC => :source_list,
|
|
135
|
+
REPORT_TO => :report_to_endpoint,
|
|
136
|
+
REPORT_URI => :source_list,
|
|
132
137
|
REQUIRE_SRI_FOR => :require_sri_for_list,
|
|
133
138
|
REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list,
|
|
134
|
-
REPORT_URI => :source_list,
|
|
135
|
-
PREFETCH_SRC => :source_list,
|
|
136
139
|
SANDBOX => :sandbox_list,
|
|
137
140
|
SCRIPT_SRC => :source_list,
|
|
138
141
|
SCRIPT_SRC_ELEM => :source_list,
|
|
@@ -158,6 +161,7 @@ module SecureHeaders
|
|
|
158
161
|
FORM_ACTION,
|
|
159
162
|
FRAME_ANCESTORS,
|
|
160
163
|
NAVIGATE_TO,
|
|
164
|
+
REPORT_TO,
|
|
161
165
|
REPORT_URI,
|
|
162
166
|
]
|
|
163
167
|
|
|
@@ -201,7 +205,7 @@ module SecureHeaders
|
|
|
201
205
|
|
|
202
206
|
# Public: Validates each source expression.
|
|
203
207
|
#
|
|
204
|
-
# Does not validate the
|
|
208
|
+
# Does not validate the individual values of the source expression (e.g.
|
|
205
209
|
# script_src => h*t*t*p: will not raise an exception)
|
|
206
210
|
def validate_config!(config)
|
|
207
211
|
return if config.nil? || config.opt_out?
|
|
@@ -344,6 +348,8 @@ module SecureHeaders
|
|
|
344
348
|
validate_require_sri_source_expression!(directive, value)
|
|
345
349
|
when :require_trusted_types_for_list
|
|
346
350
|
validate_require_trusted_types_for_source_expression!(directive, value)
|
|
351
|
+
when :report_to_endpoint
|
|
352
|
+
validate_report_to_endpoint_expression!(directive, value)
|
|
347
353
|
else
|
|
348
354
|
raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
|
|
349
355
|
end
|
|
@@ -398,11 +404,23 @@ module SecureHeaders
|
|
|
398
404
|
end
|
|
399
405
|
end
|
|
400
406
|
|
|
407
|
+
# Private: validates that a report-to endpoint expression:
|
|
408
|
+
# 1. is a string
|
|
409
|
+
# 2. is not empty
|
|
410
|
+
def validate_report_to_endpoint_expression!(directive, endpoint_name)
|
|
411
|
+
unless endpoint_name.is_a?(String)
|
|
412
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be a string. Found #{endpoint_name.class} value")
|
|
413
|
+
end
|
|
414
|
+
if endpoint_name.empty?
|
|
415
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must not be empty")
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
401
419
|
# Private: validates that a source expression:
|
|
402
420
|
# 1. is an array of strings
|
|
403
421
|
# 2. does not contain any deprecated, now invalid values (inline, eval, self, none)
|
|
404
422
|
#
|
|
405
|
-
# Does not validate the
|
|
423
|
+
# Does not validate the individual values of the source expression (e.g.
|
|
406
424
|
# script_src => h*t*t*p: will not raise an exception)
|
|
407
425
|
def validate_source_expression!(directive, source_expression)
|
|
408
426
|
if source_expression != OPT_OUT
|
|
@@ -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 = "
|
|
6
|
-
DEFAULT_VALUE = "
|
|
5
|
+
HEADER_NAME = "x-xss-protection".freeze
|
|
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
|