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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +3 -0
  4. data/.ruby-version +1 -1
  5. data/.travis.yml +8 -4
  6. data/CHANGELOG.md +38 -1
  7. data/CODE_OF_CONDUCT.md +46 -0
  8. data/CONTRIBUTING.md +41 -0
  9. data/Gemfile +7 -4
  10. data/Guardfile +1 -0
  11. data/LICENSE +4 -199
  12. data/README.md +18 -20
  13. data/Rakefile +22 -18
  14. data/docs/cookies.md +16 -2
  15. data/docs/hashes.md +1 -1
  16. data/docs/per_action_configuration.md +28 -0
  17. data/lib/secure_headers/configuration.rb +30 -17
  18. data/lib/secure_headers/hash_helper.rb +2 -1
  19. data/lib/secure_headers/headers/clear_site_data.rb +16 -12
  20. data/lib/secure_headers/headers/content_security_policy.rb +54 -22
  21. data/lib/secure_headers/headers/content_security_policy_config.rb +46 -12
  22. data/lib/secure_headers/headers/cookie.rb +21 -3
  23. data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
  24. data/lib/secure_headers/headers/policy_management.rb +122 -67
  25. data/lib/secure_headers/headers/public_key_pins.rb +5 -4
  26. data/lib/secure_headers/headers/referrer_policy.rb +2 -1
  27. data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
  28. data/lib/secure_headers/headers/x_content_type_options.rb +2 -1
  29. data/lib/secure_headers/headers/x_download_options.rb +3 -2
  30. data/lib/secure_headers/headers/x_frame_options.rb +2 -1
  31. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +3 -2
  32. data/lib/secure_headers/headers/x_xss_protection.rb +2 -1
  33. data/lib/secure_headers/middleware.rb +10 -9
  34. data/lib/secure_headers/railtie.rb +7 -6
  35. data/lib/secure_headers/utils/cookies_config.rb +13 -12
  36. data/lib/secure_headers/view_helper.rb +4 -3
  37. data/lib/secure_headers.rb +7 -3
  38. data/lib/tasks/tasks.rake +2 -1
  39. data/secure_headers.gemspec +13 -3
  40. data/spec/lib/secure_headers/configuration_spec.rb +9 -8
  41. data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +11 -27
  42. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +47 -26
  43. data/spec/lib/secure_headers/headers/cookie_spec.rb +38 -20
  44. data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
  45. data/spec/lib/secure_headers/headers/policy_management_spec.rb +57 -10
  46. data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
  47. data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +4 -3
  48. data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +5 -4
  49. data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +2 -1
  50. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +3 -2
  51. data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +2 -1
  52. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +4 -3
  53. data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +4 -3
  54. data/spec/lib/secure_headers/middleware_spec.rb +29 -21
  55. data/spec/lib/secure_headers/view_helpers_spec.rb +5 -5
  56. data/spec/lib/secure_headers_spec.rb +106 -107
  57. data/spec/spec_helper.rb +10 -22
  58. data/upgrading-to-4-0.md +55 -0
  59. 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
- 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
@@ -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
- REFLECTED_XSS = :reflected_xss
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
- DIRECTIVES_2_0 + DIRECTIVES_DRAFT - FIREFOX_UNSUPPORTED_DIRECTIVES
102
+ DIRECTIVES_3_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
102
103
  ).freeze
103
104
 
104
105
  FIREFOX_46_DIRECTIVES = (
105
- DIRECTIVES_2_0 + DIRECTIVES_DRAFT - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
106
+ DIRECTIVES_3_0 - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
106
107
  ).freeze
107
108
 
108
109
  CHROME_DIRECTIVES = (
109
- DIRECTIVES_2_0 + DIRECTIVES_DRAFT
110
+ DIRECTIVES_3_0
110
111
  ).freeze
111
112
 
112
- ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
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 => :source_list,
157
- REFLECTED_XSS => :string,
146
+ PLUGIN_TYPES => :media_type_list,
158
147
  REPORT_URI => :source_list,
159
- SANDBOX => :source_list,
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 source_list?(directive)
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.keys.each do |directive|
275
- if !original[directive] && ((source_list?(directive) && FETCH_SOURCES.include?(directive)) || nonce_added?(original, additions))
276
- if nonce_added?(original, additions)
277
- inferred_directive = directive.to_s.gsub(/_nonce/, "_src").to_sym
278
- unless original[inferred_directive] || NON_FETCH_SOURCES.include?(inferred_directive)
279
- original[inferred_directive] = original[:default_src]
280
- end
281
- else
282
- original[directive] = original[:default_src]
283
- end
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 nonce_added?(original, additions)
289
- [:script_nonce, :style_nonce].each do |nonce|
290
- if additions[nonce] && !original[nonce]
291
- return true
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, source_expression)
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?(source_expression)
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
- validate_source_expression!(directive, source_expression)
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. has a valid name
319
- # 2. is an array of strings
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
- ensure_valid_directive!(directive)
326
- ensure_array_of_strings!(directive, source_expression)
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, source_expression)
337
- unless source_expression.is_a?(Array) && source_expression.compact.all? { |v| v.is_a?(String) }
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.each do |source_expression|
344
- if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(source_expression)
345
- raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{source_expression}). This value must be single quoted.")
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
- header_value = [
53
+ [
53
54
  max_age_directive,
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,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 = 'Strict-Transport-Security'
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 = 'noopen'
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 = 'none'
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 = 'X-XSS-Protection'
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['Set-Cookie']
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['Set-Cookie'] = cookies.map do |cookie|
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) != 'https'
41
- config.merge!(secure: false)
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['HTTPS'] == 'on' || env['HTTP_X_SSL_REQUEST'] == 'on'
50
- 'https'
51
- elsif env['HTTP_X_FORWARDED_PROTO']
52
- env['HTTP_X_FORWARDED_PROTO'].split(',')[0]
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['rack.url_scheme']
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 = ['X-Frame-Options', 'X-XSS-Protection',
7
- 'X-Permitted-Cross-Domain-Policies', 'X-Download-Options',
8
- 'X-Content-Type-Options', 'Strict-Transport-Security',
9
- 'Content-Security-Policy', 'Content-Security-Policy-Report-Only',
10
- 'Public-Key-Pins', 'Public-Key-Pins-Report-Only', 'Referrer-Policy']
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('..', '..', 'lib', 'tasks', 'tasks.rake'), File.dirname(__FILE__))
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! if config[:secure]
15
- validate_httponly_config! if config[:httponly]
16
- validate_samesite_config! if config[:samesite]
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
- validate_hash_or_boolean!(:secure)
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
- validate_hash_or_boolean!(:httponly)
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], '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], '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 validate_hash_or_boolean!(attribute)
65
- if !(is_hash?(config[attribute]) || is_boolean?(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 is_boolean?(obj)
91
- obj && (obj.is_a?(TrueClass) || obj.is_a?(FalseClass))
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('app', 'views', self.instance_variable_get(:@virtual_path) + '.html.erb')
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/script_hashes.yml:***
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
@@ -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(arg = nil)
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
- config = config_for(request, prevent_dup = true)
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 [ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig].include?(klass)
184
+ header_name, value = if klass == ContentSecurityPolicyConfig || klass == ContentSecurityPolicyReportOnlyConfig
181
185
  csp_header_for_ua(header, user_agent)
182
186
  else
183
187
  header