secure_headers 3.9.0 → 4.0.0.alpha01

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of secure_headers might be problematic. Click here for more details.

Files changed (55) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +3 -0
  4. data/.ruby-version +1 -1
  5. data/.travis.yml +8 -6
  6. data/CHANGELOG.md +2 -34
  7. data/CONTRIBUTING.md +1 -1
  8. data/Gemfile +7 -4
  9. data/Guardfile +1 -0
  10. data/README.md +4 -25
  11. data/Rakefile +22 -18
  12. data/docs/cookies.md +18 -5
  13. data/lib/secure_headers.rb +1 -2
  14. data/lib/secure_headers/configuration.rb +6 -16
  15. data/lib/secure_headers/hash_helper.rb +2 -1
  16. data/lib/secure_headers/headers/clear_site_data.rb +2 -1
  17. data/lib/secure_headers/headers/content_security_policy.rb +14 -60
  18. data/lib/secure_headers/headers/content_security_policy_config.rb +1 -1
  19. data/lib/secure_headers/headers/cookie.rb +22 -10
  20. data/lib/secure_headers/headers/policy_management.rb +57 -98
  21. data/lib/secure_headers/headers/public_key_pins.rb +4 -3
  22. data/lib/secure_headers/headers/referrer_policy.rb +1 -0
  23. data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
  24. data/lib/secure_headers/headers/x_content_type_options.rb +1 -0
  25. data/lib/secure_headers/headers/x_download_options.rb +2 -1
  26. data/lib/secure_headers/headers/x_frame_options.rb +1 -0
  27. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +2 -1
  28. data/lib/secure_headers/headers/x_xss_protection.rb +2 -1
  29. data/lib/secure_headers/middleware.rb +10 -9
  30. data/lib/secure_headers/railtie.rb +7 -6
  31. data/lib/secure_headers/utils/cookies_config.rb +17 -18
  32. data/lib/secure_headers/view_helper.rb +2 -1
  33. data/lib/tasks/tasks.rake +2 -1
  34. data/secure_headers.gemspec +13 -3
  35. data/spec/lib/secure_headers/configuration_spec.rb +9 -8
  36. data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +2 -1
  37. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +17 -53
  38. data/spec/lib/secure_headers/headers/cookie_spec.rb +58 -37
  39. data/spec/lib/secure_headers/headers/policy_management_spec.rb +20 -41
  40. data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
  41. data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +4 -3
  42. data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +5 -4
  43. data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +2 -1
  44. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +3 -2
  45. data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +2 -1
  46. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +4 -3
  47. data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +4 -3
  48. data/spec/lib/secure_headers/middleware_spec.rb +18 -21
  49. data/spec/lib/secure_headers/view_helpers_spec.rb +5 -4
  50. data/spec/lib/secure_headers_spec.rb +92 -120
  51. data/spec/spec_helper.rb +9 -23
  52. data/upgrading-to-4-0.md +49 -0
  53. metadata +16 -11
  54. data/lib/secure_headers/headers/expect_certificate_transparency.rb +0 -70
  55. data/spec/lib/secure_headers/headers/expect_certificate_transparency_spec.rb +0 -42
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module SecureHeaders
2
3
  module DynamicConfig
3
4
  def self.included(base)
@@ -37,7 +38,6 @@ module SecureHeaders
37
38
  @script_src = nil
38
39
  @style_nonce = nil
39
40
  @style_src = nil
40
- @worker_src = nil
41
41
  @upgrade_insecure_requests = nil
42
42
 
43
43
  from_hash(hash)
@@ -1,5 +1,7 @@
1
- require 'cgi'
2
- require 'secure_headers/utils/cookies_config'
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
@@ -81,13 +94,12 @@ module SecureHeaders
81
94
  "SameSite=Lax"
82
95
  elsif flag_samesite_strict?
83
96
  "SameSite=Strict"
84
- elsif flag_samesite_none?
85
- "SameSite=None"
86
97
  end
87
98
  end
88
99
 
89
100
  def flag_samesite?
90
- flag_samesite_lax? || flag_samesite_strict? || flag_samesite_none?
101
+ return false if config == OPT_OUT || config[:samesite] == OPT_OUT
102
+ flag_samesite_lax? || flag_samesite_strict?
91
103
  end
