secure_headers 3.6.0 → 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 -4
- data/CHANGELOG.md +38 -1
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +41 -0
- data/Gemfile +7 -4
- data/Guardfile +1 -0
- data/LICENSE +4 -199
- data/README.md +18 -20
- 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 +30 -17
- 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 +54 -22
- data/lib/secure_headers/headers/content_security_policy_config.rb +46 -12
- 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 +122 -67
- data/lib/secure_headers/headers/public_key_pins.rb +5 -4
- data/lib/secure_headers/headers/referrer_policy.rb +2 -1
- data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
- data/lib/secure_headers/headers/x_content_type_options.rb +2 -1
- data/lib/secure_headers/headers/x_download_options.rb +3 -2
- data/lib/secure_headers/headers/x_frame_options.rb +2 -1
- data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +3 -2
- 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 +7 -3
- 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 +47 -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 +106 -107
- data/spec/spec_helper.rb +10 -22
- data/upgrading-to-4-0.md +55 -0
- metadata +19 -7
|
@@ -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,25 +69,19 @@ 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
|
|
84
|
+
SAFARI_10_DIRECTIVES = DIRECTIVES_2_0
|
|
84
85
|
|
|
85
86
|
FIREFOX_UNSUPPORTED_DIRECTIVES = [
|
|
86
87
|
BLOCK_ALL_MIXED_CONTENT,
|
|
@@ -98,41 +99,30 @@ module SecureHeaders
|
|
|
98
99
|
].freeze
|
|
99
100
|
|
|
100
101
|
FIREFOX_DIRECTIVES = (
|
|
101
|
-
|
|
102
|
+
DIRECTIVES_3_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
|
|
102
103
|
).freeze
|
|
103
104
|
|
|
104
105
|
FIREFOX_46_DIRECTIVES = (
|
|
105
|
-
|
|
106
|
+
DIRECTIVES_3_0 - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
|
|
106
107
|
).freeze
|
|
107
108
|
|
|
108
109
|
CHROME_DIRECTIVES = (
|
|
109
|
-
|
|
110
|
+
DIRECTIVES_3_0
|
|
110
111
|
).freeze
|
|
111
112
|
|
|
112
|
-
ALL_DIRECTIVES =
|
|
113
|
+
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0).uniq.sort
|
|
113
114
|
|
|
114
115
|
# Think of default-src and report-uri as the beginning and end respectively,
|
|
115
116
|
# everything else is in between.
|
|
116
117
|
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
|
|
117
118
|
|
|
118
|
-
# These are directives that do not inherit the default-src value. This is
|
|
119
|
-
# useful when calling #combine_policies.
|
|
120
|
-
NON_FETCH_SOURCES = [
|
|
121
|
-
BASE_URI,
|
|
122
|
-
FORM_ACTION,
|
|
123
|
-
FRAME_ANCESTORS,
|
|
124
|
-
PLUGIN_TYPES,
|
|
125
|
-
REPORT_URI
|
|
126
|
-
]
|
|
127
|
-
|
|
128
|
-
FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES
|
|
129
|
-
|
|
130
119
|
VARIATIONS = {
|
|
131
120
|
"Chrome" => CHROME_DIRECTIVES,
|
|
132
121
|
"Opera" => CHROME_DIRECTIVES,
|
|
133
122
|
"Firefox" => FIREFOX_DIRECTIVES,
|
|
134
123
|
"FirefoxTransitional" => FIREFOX_46_DIRECTIVES,
|
|
135
124
|
"Safari" => SAFARI_DIRECTIVES,
|
|
125
|
+
"SafariTransitional" => SAFARI_10_DIRECTIVES,
|
|
136
126
|
"Edge" => EDGE_DIRECTIVES,
|
|
137
127
|
"Other" => CHROME_DIRECTIVES
|
|
138
128
|
}.freeze
|
|
@@ -153,15 +143,30 @@ module SecureHeaders
|
|
|
153
143
|
MANIFEST_SRC => :source_list,
|
|
154
144
|
MEDIA_SRC => :source_list,
|
|
155
145
|
OBJECT_SRC => :source_list,
|
|
156
|
-
PLUGIN_TYPES => :
|
|
157
|
-
REFLECTED_XSS => :string,
|
|
146
|
+
PLUGIN_TYPES => :media_type_list,
|
|
158
147
|
REPORT_URI => :source_list,
|
|
159
|
-
SANDBOX => :
|
|
148
|
+
SANDBOX => :sandbox_list,
|
|
160
149
|
SCRIPT_SRC => :source_list,
|
|
161
150
|
STYLE_SRC => :source_list,
|
|
162
151
|
UPGRADE_INSECURE_REQUESTS => :boolean
|
|
163
152
|
}.freeze
|
|
164
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
|
|
165
170
|
|
|
166
171
|
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
|
167
172
|
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
|
@@ -201,6 +206,10 @@ module SecureHeaders
|
|
|
201
206
|
def validate_config!(config)
|
|
202
207
|
return if config.nil? || config.opt_out?
|
|
203
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
|
+
|
|
204
213
|
ContentSecurityPolicyConfig.attrs.each do |key|
|
|
205
214
|
value = config.directive_value(key)
|
|
206
215
|
next unless value
|
|
@@ -259,7 +268,7 @@ module SecureHeaders
|
|
|
259
268
|
# when each hash contains a value for a given key.
|
|
260
269
|
def merge_policy_additions(original, additions)
|
|
261
270
|
original.merge(additions) do |directive, lhs, rhs|
|
|
262
|
-
if
|
|
271
|
+
if list_directive?(directive)
|
|
263
272
|
(lhs.to_a + rhs.to_a).compact.uniq
|
|
264
273
|
else
|
|
265
274
|
rhs
|
|
@@ -267,63 +276,108 @@ module SecureHeaders
|
|
|
267
276
|
end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
|
|
268
277
|
end
|
|
269
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
|
+
|
|
270
286
|
# For each directive in additions that does not exist in the original config,
|
|
271
287
|
# copy the default-src value to the original config. This modifies the original hash.
|
|
272
288
|
def populate_fetch_source_with_default!(original, additions)
|
|
273
289
|
# in case we would be appending to an empty directive, fill it with the default-src value
|
|
274
|
-
additions.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
290
|
+
additions.each_key do |directive|
|
|
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)
|
|
284
300
|
end
|
|
285
301
|
end
|
|
286
302
|
end
|
|
287
303
|
|
|
288
|
-
def
|
|
289
|
-
[
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
end
|
|
293
|
-
end
|
|
304
|
+
def default_for(directive, original)
|
|
305
|
+
return original[FRAME_SRC] if directive == CHILD_SRC && original[FRAME_SRC]
|
|
306
|
+
return original[CHILD_SRC] if directive == FRAME_SRC && original[CHILD_SRC]
|
|
307
|
+
original[DEFAULT_SRC]
|
|
294
308
|
end
|
|
295
309
|
|
|
296
310
|
def source_list?(directive)
|
|
297
311
|
DIRECTIVE_VALUE_TYPES[directive] == :source_list
|
|
298
312
|
end
|
|
299
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
|
+
|
|
300
322
|
# Private: Validates that the configuration has a valid type, or that it is a valid
|
|
301
323
|
# source expression.
|
|
302
|
-
def validate_directive!(directive,
|
|
324
|
+
def validate_directive!(directive, value)
|
|
325
|
+
ensure_valid_directive!(directive)
|
|
303
326
|
case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
|
|
304
327
|
when :boolean
|
|
305
|
-
unless boolean?(
|
|
306
|
-
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean value")
|
|
307
|
-
end
|
|
308
|
-
when :string
|
|
309
|
-
unless source_expression.is_a?(String)
|
|
310
|
-
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")
|
|
311
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)
|
|
312
337
|
else
|
|
313
|
-
|
|
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)")
|
|
314
368
|
end
|
|
315
369
|
end
|
|
316
370
|
|
|
317
371
|
# Private: validates that a source expression:
|
|
318
|
-
# 1.
|
|
319
|
-
# 2.
|
|
320
|
-
# 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)
|
|
321
374
|
#
|
|
322
375
|
# Does not validate the invididual values of the source expression (e.g.
|
|
323
376
|
# script_src => h*t*t*p: will not raise an exception)
|
|
324
377
|
def validate_source_expression!(directive, source_expression)
|
|
325
|
-
|
|
326
|
-
|
|
378
|
+
if source_expression != OPT_OUT
|
|
379
|
+
ensure_array_of_strings!(directive, source_expression)
|
|
380
|
+
end
|
|
327
381
|
ensure_valid_sources!(directive, source_expression)
|
|
328
382
|
end
|
|
329
383
|
|
|
@@ -333,16 +387,17 @@ module SecureHeaders
|
|
|
333
387
|
end
|
|
334
388
|
end
|
|
335
389
|
|
|
336
|
-
def ensure_array_of_strings!(directive,
|
|
337
|
-
|
|
390
|
+
def ensure_array_of_strings!(directive, value)
|
|
391
|
+
if (!value.is_a?(Array) || !value.compact.all? { |v| v.is_a?(String) })
|
|
338
392
|
raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
|
|
339
393
|
end
|
|
340
394
|
end
|
|
341
395
|
|
|
342
396
|
def ensure_valid_sources!(directive, source_expression)
|
|
343
|
-
source_expression
|
|
344
|
-
|
|
345
|
-
|
|
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.")
|
|
346
401
|
end
|
|
347
402
|
end
|
|
348
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,7 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class ReferrerPolicyConfigError < StandardError; end
|
|
3
4
|
class ReferrerPolicy
|
|
4
|
-
HEADER_NAME = "Referrer-Policy"
|
|
5
|
+
HEADER_NAME = "Referrer-Policy".freeze
|
|
5
6
|
DEFAULT_VALUE = "origin-when-cross-origin"
|
|
6
7
|
VALID_POLICIES = %w(
|
|
7
8
|
no-referrer
|
|
@@ -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 XContentTypeOptionsConfigError < StandardError; end
|
|
3
4
|
|
|
4
5
|
class XContentTypeOptions
|
|
5
|
-
HEADER_NAME = "X-Content-Type-Options"
|
|
6
|
+
HEADER_NAME = "X-Content-Type-Options".freeze
|
|
6
7
|
DEFAULT_VALUE = "nosniff"
|
|
7
8
|
CONFIG_KEY = :x_content_type_options
|
|
8
9
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XDOConfigError < StandardError; end
|
|
3
4
|
class XDownloadOptions
|
|
4
|
-
HEADER_NAME = "X-Download-Options"
|
|
5
|
-
DEFAULT_VALUE =
|
|
5
|
+
HEADER_NAME = "X-Download-Options".freeze
|
|
6
|
+
DEFAULT_VALUE = "noopen"
|
|
6
7
|
CONFIG_KEY = :x_download_options
|
|
7
8
|
|
|
8
9
|
class << self
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XFOConfigError < StandardError; end
|
|
3
4
|
class XFrameOptions
|
|
4
|
-
HEADER_NAME = "X-Frame-Options"
|
|
5
|
+
HEADER_NAME = "X-Frame-Options".freeze
|
|
5
6
|
CONFIG_KEY = :x_frame_options
|
|
6
7
|
SAMEORIGIN = "sameorigin"
|
|
7
8
|
DENY = "deny"
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XPCDPConfigError < StandardError; end
|
|
3
4
|
class XPermittedCrossDomainPolicies
|
|
4
|
-
HEADER_NAME = "X-Permitted-Cross-Domain-Policies"
|
|
5
|
-
DEFAULT_VALUE =
|
|
5
|
+
HEADER_NAME = "X-Permitted-Cross-Domain-Policies".freeze
|
|
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
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class CookiesConfig
|
|
3
4
|
|
|
@@ -11,9 +12,9 @@ module SecureHeaders
|
|
|
11
12
|
return if config.nil? || config == SecureHeaders::OPT_OUT
|
|
12
13
|
|
|
13
14
|
validate_config!
|
|
14
|
-
validate_secure_config!
|
|
15
|
-
validate_httponly_config!
|
|
16
|
-
validate_samesite_config!
|
|
15
|
+
validate_secure_config! unless config[:secure].nil?
|
|
16
|
+
validate_httponly_config! unless config[:httponly].nil?
|
|
17
|
+
validate_samesite_config! unless config[:samesite].nil?
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
private
|
|
@@ -23,16 +24,17 @@ module SecureHeaders
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def validate_secure_config!
|
|
26
|
-
|
|
27
|
+
validate_hash_or_true_or_opt_out!(:secure)
|
|
27
28
|
validate_exclusive_use_of_hash_constraints!(config[:secure], :secure)
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def validate_httponly_config!
|
|
31
|
-
|
|
32
|
+
validate_hash_or_true_or_opt_out!(:httponly)
|
|
32
33
|
validate_exclusive_use_of_hash_constraints!(config[:httponly], :httponly)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def validate_samesite_config!
|
|
37
|
+
return if config[:samesite] == OPT_OUT
|
|
36
38
|
raise CookiesConfigError.new("samesite cookie config must be a hash") unless is_hash?(config[:samesite])
|
|
37
39
|
|
|
38
40
|
validate_samesite_boolean_config!
|
|
@@ -51,18 +53,18 @@ module SecureHeaders
|
|
|
51
53
|
def validate_samesite_hash_config!
|
|
52
54
|
# validate Hash-based samesite configuration
|
|
53
55
|
if is_hash?(config[:samesite][:lax])
|
|
54
|
-
validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax],
|
|
56
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax], "samesite lax")
|
|
55
57
|
|
|
56
58
|
if is_hash?(config[:samesite][:strict])
|
|
57
|
-
validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict],
|
|
59
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict], "samesite strict")
|
|
58
60
|
validate_exclusive_use_of_samesite_enforcement!(:only)
|
|
59
61
|
validate_exclusive_use_of_samesite_enforcement!(:except)
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
def
|
|
65
|
-
if !(is_hash?(config[attribute]) ||
|
|
66
|
+
def validate_hash_or_true_or_opt_out!(attribute)
|
|
67
|
+
if !(is_hash?(config[attribute]) || is_true_or_opt_out?(config[attribute]))
|
|
66
68
|
raise CookiesConfigError.new("#{attribute} cookie config must be a hash or boolean")
|
|
67
69
|
end
|
|
68
70
|
end
|
|
@@ -70,7 +72,6 @@ module SecureHeaders
|
|
|
70
72
|
# validate exclusive use of only or except but not both at the same time
|
|
71
73
|
def validate_exclusive_use_of_hash_constraints!(conf, attribute)
|
|
72
74
|
return unless is_hash?(conf)
|
|
73
|
-
|
|
74
75
|
if conf.key?(:only) && conf.key?(:except)
|
|
75
76
|
raise CookiesConfigError.new("#{attribute} cookie config is invalid, simultaneous use of conditional arguments `only` and `except` is not permitted.")
|
|
76
77
|
end
|
|
@@ -87,8 +88,8 @@ module SecureHeaders
|
|
|
87
88
|
obj && obj.is_a?(Hash)
|
|
88
89
|
end
|
|
89
90
|
|
|
90
|
-
def
|
|
91
|
-
obj && (obj.is_a?(TrueClass) || obj
|
|
91
|
+
def is_true_or_opt_out?(obj)
|
|
92
|
+
obj && (obj.is_a?(TrueClass) || obj == OPT_OUT)
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
end
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
module ViewHelpers
|
|
3
4
|
include SecureHeaders::HashHelper
|
|
@@ -75,7 +76,7 @@ module SecureHeaders
|
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
content = capture(&block)
|
|
78
|
-
file_path = File.join(
|
|
79
|
+
file_path = File.join("app", "views", self.instance_variable_get(:@virtual_path) + ".html.erb")
|
|
79
80
|
|
|
80
81
|
if raise_error_on_unrecognized_hash
|
|
81
82
|
hash_value = hash_source(content)
|
|
@@ -95,9 +96,9 @@ module SecureHeaders
|
|
|
95
96
|
<<-EOF
|
|
96
97
|
\n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
|
|
97
98
|
#{content}
|
|
98
|
-
*** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/
|
|
99
|
+
*** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/secure_headers_generated_hashes.yml:***
|
|
99
100
|
#{file_path}:
|
|
100
|
-
- #{hash_value}\n\n
|
|
101
|
+
- \"#{hash_value}\"\n\n
|
|
101
102
|
NOTE: dynamic javascript is not supported using script hash integration
|
|
102
103
|
on purpose. It defeats the point of using it in the first place.
|
|
103
104
|
EOF
|
data/lib/secure_headers.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
require "secure_headers/configuration"
|
|
2
3
|
require "secure_headers/hash_helper"
|
|
3
4
|
require "secure_headers/headers/cookie"
|
|
@@ -11,6 +12,7 @@ require "secure_headers/headers/x_download_options"
|
|
|
11
12
|
require "secure_headers/headers/x_permitted_cross_domain_policies"
|
|
12
13
|
require "secure_headers/headers/referrer_policy"
|
|
13
14
|
require "secure_headers/headers/clear_site_data"
|
|
15
|
+
require "secure_headers/headers/expect_certificate_transparency"
|
|
14
16
|
require "secure_headers/middleware"
|
|
15
17
|
require "secure_headers/railtie"
|
|
16
18
|
require "secure_headers/view_helper"
|
|
@@ -23,7 +25,7 @@ module SecureHeaders
|
|
|
23
25
|
class NoOpHeaderConfig
|
|
24
26
|
include Singleton
|
|
25
27
|
|
|
26
|
-
def boom(
|
|
28
|
+
def boom(*args)
|
|
27
29
|
raise "Illegal State: attempted to modify NoOpHeaderConfig. Create a new config instead."
|
|
28
30
|
end
|
|
29
31
|
|
|
@@ -51,6 +53,7 @@ module SecureHeaders
|
|
|
51
53
|
CSP = ContentSecurityPolicy
|
|
52
54
|
|
|
53
55
|
ALL_HEADER_CLASSES = [
|
|
56
|
+
ExpectCertificateTransparency,
|
|
54
57
|
ClearSiteData,
|
|
55
58
|
ContentSecurityPolicyConfig,
|
|
56
59
|
ContentSecurityPolicyReportOnlyConfig,
|
|
@@ -163,7 +166,8 @@ module SecureHeaders
|
|
|
163
166
|
# returned is meant to be merged into the header value from `@app.call(env)`
|
|
164
167
|
# in Rack middleware.
|
|
165
168
|
def header_hash_for(request)
|
|
166
|
-
|
|
169
|
+
prevent_dup = true
|
|
170
|
+
config = config_for(request, prevent_dup)
|
|
167
171
|
headers = config.cached_headers
|
|
168
172
|
user_agent = UserAgent.parse(request.user_agent)
|
|
169
173
|
|
|
@@ -177,7 +181,7 @@ module SecureHeaders
|
|
|
177
181
|
|
|
178
182
|
header_classes_for(request).each_with_object({}) do |klass, hash|
|
|
179
183
|
if header = headers[klass::CONFIG_KEY]
|
|
180
|
-
header_name, value = if
|
|
184
|
+
header_name, value = if klass == ContentSecurityPolicyConfig || klass == ContentSecurityPolicyReportOnlyConfig
|
|
181
185
|
csp_header_for_ua(header, user_agent)
|
|
182
186
|
else
|
|
183
187
|
header
|