secure_headers 3.6.4 → 4.0.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/.rspec +2 -0
- data/.rubocop.yml +3 -0
- data/.ruby-version +1 -1
- data/.travis.yml +8 -6
- data/CHANGELOG.md +21 -1
- data/CONTRIBUTING.md +3 -5
- data/Gemfile +7 -4
- data/Guardfile +1 -0
- data/README.md +15 -19
- data/Rakefile +22 -18
- data/docs/cookies.md +16 -2
- data/docs/hashes.md +1 -1
- data/docs/per_action_configuration.md +28 -0
- data/lib/secure_headers/configuration.rb +29 -16
- data/lib/secure_headers/hash_helper.rb +2 -1
- data/lib/secure_headers/headers/clear_site_data.rb +16 -12
- data/lib/secure_headers/headers/content_security_policy.rb +52 -20
- data/lib/secure_headers/headers/content_security_policy_config.rb +25 -0
- data/lib/secure_headers/headers/cookie.rb +21 -3
- data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
- data/lib/secure_headers/headers/policy_management.rb +115 -68
- data/lib/secure_headers/headers/public_key_pins.rb +5 -4
- data/lib/secure_headers/headers/referrer_policy.rb +1 -0
- data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
- data/lib/secure_headers/headers/x_content_type_options.rb +1 -0
- data/lib/secure_headers/headers/x_download_options.rb +2 -1
- data/lib/secure_headers/headers/x_frame_options.rb +1 -0
- data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +2 -1
- data/lib/secure_headers/headers/x_xss_protection.rb +2 -1
- data/lib/secure_headers/middleware.rb +10 -9
- data/lib/secure_headers/railtie.rb +7 -6
- data/lib/secure_headers/utils/cookies_config.rb +13 -12
- data/lib/secure_headers/view_helper.rb +4 -3
- data/lib/secure_headers.rb +6 -2
- data/lib/tasks/tasks.rake +2 -1
- data/secure_headers.gemspec +13 -3
- data/spec/lib/secure_headers/configuration_spec.rb +9 -8
- data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +11 -27
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +42 -26
- data/spec/lib/secure_headers/headers/cookie_spec.rb +38 -20
- data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +57 -10
- data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
- data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +4 -3
- data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +5 -4
- data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +2 -1
- data/spec/lib/secure_headers/headers/x_download_options_spec.rb +3 -2
- data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +2 -1
- data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +4 -3
- data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +4 -3
- data/spec/lib/secure_headers/middleware_spec.rb +29 -21
- data/spec/lib/secure_headers/view_helpers_spec.rb +5 -5
- data/spec/lib/secure_headers_spec.rb +96 -124
- data/spec/spec_helper.rb +10 -22
- data/upgrading-to-4-0.md +55 -0
- metadata +16 -6
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
module DynamicConfig
|
|
3
4
|
def self.included(base)
|
|
@@ -15,6 +16,30 @@ module SecureHeaders
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def initialize(hash)
|
|
19
|
+
@base_uri = nil
|
|
20
|
+
@block_all_mixed_content = nil
|
|
21
|
+
@child_src = nil
|
|
22
|
+
@connect_src = nil
|
|
23
|
+
@default_src = nil
|
|
24
|
+
@font_src = nil
|
|
25
|
+
@form_action = nil
|
|
26
|
+
@frame_ancestors = nil
|
|
27
|
+
@frame_src = nil
|
|
28
|
+
@img_src = nil
|
|
29
|
+
@manifest_src = nil
|
|
30
|
+
@media_src = nil
|
|
31
|
+
@object_src = nil
|
|
32
|
+
@plugin_types = nil
|
|
33
|
+
@preserve_schemes = nil
|
|
34
|
+
@report_only = nil
|
|
35
|
+
@report_uri = nil
|
|
36
|
+
@sandbox = nil
|
|
37
|
+
@script_nonce = nil
|
|
38
|
+
@script_src = nil
|
|
39
|
+
@style_nonce = nil
|
|
40
|
+
@style_src = nil
|
|
41
|
+
@upgrade_insecure_requests = nil
|
|
42
|
+
|
|
18
43
|
from_hash(hash)
|
|
19
44
|
@modified = false
|
|
20
45
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
require
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "cgi"
|
|
3
|
+
require "secure_headers/utils/cookies_config"
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
module SecureHeaders
|
|
5
7
|
class CookiesConfigError < StandardError; end
|
|
@@ -13,8 +15,18 @@ module SecureHeaders
|
|
|
13
15
|
|
|
14
16
|
attr_reader :raw_cookie, :config
|
|
15
17
|
|
|
18
|
+
COOKIE_DEFAULTS = {
|
|
19
|
+
httponly: true,
|
|
20
|
+
secure: true,
|
|
21
|
+
samesite: { lax: true },
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
16
24
|
def initialize(cookie, config)
|
|
17
25
|
@raw_cookie = cookie
|
|
26
|
+
unless config == OPT_OUT
|
|
27
|
+
config ||= {}
|
|
28
|
+
config = COOKIE_DEFAULTS.merge(config)
|
|
29
|
+
end
|
|
18
30
|
@config = config
|
|
19
31
|
@attributes = {
|
|
20
32
|
httponly: nil,
|
|
@@ -56,6 +68,7 @@ module SecureHeaders
|
|
|
56
68
|
end
|
|
57
69
|
|
|
58
70
|
def flag_cookie?(attribute)
|
|
71
|
+
return false if config == OPT_OUT
|
|
59
72
|
case config[attribute]
|
|
60
73
|
when TrueClass
|
|
61
74
|
true
|
|
@@ -85,6 +98,7 @@ module SecureHeaders
|
|
|
85
98
|
end
|
|
86
99
|
|
|
87
100
|
def flag_samesite?
|
|
101
|
+
return false if config == OPT_OUT || config[:samesite] == OPT_OUT
|
|
88
102
|
flag_samesite_lax? || flag_samesite_strict?
|
|
89
103
|
end
|
|
90
104
|
|
|
@@ -99,6 +113,10 @@ module SecureHeaders
|
|
|
99
113
|
def flag_samesite_enforcement?(mode)
|
|
100
114
|
return unless config[:samesite]
|
|
101
115
|
|
|
116
|
+
if config[:samesite].is_a?(TrueClass) && mode == :lax
|
|
117
|
+
return true
|
|
118
|
+
end
|
|
119
|
+
|
|
102
120
|
case config[:samesite][mode]
|
|
103
121
|
when Hash
|
|
104
122
|
conditionally_flag?(config[:samesite][mode])
|
|
@@ -113,7 +131,7 @@ module SecureHeaders
|
|
|
113
131
|
return unless cookie
|
|
114
132
|
|
|
115
133
|
cookie.split(/[;,]\s?/).each do |pairs|
|
|
116
|
-
name, values = pairs.split(
|
|
134
|
+
name, values = pairs.split("=", 2)
|
|
117
135
|
name = CGI.unescape(name)
|
|
118
136
|
|
|
119
137
|
attribute = name.downcase.to_sym
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
class ExpectCertificateTransparencyConfigError < StandardError; end
|
|
4
|
+
|
|
5
|
+
class ExpectCertificateTransparency
|
|
6
|
+
HEADER_NAME = "Expect-CT".freeze
|
|
7
|
+
CONFIG_KEY = :expect_certificate_transparency
|
|
8
|
+
INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze
|
|
9
|
+
INVALID_ENFORCE_VALUE_ERROR = "enforce must be a boolean".freeze
|
|
10
|
+
REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
|
|
11
|
+
INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Public: Generate a Expect-CT header.
|
|
15
|
+
#
|
|
16
|
+
# Returns nil if not configured, returns header name and value if
|
|
17
|
+
# configured.
|
|
18
|
+
def make_header(config)
|
|
19
|
+
return if config.nil?
|
|
20
|
+
|
|
21
|
+
header = new(config)
|
|
22
|
+
[HEADER_NAME, header.value]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_config!(config)
|
|
26
|
+
return if config.nil? || config == OPT_OUT
|
|
27
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
|
|
28
|
+
|
|
29
|
+
unless [true, false, nil].include?(config[:enforce])
|
|
30
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if !config[:max_age]
|
|
34
|
+
raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
|
|
35
|
+
elsif config[:max_age].to_s !~ /\A\d+\z/
|
|
36
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(config)
|
|
42
|
+
@enforced = config.fetch(:enforce, nil)
|
|
43
|
+
@max_age = config.fetch(:max_age, nil)
|
|
44
|
+
@report_uri = config.fetch(:report_uri, nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def value
|
|
48
|
+
[
|
|
49
|
+
enforced_directive,
|
|
50
|
+
max_age_directive,
|
|
51
|
+
report_uri_directive
|
|
52
|
+
].compact.join("; ").strip
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enforced_directive
|
|
56
|
+
# Unfortunately `if @enforced` isn't enough here in case someone
|
|
57
|
+
# passes in a random string so let's be specific with it to prevent
|
|
58
|
+
# accidental enforcement.
|
|
59
|
+
"enforce" if @enforced == true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def max_age_directive
|
|
63
|
+
"max-age=#{@max_age}" if @max_age
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def report_uri_directive
|
|
67
|
+
"report-uri=\"#{@report_uri}\"" if @report_uri
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
module PolicyManagement
|
|
3
4
|
def self.included(base)
|
|
@@ -5,8 +6,14 @@ module SecureHeaders
|
|
|
5
6
|
end
|
|
6
7
|
|
|
7
8
|
MODERN_BROWSERS = %w(Chrome Opera Firefox)
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
DEFAULT_CONFIG = {
|
|
10
|
+
default_src: %w(https:),
|
|
11
|
+
img_src: %w(https: data: 'self'),
|
|
12
|
+
object_src: %w('none'),
|
|
13
|
+
script_src: %w(https:),
|
|
14
|
+
style_src: %w('self' 'unsafe-inline' https:),
|
|
15
|
+
form_action: %w('self')
|
|
16
|
+
}.freeze
|
|
10
17
|
DATA_PROTOCOL = "data:".freeze
|
|
11
18
|
BLOB_PROTOCOL = "blob:".freeze
|
|
12
19
|
SELF = "'self'".freeze
|
|
@@ -62,22 +69,15 @@ module SecureHeaders
|
|
|
62
69
|
|
|
63
70
|
# All the directives currently under consideration for CSP level 3.
|
|
64
71
|
# https://w3c.github.io/webappsec/specs/CSP2/
|
|
72
|
+
BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
|
|
65
73
|
MANIFEST_SRC = :manifest_src
|
|
66
|
-
|
|
74
|
+
UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
|
|
67
75
|
DIRECTIVES_3_0 = [
|
|
68
76
|
DIRECTIVES_2_0,
|
|
69
|
-
MANIFEST_SRC,
|
|
70
|
-
REFLECTED_XSS
|
|
71
|
-
].flatten.freeze
|
|
72
|
-
|
|
73
|
-
# All the directives that are not currently in a formal spec, but have
|
|
74
|
-
# been implemented somewhere.
|
|
75
|
-
BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
|
|
76
|
-
UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
|
|
77
|
-
DIRECTIVES_DRAFT = [
|
|
78
77
|
BLOCK_ALL_MIXED_CONTENT,
|
|
78
|
+
MANIFEST_SRC,
|
|
79
79
|
UPGRADE_INSECURE_REQUESTS
|
|
80
|
-
].freeze
|
|
80
|
+
].flatten.freeze
|
|
81
81
|
|
|
82
82
|
EDGE_DIRECTIVES = DIRECTIVES_1_0
|
|
83
83
|
SAFARI_DIRECTIVES = DIRECTIVES_1_0
|
|
@@ -99,35 +99,23 @@ module SecureHeaders
|
|
|
99
99
|
].freeze
|
|
100
100
|
|
|
101
101
|
FIREFOX_DIRECTIVES = (
|
|
102
|
-
|
|
102
|
+
DIRECTIVES_3_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
|
|
103
103
|
).freeze
|
|
104
104
|
|
|
105
105
|
FIREFOX_46_DIRECTIVES = (
|
|
106
|
-
|
|
106
|
+
DIRECTIVES_3_0 - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
|
|
107
107
|
).freeze
|
|
108
108
|
|
|
109
109
|
CHROME_DIRECTIVES = (
|
|
110
|
-
|
|
110
|
+
DIRECTIVES_3_0
|
|
111
111
|
).freeze
|
|
112
112
|
|
|
113
|
-
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0
|
|
113
|
+
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0).uniq.sort
|
|
114
114
|
|
|
115
115
|
# Think of default-src and report-uri as the beginning and end respectively,
|
|
116
116
|
# everything else is in between.
|
|
117
117
|
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
|
|
118
118
|
|
|
119
|
-
# These are directives that do not inherit the default-src value. This is
|
|
120
|
-
# useful when calling #combine_policies.
|
|
121
|
-
NON_FETCH_SOURCES = [
|
|
122
|
-
BASE_URI,
|
|
123
|
-
FORM_ACTION,
|
|
124
|
-
FRAME_ANCESTORS,
|
|
125
|
-
PLUGIN_TYPES,
|
|
126
|
-
REPORT_URI
|
|
127
|
-
]
|
|
128
|
-
|
|
129
|
-
FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES
|
|
130
|
-
|
|
131
119
|
VARIATIONS = {
|
|
132
120
|
"Chrome" => CHROME_DIRECTIVES,
|
|
133
121
|
"Opera" => CHROME_DIRECTIVES,
|
|
@@ -155,15 +143,30 @@ module SecureHeaders
|
|
|
155
143
|
MANIFEST_SRC => :source_list,
|
|
156
144
|
MEDIA_SRC => :source_list,
|
|
157
145
|
OBJECT_SRC => :source_list,
|
|
158
|
-
PLUGIN_TYPES => :
|
|
159
|
-
REFLECTED_XSS => :string,
|
|
146
|
+
PLUGIN_TYPES => :media_type_list,
|
|
160
147
|
REPORT_URI => :source_list,
|
|
161
|
-
SANDBOX => :
|
|
148
|
+
SANDBOX => :sandbox_list,
|
|
162
149
|
SCRIPT_SRC => :source_list,
|
|
163
150
|
STYLE_SRC => :source_list,
|
|
164
151
|
UPGRADE_INSECURE_REQUESTS => :boolean
|
|
165
152
|
}.freeze
|
|
166
153
|
|
|
154
|
+
# These are directives that don't have use a source list, and hence do not
|
|
155
|
+
# inherit the default-src value.
|
|
156
|
+
NON_SOURCE_LIST_SOURCES = DIRECTIVE_VALUE_TYPES.select do |_, type|
|
|
157
|
+
type != :source_list
|
|
158
|
+
end.keys.freeze
|
|
159
|
+
|
|
160
|
+
# These are directives that take a source list, but that do not inherit
|
|
161
|
+
# the default-src value.
|
|
162
|
+
NON_FETCH_SOURCES = [
|
|
163
|
+
BASE_URI,
|
|
164
|
+
FORM_ACTION,
|
|
165
|
+
FRAME_ANCESTORS,
|
|
166
|
+
REPORT_URI
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES - NON_SOURCE_LIST_SOURCES
|
|
167
170
|
|
|
168
171
|
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
|
169
172
|
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
|
@@ -203,6 +206,10 @@ module SecureHeaders
|
|
|
203
206
|
def validate_config!(config)
|
|
204
207
|
return if config.nil? || config.opt_out?
|
|
205
208
|
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config.directive_value(:default_src)
|
|
209
|
+
if config.directive_value(:script_src).nil?
|
|
210
|
+
raise ContentSecurityPolicyConfigError.new(":script_src is required, falling back to default-src is too dangerous. Use `script_src: OPT_OUT` to override")
|
|
211
|
+
end
|
|
212
|
+
|
|
206
213
|
ContentSecurityPolicyConfig.attrs.each do |key|
|
|
207
214
|
value = config.directive_value(key)
|
|
208
215
|
next unless value
|
|
@@ -261,7 +268,7 @@ module SecureHeaders
|
|
|
261
268
|
# when each hash contains a value for a given key.
|
|
262
269
|
def merge_policy_additions(original, additions)
|
|
263
270
|
original.merge(additions) do |directive, lhs, rhs|
|
|
264
|
-
if
|
|
271
|
+
if list_directive?(directive)
|
|
265
272
|
(lhs.to_a + rhs.to_a).compact.uniq
|
|
266
273
|
else
|
|
267
274
|
rhs
|
|
@@ -269,20 +276,27 @@ module SecureHeaders
|
|
|
269
276
|
end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
|
|
270
277
|
end
|
|
271
278
|
|
|
279
|
+
# Returns True if a directive expects a list of values and False otherwise.
|
|
280
|
+
def list_directive?(directive)
|
|
281
|
+
source_list?(directive) ||
|
|
282
|
+
sandbox_list?(directive) ||
|
|
283
|
+
media_type_list?(directive)
|
|
284
|
+
end
|
|
285
|
+
|
|
272
286
|
# For each directive in additions that does not exist in the original config,
|
|
273
287
|
# copy the default-src value to the original config. This modifies the original hash.
|
|
274
288
|
def populate_fetch_source_with_default!(original, additions)
|
|
275
289
|
# in case we would be appending to an empty directive, fill it with the default-src value
|
|
276
290
|
additions.each_key do |directive|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
291
|
+
directive = if directive.to_s.end_with?("_nonce")
|
|
292
|
+
directive.to_s.gsub(/_nonce/, "_src").to_sym
|
|
293
|
+
else
|
|
294
|
+
directive
|
|
295
|
+
end
|
|
296
|
+
# Don't set a default if directive has an existing value
|
|
297
|
+
next if original[directive]
|
|
298
|
+
if FETCH_SOURCES.include?(directive)
|
|
299
|
+
original[directive] = default_for(directive, original)
|
|
286
300
|
end
|
|
287
301
|
end
|
|
288
302
|
end
|
|
@@ -293,45 +307,77 @@ module SecureHeaders
|
|
|
293
307
|
original[DEFAULT_SRC]
|
|
294
308
|
end
|
|
295
309
|
|
|
296
|
-
def nonce_added?(original, additions)
|
|
297
|
-
[:script_nonce, :style_nonce].each do |nonce|
|
|
298
|
-
if additions[nonce] && !original[nonce]
|
|
299
|
-
return true
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
end
|
|
303
|
-
|
|
304
310
|
def source_list?(directive)
|
|
305
311
|
DIRECTIVE_VALUE_TYPES[directive] == :source_list
|
|
306
312
|
end
|
|
307
313
|
|
|
314
|
+
def sandbox_list?(directive)
|
|
315
|
+
DIRECTIVE_VALUE_TYPES[directive] == :sandbox_list
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def media_type_list?(directive)
|
|
319
|
+
DIRECTIVE_VALUE_TYPES[directive] == :media_type_list
|
|
320
|
+
end
|
|
321
|
+
|
|
308
322
|
# Private: Validates that the configuration has a valid type, or that it is a valid
|
|
309
323
|
# source expression.
|
|
310
|
-
def validate_directive!(directive,
|
|
324
|
+
def validate_directive!(directive, value)
|
|
325
|
+
ensure_valid_directive!(directive)
|
|
311
326
|
case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
|
|
312
327
|
when :boolean
|
|
313
|
-
unless boolean?(
|
|
314
|
-
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean value")
|
|
315
|
-
end
|
|
316
|
-
when :string
|
|
317
|
-
unless source_expression.is_a?(String)
|
|
318
|
-
raise ContentSecurityPolicyConfigError.new("#{directive} Must be a string. Found #{config.class}: #{config} value")
|
|
328
|
+
unless boolean?(value)
|
|
329
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value")
|
|
319
330
|
end
|
|
331
|
+
when :sandbox_list
|
|
332
|
+
validate_sandbox_expression!(directive, value)
|
|
333
|
+
when :media_type_list
|
|
334
|
+
validate_media_type_expression!(directive, value)
|
|
335
|
+
when :source_list
|
|
336
|
+
validate_source_expression!(directive, value)
|
|
320
337
|
else
|
|
321
|
-
|
|
338
|
+
raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Private: validates that a sandbox token expression:
|
|
343
|
+
# 1. is an array of strings or optionally `true` (to enable maximal sandboxing)
|
|
344
|
+
# 2. For arrays, each element is of the form allow-*
|
|
345
|
+
def validate_sandbox_expression!(directive, sandbox_token_expression)
|
|
346
|
+
# We support sandbox: true to indicate a maximally secure sandbox.
|
|
347
|
+
return if boolean?(sandbox_token_expression) && sandbox_token_expression == true
|
|
348
|
+
ensure_array_of_strings!(directive, sandbox_token_expression)
|
|
349
|
+
valid = sandbox_token_expression.compact.all? do |v|
|
|
350
|
+
v.is_a?(String) && v.start_with?("allow-")
|
|
351
|
+
end
|
|
352
|
+
if !valid
|
|
353
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be True or an array of zero or more sandbox token strings (ex. allow-forms)")
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Private: validates that a media type expression:
|
|
358
|
+
# 1. is an array of strings
|
|
359
|
+
# 2. each element is of the form type/subtype
|
|
360
|
+
def validate_media_type_expression!(directive, media_type_expression)
|
|
361
|
+
ensure_array_of_strings!(directive, media_type_expression)
|
|
362
|
+
valid = media_type_expression.compact.all? do |v|
|
|
363
|
+
# All media types are of the form: <type from RFC 2045> "/" <subtype from RFC 2045>.
|
|
364
|
+
v =~ /\A.+\/.+\z/
|
|
365
|
+
end
|
|
366
|
+
if !valid
|
|
367
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of valid media types (ex. application/pdf)")
|
|
322
368
|
end
|
|
323
369
|
end
|
|
324
370
|
|
|
325
371
|
# Private: validates that a source expression:
|
|
326
|
-
# 1.
|
|
327
|
-
# 2.
|
|
328
|
-
# 3. does not contain any depreated, now invalid values (inline, eval, self, none)
|
|
372
|
+
# 1. is an array of strings
|
|
373
|
+
# 2. does not contain any deprecated, now invalid values (inline, eval, self, none)
|
|
329
374
|
#
|
|
330
375
|
# Does not validate the invididual values of the source expression (e.g.
|
|
331
376
|
# script_src => h*t*t*p: will not raise an exception)
|
|
332
377
|
def validate_source_expression!(directive, source_expression)
|
|
333
|
-
|
|
334
|
-
|
|
378
|
+
if source_expression != OPT_OUT
|
|
379
|
+
ensure_array_of_strings!(directive, source_expression)
|
|
380
|
+
end
|
|
335
381
|
ensure_valid_sources!(directive, source_expression)
|
|
336
382
|
end
|
|
337
383
|
|
|
@@ -341,16 +387,17 @@ module SecureHeaders
|
|
|
341
387
|
end
|
|
342
388
|
end
|
|
343
389
|
|
|
344
|
-
def ensure_array_of_strings!(directive,
|
|
345
|
-
|
|
390
|
+
def ensure_array_of_strings!(directive, value)
|
|
391
|
+
if (!value.is_a?(Array) || !value.compact.all? { |v| v.is_a?(String) })
|
|
346
392
|
raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
|
|
347
393
|
end
|
|
348
394
|
end
|
|
349
395
|
|
|
350
396
|
def ensure_valid_sources!(directive, source_expression)
|
|
351
|
-
source_expression
|
|
352
|
-
|
|
353
|
-
|
|
397
|
+
return if source_expression == OPT_OUT
|
|
398
|
+
source_expression.each do |expression|
|
|
399
|
+
if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(expression)
|
|
400
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{expression}). This value must be single quoted.")
|
|
354
401
|
end
|
|
355
402
|
end
|
|
356
403
|
end
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class PublicKeyPinsConfigError < StandardError; end
|
|
3
4
|
class PublicKeyPins
|
|
@@ -49,12 +50,12 @@ module SecureHeaders
|
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
def value
|
|
52
|
-
|
|
53
|
+
[
|
|
53
54
|
max_age_directive,
|
|
54
55
|
pin_directives,
|
|
55
56
|
report_uri_directive,
|
|
56
57
|
subdomain_directive
|
|
57
|
-
].compact.join(
|
|
58
|
+
].compact.join("; ").strip
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
def pin_directives
|
|
@@ -63,7 +64,7 @@ module SecureHeaders
|
|
|
63
64
|
pin.map do |token, hash|
|
|
64
65
|
"pin-#{token}=\"#{hash}\"" if HASH_ALGORITHMS.include?(token)
|
|
65
66
|
end
|
|
66
|
-
end.join(
|
|
67
|
+
end.join("; ")
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def max_age_directive
|
|
@@ -75,7 +76,7 @@ module SecureHeaders
|
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
def subdomain_directive
|
|
78
|
-
@include_subdomains ?
|
|
79
|
+
@include_subdomains ? "includeSubDomains" : nil
|
|
79
80
|
end
|
|
80
81
|
end
|
|
81
82
|
end
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class STSConfigError < StandardError; end
|
|
3
4
|
|
|
4
5
|
class StrictTransportSecurity
|
|
5
|
-
HEADER_NAME =
|
|
6
|
+
HEADER_NAME = "Strict-Transport-Security".freeze
|
|
6
7
|
HSTS_MAX_AGE = "631138519"
|
|
7
8
|
DEFAULT_VALUE = "max-age=" + HSTS_MAX_AGE
|
|
8
9
|
VALID_STS_HEADER = /\Amax-age=\d+(; includeSubdomains)?(; preload)?\z/i
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XDOConfigError < StandardError; end
|
|
3
4
|
class XDownloadOptions
|
|
4
5
|
HEADER_NAME = "X-Download-Options".freeze
|
|
5
|
-
DEFAULT_VALUE =
|
|
6
|
+
DEFAULT_VALUE = "noopen"
|
|
6
7
|
CONFIG_KEY = :x_download_options
|
|
7
8
|
|
|
8
9
|
class << self
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XPCDPConfigError < StandardError; end
|
|
3
4
|
class XPermittedCrossDomainPolicies
|
|
4
5
|
HEADER_NAME = "X-Permitted-Cross-Domain-Policies".freeze
|
|
5
|
-
DEFAULT_VALUE =
|
|
6
|
+
DEFAULT_VALUE = "none"
|
|
6
7
|
VALID_POLICIES = %w(all none master-only by-content-type by-ftp-filename)
|
|
7
8
|
CONFIG_KEY = :x_permitted_cross_domain_policies
|
|
8
9
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XXssProtectionConfigError < StandardError; end
|
|
3
4
|
class XXssProtection
|
|
4
|
-
HEADER_NAME =
|
|
5
|
+
HEADER_NAME = "X-XSS-Protection".freeze
|
|
5
6
|
DEFAULT_VALUE = "1; mode=block"
|
|
6
7
|
VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/i
|
|
7
8
|
CONFIG_KEY = :x_xss_protection
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class Middleware
|
|
3
4
|
HPKP_SAME_HOST_WARNING = "[WARNING] HPKP report host should not be the same as the request host. See https://github.com/twitter/secureheaders/issues/166"
|
|
@@ -25,11 +26,11 @@ module SecureHeaders
|
|
|
25
26
|
|
|
26
27
|
# inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
|
|
27
28
|
def flag_cookies!(headers, config)
|
|
28
|
-
if cookies = headers[
|
|
29
|
+
if cookies = headers["Set-Cookie"]
|
|
29
30
|
# Support Rails 2.3 / Rack 1.1 arrays as headers
|
|
30
31
|
cookies = cookies.split("\n") unless cookies.is_a?(Array)
|
|
31
32
|
|
|
32
|
-
headers[
|
|
33
|
+
headers["Set-Cookie"] = cookies.map do |cookie|
|
|
33
34
|
SecureHeaders::Cookie.new(cookie, config).to_s
|
|
34
35
|
end.join("\n")
|
|
35
36
|
end
|
|
@@ -37,8 +38,8 @@ module SecureHeaders
|
|
|
37
38
|
|
|
38
39
|
# disable Secure cookies for non-https requests
|
|
39
40
|
def override_secure(env, config = {})
|
|
40
|
-
if scheme(env) !=
|
|
41
|
-
config
|
|
41
|
+
if scheme(env) != "https" && config != OPT_OUT
|
|
42
|
+
config[:secure] = OPT_OUT
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
config
|
|
@@ -46,12 +47,12 @@ module SecureHeaders
|
|
|
46
47
|
|
|
47
48
|
# derived from https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L119
|
|
48
49
|
def scheme(env)
|
|
49
|
-
if env[
|
|
50
|
-
|
|
51
|
-
elsif env[
|
|
52
|
-
env[
|
|
50
|
+
if env["HTTPS"] == "on" || env["HTTP_X_SSL_REQUEST"] == "on"
|
|
51
|
+
"https"
|
|
52
|
+
elsif env["HTTP_X_FORWARDED_PROTO"]
|
|
53
|
+
env["HTTP_X_FORWARDED_PROTO"].split(",")[0]
|
|
53
54
|
else
|
|
54
|
-
env[
|
|
55
|
+
env["rack.url_scheme"]
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
58
|
end
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
# rails 3.1+
|
|
2
3
|
if defined?(Rails::Railtie)
|
|
3
4
|
module SecureHeaders
|
|
4
5
|
class Railtie < Rails::Railtie
|
|
5
6
|
isolate_namespace SecureHeaders if defined? isolate_namespace # rails 3.0
|
|
6
|
-
conflicting_headers = [
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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"]
|
|
11
12
|
|
|
12
13
|
initializer "secure_headers.middleware" do
|
|
13
14
|
Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
rake_tasks do
|
|
17
|
-
load File.expand_path(File.join(
|
|
18
|
+
load File.expand_path(File.join("..", "..", "lib", "tasks", "tasks.rake"), File.dirname(__FILE__))
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
initializer "secure_headers.action_controller" do
|