92
104
 
93
105
  def flag_samesite_lax?
@@ -98,13 +110,13 @@ module SecureHeaders
98
110
  flag_samesite_enforcement?(:strict)
99
111
  end
100
112
 
101
- def flag_samesite_none?
102
- flag_samesite_enforcement?(:none)
103
- end
104
-
105
113
  def flag_samesite_enforcement?(mode)
106
114
  return unless config[:samesite]
107
115
 
116
+ if config[:samesite].is_a?(TrueClass) && mode == :lax
117
+ return true
118
+ end
119
+
108
120
  case config[:samesite][mode]
109
121
  when Hash
110
122
  conditionally_flag?(config[:samesite][mode])
@@ -119,7 +131,7 @@ module SecureHeaders
119
131
  return unless cookie
120
132
 
121
133
  cookie.split(/[;,]\s?/).each do |pairs|
122
- name, values = pairs.split('=',2)
134
+ name, values = pairs.split("=", 2)
123
135
  name = CGI.unescape(name)
124
136
 
125
137
  attribute = name.downcase.to_sym
@@ -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
- DEFAULT_VALUE = "default-src https:".freeze
9
- DEFAULT_CONFIG = { default_src: %w(https:) }.freeze
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
@@ -65,13 +72,10 @@ module SecureHeaders
65
72
  BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
66
73
  MANIFEST_SRC = :manifest_src
67
74
  UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
68
- WORKER_SRC = :worker_src
69
-
70
75
  DIRECTIVES_3_0 = [
71
76
  DIRECTIVES_2_0,
72
77
  BLOCK_ALL_MIXED_CONTENT,
73
78
  MANIFEST_SRC,
74
- WORKER_SRC,
75
79
  UPGRADE_INSECURE_REQUESTS
76
80
  ].flatten.freeze
77
81
 
@@ -82,7 +86,6 @@ module SecureHeaders
82
86
  FIREFOX_UNSUPPORTED_DIRECTIVES = [
83
87
  BLOCK_ALL_MIXED_CONTENT,
84
88
  CHILD_SRC,
85
- WORKER_SRC,
86
89
  PLUGIN_TYPES
87
90
  ].freeze
88
91
 
@@ -92,7 +95,6 @@ module SecureHeaders
92
95
 
93
96
  FIREFOX_46_UNSUPPORTED_DIRECTIVES = [
94
97
  BLOCK_ALL_MIXED_CONTENT,
95
- WORKER_SRC,
96
98
  PLUGIN_TYPES
97
99
  ].freeze
98
100
 
@@ -114,6 +116,18 @@ module SecureHeaders
114
116
  # everything else is in between.
115
117
  BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
116
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
+
117
131
  VARIATIONS = {
118
132
  "Chrome" => CHROME_DIRECTIVES,
119
133
  "Opera" => CHROME_DIRECTIVES,
@@ -141,31 +155,14 @@ module SecureHeaders
141
155
  MANIFEST_SRC => :source_list,
142
156
  MEDIA_SRC => :source_list,
143
157
  OBJECT_SRC => :source_list,
144
- PLUGIN_TYPES => :media_type_list,
158
+ PLUGIN_TYPES => :source_list,
145
159
  REPORT_URI => :source_list,
146
- SANDBOX => :sandbox_list,
160
+ SANDBOX => :source_list,
147
161
  SCRIPT_SRC => :source_list,
148
162
  STYLE_SRC => :source_list,
149
- WORKER_SRC => :source_list,
150
163
  UPGRADE_INSECURE_REQUESTS => :boolean
151
164
  }.freeze
152
165
 
153
- # These are directives that don't have use a source list, and hence do not
154
- # inherit the default-src value.
155
- NON_SOURCE_LIST_SOURCES = DIRECTIVE_VALUE_TYPES.select do |_, type|
156
- type != :source_list
157
- end.keys.freeze
158
-
159
- # These are directives that take a source list, but that do not inherit
160
- # the default-src value.
161
- NON_FETCH_SOURCES = [
162
- BASE_URI,
163
- FORM_ACTION,
164
- FRAME_ANCESTORS,
165
- REPORT_URI
166
- ]
167
-
168
- FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES - NON_SOURCE_LIST_SOURCES
169
166
 
