secure_headers 3.9.0 → 4.0.0.alpha01

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.

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