170
167
  STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
171
168
  HTTP_SCHEME_REGEX = %r{\Ahttps?://}
@@ -205,6 +202,7 @@ module SecureHeaders
205
202
  def validate_config!(config)
206
203
  return if config.nil? || config.opt_out?
207
204
  raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config.directive_value(:default_src)
205
+ raise ContentSecurityPolicyConfigError.new(":script_src is required, falling back to default-src is too dangerous") unless config.directive_value(:script_src)
208
206
  ContentSecurityPolicyConfig.attrs.each do |key|
209
207
  value = config.directive_value(key)
210
208
  next unless value
@@ -263,7 +261,7 @@ module SecureHeaders
263
261
  # when each hash contains a value for a given key.
264
262
  def merge_policy_additions(original, additions)
265
263
  original.merge(additions) do |directive, lhs, rhs|
266
- if list_directive?(directive)
264
+ if source_list?(directive)
267
265
  (lhs.to_a + rhs.to_a).compact.uniq
268
266
  else
269
267
  rhs
@@ -271,27 +269,20 @@ module SecureHeaders
271
269
  end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
272
270
  end
273
271
 
274
- # Returns True if a directive expects a list of values and False otherwise.
275
- def list_directive?(directive)
276
- source_list?(directive) ||
277
- sandbox_list?(directive) ||
278
- media_type_list?(directive)
279
- end
280
-
281
272
  # For each directive in additions that does not exist in the original config,
282
273
  # copy the default-src value to the original config. This modifies the original hash.
283
274
  def populate_fetch_source_with_default!(original, additions)
284
275
  # in case we would be appending to an empty directive, fill it with the default-src value
285
276
  additions.each_key do |directive|
286
- directive = if directive.to_s.end_with?("_nonce")
287
- directive.to_s.gsub(/_nonce/, "_src").to_sym
288
- else
289
- directive
290
- end
291
- # Don't set a default if directive has an existing value
292
- next if original[directive]
293
- if FETCH_SOURCES.include?(directive)
294
- original[directive] = default_for(directive, original)
277
+ if !original[directive] && ((source_list?(directive) && FETCH_SOURCES.include?(directive)) || nonce_added?(original, additions))
278
+ if nonce_added?(original, additions)
279
+ inferred_directive = directive.to_s.gsub(/_nonce/, "_src").to_sym
280
+ unless original[inferred_directive] || NON_FETCH_SOURCES.include?(inferred_directive)
281
+ original[inferred_directive] = default_for(directive, original)
282
+ end
283
+ else
284
+ original[directive] = default_for(directive, original)
285
+ end
295
286
  end
296
287
  end
297
288
  end
@@ -302,77 +293,45 @@ module SecureHeaders
302
293
  original[DEFAULT_SRC]
303
294
  end
304
295
 
305
- def source_list?(directive)
306
- DIRECTIVE_VALUE_TYPES[directive] == :source_list
307
- end
308
-
309
- def sandbox_list?(directive)
310
- DIRECTIVE_VALUE_TYPES[directive] == :sandbox_list
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
311
302
  end
312
303
 
313
- def media_type_list?(directive)
314
- DIRECTIVE_VALUE_TYPES[directive] == :media_type_list
304
+ def source_list?(directive)
305
+ DIRECTIVE_VALUE_TYPES[directive] == :source_list
315
306
  end
316
307
 
317
308
  # Private: Validates that the configuration has a valid type, or that it is a valid
318
309
  # source expression.
319
- def validate_directive!(directive, value)
320
- ensure_valid_directive!(directive)
310
+ def validate_directive!(directive, source_expression)
321
311
  case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
322
312
  when :boolean
323
- unless boolean?(value)
324
- raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value")
313
+ unless boolean?(source_expression)
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")
325
319
  end
326
- when :sandbox_list
327
- validate_sandbox_expression!(directive, value)
328
- when :media_type_list
329
- validate_media_type_expression!(directive, value)
330
- when :source_list
331
- validate_source_expression!(directive, value)
332
320
  else
333
- raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
334
- end
335
- end
336
-
337
- # Private: validates that a sandbox token expression:
338
- # 1. is an array of strings or optionally `true` (to enable maximal sandboxing)
339
- # 2. For arrays, each element is of the form allow-*
340
- def validate_sandbox_expression!(directive, sandbox_token_expression)
341
- # We support sandbox: true to indicate a maximally secure sandbox.
342
- return if boolean?(sandbox_token_expression) && sandbox_token_expression == true
343
- ensure_array_of_strings!(directive, sandbox_token_expression)
344
- valid = sandbox_token_expression.compact.all? do |v|
345
- v.is_a?(String) && v.start_with?("allow-")
346
- end
347
- if !valid
348
- raise ContentSecurityPolicyConfigError.new("#{directive} must be True or an array of zero or more sandbox token strings (ex. allow-forms)")
349
- end
350
- end
351
-
352
- # Private: validates that a media type expression:
353
- # 1. is an array of strings
354
- # 2. each element is of the form type/subtype
355
- def validate_media_type_expression!(directive, media_type_expression)
356
- ensure_array_of_strings!(directive, media_type_expression)
357
- valid = media_type_expression.compact.all? do |v|
358
- # All media types are of the form: <type from RFC 2045> "/" <subtype from RFC 2045>.
359
- v =~ /\A.+\/.+\z/
360
- end
361
- if !valid
362
- raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of valid media types (ex. application/pdf)")
321
+ validate_source_expression!(directive, source_expression)
363
322
  end
364
323
  end
365
324
 
366
325
  # Private: validates that a source expression:
367
- # 1. is an array of strings
368
- # 2. does not contain any deprecated, now invalid values (inline, eval, self, none)
326
+ # 1. has a valid name
327
+ # 2. is an array of strings
328
+ # 3. does not contain any depreated, now invalid values (inline, eval, self, none)
369
329
  #
370
330
  # Does not validate the invididual values of the source expression (e.g.
371
331
  # script_src => h*t*t*p: will not raise an exception)
372
332
  def validate_source_expression!(directive, source_expression)
373
- if source_expression != OPT_OUT
374
- ensure_array_of_strings!(directive, source_expression)
375
- end
333
+ ensure_valid_directive!(directive)
334
+ ensure_array_of_strings!(directive, source_expression)
376
335
  ensure_valid_sources!(directive, source_expression)
377
336
  end
378
337
 
@@ -382,8 +341,8 @@ module SecureHeaders
382
341
  end
383
342
  end
384
343
 
385
- def ensure_array_of_strings!(directive, value)
386
- if (!value.is_a?(Array) || !value.compact.all? { |v| v.is_a?(String) })
344
+ def ensure_array_of_strings!(directive, source_expression)
345
+ unless source_expression.is_a?(Array) && source_expression.compact.all? { |v| v.is_a?(String) }
387
346
  raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
388
347
  end
389
348
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module SecureHeaders
2
3
  class PublicKeyPinsConfigError < StandardError; end
3
4
  class PublicKeyPins
@@ -54,7 +55,7 @@ module SecureHeaders
54
55
  pin_directives,
55
56
  report_uri_directive,
56
57
  subdomain_directive
57
- ].compact.join('; ').strip
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 ? 'includeSubDomains' : nil
79
+ @include_subdomains ? "includeSubDomains" : nil
79
80
  end
80
81
  end
81
82
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module SecureHeaders
2
3
  class ReferrerPolicyConfigError < StandardError; end
3
4
  class ReferrerPolicy
@@ -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 = 'Strict-Transport-Security'.freeze
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,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module SecureHeaders
2
3
  class XContentTypeOptionsConfigError < StandardError; end
3
4
 
@@ -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 = 'noopen'
6
+ DEFAULT_VALUE = "noopen"
6
7
  CONFIG_KEY = :x_download_options
7
8
 
8
9
  class << self
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module SecureHeaders
2
3
  class XFOConfigError < StandardError; end
3
4
  class XFrameOptions
@@ -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 = 'none'
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 = 'X-XSS-Protection'.freeze
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