secure_headers 4.0.0.alpha01 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1cfa800eacf250583e379d13a9149d585bc2f48d
4
- data.tar.gz: 3de8c81b1308ec9d1d53f822cff8eeb7e20b5af0
3
+ metadata.gz: de4e0f18558ac4a65a9983f56e4dd65ca95e5f82
4
+ data.tar.gz: 3c16a5bdeec36aab812bcbfbbdba544b969c3a33
5
5
  SHA512:
6
- metadata.gz: 4c25ae5372ac00f74b0cae3b7de0a767a15b45f275bed64994c2cd7d078a2f0b52bdce562d14280984ad494fd008445d5d5448a3ac94694cd1f9778da3deeacc
7
- data.tar.gz: 6fab2c382ed56f9a63dd118a34b617fb82b4ee4e9ef55f37d78e134ec9068c9ee1022ba0a94991d0b7b82ea756d78132d3c0dfc909e66d4068acde4f6d2673ff
6
+ metadata.gz: 14f3bc42066b1b4bd6044b75a78818a6ce6edc60760e84af03d13623758a4d05b9397d0c4b4baa92c71d105e0334fa39ec56124c3014e8a63096236f9c6e6d51
7
+ data.tar.gz: f07cfc4d11b24a7d7ec1ad50b2fb756fd3a69c8558b922eb79daf35bbf5dc721c24ca27dfa8b5ca13865dc3345cb0162161f3c41fa04ffbff8c190908d8a7b32
data/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  ## 4.x
2
2
 
3
- - See the [upgrading to 4.0](upgrading-to-4.0.md) guide. Lots of breaking changes.
3
+ - See the [upgrading to 4.0](upgrading-to-4-0.md) guide. Lots of breaking changes.
4
+
5
+ ## 3.7.1
6
+
7
+ Fix support for the sandbox attribute of CSP. `true` and `[]` represent the maximally restricted policy (`sandbox;`) and validate other values.
8
+
9
+ ## 3.7.0
10
+
11
+ Adds support for the `Expect-CT` header (@jacobbednarz: https://github.com/twitter/secureheaders/pull/322)
12
+
13
+ ## 3.6.7
14
+
15
+ Actually set manifest-src when configured. https://github.com/twitter/secureheaders/pull/339 Thanks @carlosantoniodasilva!
4
16
 
5
17
  ## 3.6.5
6
18
 
data/README.md CHANGED
@@ -19,6 +19,7 @@ The gem will automatically apply several headers that are related to security.
19
19
  - X-Permitted-Cross-Domain-Policies - [Restrict Adobe Flash Player's access to data](https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html)
20
20
  - Referrer-Policy - [Referrer Policy draft](https://w3c.github.io/webappsec-referrer-policy/)
21
21
  - Public Key Pinning - Pin certificate fingerprints in the browser to prevent man-in-the-middle attacks due to compromised Certificate Authorities. [Public Key Pinning Specification](https://tools.ietf.org/html/rfc7469)
22
+ - Expect-CT - Only use certificates that are present in the certificate transparency logs. [Expect-CT draft specification](https://datatracker.ietf.org/doc/draft-stark-expect-ct/).
22
23
  - Clear-Site-Data - Clearing browser data for origin. [Clear-Site-Data specification](https://w3c.github.io/webappsec-clear-site-data/).
23
24
 
24
25
  It can also mark all http cookies with the Secure, HttpOnly and SameSite attributes (when configured to do so).
@@ -54,6 +55,8 @@ If you do not supply a `default` configuration, exceptions will be raised. If yo
54
55
 
55
56
  All `nil` values will fallback to their default values. `SecureHeaders::OPT_OUT` will disable the header entirely.
56
57
 
58
+ **Word of caution:** The following is not a default configuration per se. It serves as a sample implementation of the configuration. You should read more about these headers and determine what is appropriate for your requirements.
59
+
57
60
  ```ruby
58
61
  SecureHeaders::Configuration.default do |config|
59
62
  config.cookies = {
@@ -77,6 +80,11 @@ SecureHeaders::Configuration.default do |config|
77
80
  "storage",
78
81
  "executionContexts"
79
82
  ]
83
+ config.expect_certificate_transparency = {
84
+ enforce: false,
85
+ max_age: 1.day.to_i,
86
+ report_uri: "https://report-uri.io/example-ct"
87
+ }
80
88
  config.csp = {
81
89
  # "meta" values. these will shape the header, but the values are not included in the header.
82
90
  preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content.
@@ -94,6 +102,7 @@ SecureHeaders::Configuration.default do |config|
94
102
  manifest_src: %w('self'),
95
103
  media_src: %w(utoob.com),
96
104
  object_src: %w('self'),
105
+ sandbox: true, # true and [] will set a maximally restrictive setting
97
106
  plugin_types: %w(application/x-shockwave-flash),
98
107
  script_src: %w('self'),
99
108
  style_src: %w('unsafe-inline'),
@@ -103,3 +103,31 @@ Content-Security-Policy: ...
103
103
  console.log("won't execute, not whitelisted")
104
104
  </script>
105
105
  ```
106
+
107
+ ## Clearing browser cache
108
+
109
+ You can clear the browser cache after the logout request by using the following.
110
+
111
+ ``` ruby
112
+ class ApplicationController < ActionController::Base
113
+ # Configuration override to send the Clear-Site-Data header.
114
+ SecureHeaders::Configuration.override(:clear_browser_cache) do |config|
115
+ config.clear_site_data = [
116
+ SecureHeaders::ClearSiteData::ALL_TYPES
117
+ ]
118
+ end
119
+
120
+
121
+ # Clears the browser's cache for browsers supporting the Clear-Site-Data
122
+ # header.
123
+ #
124
+ # Returns nothing.
125
+ def clear_browser_cache
126
+ SecureHeaders.use_secure_headers_override(request, :clear_browser_cache)
127
+ end
128
+ end
129
+
130
+ class SessionsController < ApplicationController
131
+ after_action :clear_browser_cache, only: :destroy
132
+ end
133
+ ```
@@ -117,7 +117,7 @@ module SecureHeaders
117
117
 
118
118
  attr_writer :hsts, :x_frame_options, :x_content_type_options,
119
119
  :x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
120
- :referrer_policy, :clear_site_data
120
+ :referrer_policy, :clear_site_data, :expect_certificate_transparency
121
121
 
122
122
  attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host
123
123
 
@@ -144,6 +144,7 @@ module SecureHeaders
144
144
  @x_frame_options = nil
145
145
  @x_permitted_cross_domain_policies = nil
146
146
  @x_xss_protection = nil
147
+ @expect_certificate_transparency = nil
147
148
 
148
149
  self.hpkp = OPT_OUT
149
150
  self.referrer_policy = OPT_OUT
@@ -169,6 +170,7 @@ module SecureHeaders
169
170
  copy.x_download_options = @x_download_options
170
171
  copy.x_permitted_cross_domain_policies = @x_permitted_cross_domain_policies
171
172
  copy.clear_site_data = @clear_site_data
173
+ copy.expect_certificate_transparency = @expect_certificate_transparency
172
174
  copy.referrer_policy = @referrer_policy
173
175
  copy.hpkp = @hpkp
174
176
  copy.hpkp_report_host = @hpkp_report_host
@@ -201,6 +203,7 @@ module SecureHeaders
201
203
  XDownloadOptions.validate_config!(@x_download_options)
202
204
  XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
203
205
  ClearSiteData.validate_config!(@clear_site_data)
206
+ ExpectCertificateTransparency.validate_config!(@expect_certificate_transparency)
204
207
  PublicKeyPins.validate_config!(@hpkp)
205
208
  Cookie.validate_config!(@cookies)
206
209
  end
@@ -8,8 +8,8 @@ module SecureHeaders
8
8
  CACHE = "cache".freeze
9
9
  COOKIES = "cookies".freeze
10
10
  STORAGE = "storage".freeze
11
- EXECTION_CONTEXTS = "executionContexts".freeze
12
- ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECTION_CONTEXTS]
11
+ EXECUTION_CONTEXTS = "executionContexts".freeze
12
+ ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]
13
13
 
14
14
  CONFIG_KEY = :clear_site_data
15
15
 
@@ -75,20 +75,55 @@ module SecureHeaders
75
75
  case DIRECTIVE_VALUE_TYPES[directive_name]
76
76
  when :boolean
77
77
  symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
78
- when :string
79
- [symbol_to_hyphen_case(directive_name), @config.directive_value(directive_name)].join(" ")
80
- else
81
- build_directive(directive_name)
78
+ when :sandbox_list
79
+ build_sandbox_list_directive(directive_name)
80
+ when :media_type_list
81
+ build_media_type_list_directive(directive_name)
82
+ when :source_list
83
+ build_source_list_directive(directive_name)
82
84
  end
83
85
  end.compact.join("; ")
84
86
  end
85
87
 
88
+ def build_sandbox_list_directive(directive)
89
+ return unless sandbox_list = @config.directive_value(directive)
90
+ max_strict_policy = case sandbox_list
91
+ when Array
92
+ sandbox_list.empty?
93
+ when true
94
+ true
95
+ else
96
+ false
97
+ end
98
+
99
+ # A maximally strict sandbox policy is just the `sandbox` directive,
100
+ # whith no configuraiton values.
101
+ if max_strict_policy
102
+ symbol_to_hyphen_case(directive)
103
+ elsif sandbox_list && sandbox_list.any?
104
+ [
105
+ symbol_to_hyphen_case(directive),
106
+ sandbox_list.uniq
107
+ ].join(" ")
108
+ end
109
+ end
110
+
111
+ def build_media_type_list_directive(directive)
112
+ return unless media_type_list = @config.directive_value(directive)
113
+ if media_type_list && media_type_list.any?
114
+ [
115
+ symbol_to_hyphen_case(directive),
116
+ media_type_list.uniq
117
+ ].join(" ")
118
+ end
119
+ end
120
+
86
121
  # Private: builds a string that represents one directive in a minified form.
87
122
  #
88
123
  # directive_name - a symbol representing the various ALL_DIRECTIVES
89
124
  #
90
125
  # Returns a string representing a directive.
91
- def build_directive(directive)
126
+ def build_source_list_directive(directive)
92
127
  source_list = case directive
93
128
  when :child_src
94
129
  if supported_directives.include?(:child_src)
@@ -101,9 +136,11 @@ module SecureHeaders
101
136
  else
102
137
  @config.directive_value(directive)
103
138
  end
104
- return unless source_list && source_list.any?
105
- normalized_source_list = minify_source_list(directive, source_list)
106
- [symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
139
+
140
+ if source_list != OPT_OUT && source_list && source_list.any?
141
+ normalized_source_list = minify_source_list(directive, source_list)
142
+ [symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
143
+ end
107
144
  end
108
145
 
109
146
  # If a directive contains *, all other values are omitted.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ module SecureHeaders
3
+ class ExpectCertificateTransparencyConfigError < StandardError; end
4
+
5
+ class ExpectCertificateTransparency
6
+ HEADER_NAME = "Expect-CT".freeze
7
+ CONFIG_KEY = :expect_certificate_transparency
8
+ INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze
9
+ INVALID_ENFORCE_VALUE_ERROR = "enforce must be a boolean".freeze
10
+ REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
11
+ INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze
12
+
13
+ class << self
14
+ # Public: Generate a Expect-CT header.
15
+ #
16
+ # Returns nil if not configured, returns header name and value if
17
+ # configured.
18
+ def make_header(config)
19
+ return if config.nil?
20
+
21
+ header = new(config)
22
+ [HEADER_NAME, header.value]
23
+ end
24
+
25
+ def validate_config!(config)
26
+ return if config.nil? || config == OPT_OUT
27
+ raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
28
+
29
+ unless [true, false, nil].include?(config[:enforce])
30
+ raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
31
+ end
32
+
33
+ if !config[:max_age]
34
+ raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
35
+ elsif config[:max_age].to_s !~ /\A\d+\z/
36
+ raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
37
+ end
38
+ end
39
+ end
40
+
41
+ def initialize(config)
42
+ @enforced = config.fetch(:enforce, nil)
43
+ @max_age = config.fetch(:max_age, nil)
44
+ @report_uri = config.fetch(:report_uri, nil)
45
+ end
46
+
47
+ def value
48
+ [
49
+ enforced_directive,
50
+ max_age_directive,
51
+ report_uri_directive
52
+ ].compact.join("; ").strip
53
+ end
54
+
55
+ def enforced_directive
56
+ # Unfortunately `if @enforced` isn't enough here in case someone
57
+ # passes in a random string so let's be specific with it to prevent
58
+ # accidental enforcement.
59
+ "enforce" if @enforced == true
60
+ end
61
+
62
+ def max_age_directive
63
+ "max-age=#{@max_age}" if @max_age
64
+ end
65
+
66
+ def report_uri_directive
67
+ "report-uri=\"#{@report_uri}\"" if @report_uri
68
+ end
69
+ end
70
+ end
@@ -116,18 +116,6 @@ module SecureHeaders
116
116
  # everything else is in between.
117
117
  BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
118
118
 
119
- # These are directives that do not inherit the default-src value. This is
120
- # useful when calling #combine_policies.
121
- NON_FETCH_SOURCES = [
122
- BASE_URI,
123
- FORM_ACTION,
124
- FRAME_ANCESTORS,
125
- PLUGIN_TYPES,
126
- REPORT_URI
127
- ]
128
-
129
- FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES
130
-
131
119
  VARIATIONS = {
132
120
  "Chrome" => CHROME_DIRECTIVES,
133
121
  "Opera" => CHROME_DIRECTIVES,
@@ -155,14 +143,30 @@ module SecureHeaders
155
143
  MANIFEST_SRC => :source_list,
156
144
  MEDIA_SRC => :source_list,
157
145
  OBJECT_SRC => :source_list,
158
- PLUGIN_TYPES => :source_list,
146
+ PLUGIN_TYPES => :media_type_list,
159
147
  REPORT_URI => :source_list,
160
- SANDBOX => :source_list,
148
+ SANDBOX => :sandbox_list,
161
149
  SCRIPT_SRC => :source_list,
162
150
  STYLE_SRC => :source_list,
163
151
  UPGRADE_INSECURE_REQUESTS => :boolean
164
152
  }.freeze
165
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
166
170
 
167
171
  STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
168
172
  HTTP_SCHEME_REGEX = %r{\Ahttps?://}
@@ -202,7 +206,10 @@ module SecureHeaders
202
206
  def validate_config!(config)
203
207
  return if config.nil? || config.opt_out?
204
208
  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)
209
+ if config.directive_value(:script_src).nil?
210
+ raise ContentSecurityPolicyConfigError.new(":script_src is required, falling back to default-src is too dangerous. Use `script_src: OPT_OUT` to override")
211
+ end
212
+
206
213
  ContentSecurityPolicyConfig.attrs.each do |key|
207
214
  value = config.directive_value(key)
208
215
  next unless value
@@ -261,7 +268,7 @@ module SecureHeaders
261
268
  # when each hash contains a value for a given key.
262
269
  def merge_policy_additions(original, additions)
263
270
  original.merge(additions) do |directive, lhs, rhs|
264
- if source_list?(directive)
271
+ if list_directive?(directive)
265
272
  (lhs.to_a + rhs.to_a).compact.uniq
266
273
  else
267
274
  rhs
@@ -269,20 +276,27 @@ module SecureHeaders
269
276
  end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
270
277
  end
271
278
 
279
+ # Returns True if a directive expects a list of values and False otherwise.
280
+ def list_directive?(directive)
281
+ source_list?(directive) ||
282
+ sandbox_list?(directive) ||
283
+ media_type_list?(directive)
284
+ end
285
+
272
286
  # For each directive in additions that does not exist in the original config,
273
287
  # copy the default-src value to the original config. This modifies the original hash.
274
288
  def populate_fetch_source_with_default!(original, additions)
275
289
  # in case we would be appending to an empty directive, fill it with the default-src value
276
290
  additions.each_key do |directive|
277
- 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
291
+ directive = if directive.to_s.end_with?("_nonce")
292
+ directive.to_s.gsub(/_nonce/, "_src").to_sym
293
+ else
294
+ directive
295
+ end
296
+ # Don't set a default if directive has an existing value
297
+ next if original[directive]
298
+ if FETCH_SOURCES.include?(directive)
299
+ original[directive] = default_for(directive, original)
286
300
  end
287
301
  end
288
302
  end
@@ -293,45 +307,77 @@ module SecureHeaders
293
307
  original[DEFAULT_SRC]
294
308
  end
295
309
 
296
- def nonce_added?(original, additions)
297
- [:script_nonce, :style_nonce].each do |nonce|
298
- if additions[nonce] && !original[nonce]
299
- return true
300
- end
301
- end
302
- end
303
-
304
310
  def source_list?(directive)
305
311
  DIRECTIVE_VALUE_TYPES[directive] == :source_list
306
312
  end
307
313
 
314
+ def sandbox_list?(directive)
315
+ DIRECTIVE_VALUE_TYPES[directive] == :sandbox_list
316
+ end
317
+
318
+ def media_type_list?(directive)
319
+ DIRECTIVE_VALUE_TYPES[directive] == :media_type_list
320
+ end
321
+
308
322
  # Private: Validates that the configuration has a valid type, or that it is a valid
309
323
  # source expression.
310
- def validate_directive!(directive, source_expression)
324
+ def validate_directive!(directive, value)
325
+ ensure_valid_directive!(directive)
311
326
  case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
312
327
  when :boolean
313
- unless boolean?(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")
328
+ unless boolean?(value)
329
+ raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value")
319
330
  end
331
+ when :sandbox_list
332
+ validate_sandbox_expression!(directive, value)
333
+ when :media_type_list
334
+ validate_media_type_expression!(directive, value)
335
+ when :source_list
336
+ validate_source_expression!(directive, value)
320
337
  else
321
- 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)")
322
368
  end
323
369
  end
324
370
 
325
371
  # Private: validates that a source expression:
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)
372
+ # 1. is an array of strings
373
+ # 2. does not contain any deprecated, now invalid values (inline, eval, self, none)
329
374
  #
330
375
  # Does not validate the invididual values of the source expression (e.g.
331
376
  # script_src => h*t*t*p: will not raise an exception)
332
377
  def validate_source_expression!(directive, source_expression)
333
- ensure_valid_directive!(directive)
334
- ensure_array_of_strings!(directive, source_expression)
378
+ if source_expression != OPT_OUT
379
+ ensure_array_of_strings!(directive, source_expression)
380
+ end
335
381
  ensure_valid_sources!(directive, source_expression)
336
382
  end
337
383
 
@@ -341,13 +387,14 @@ module SecureHeaders
341
387
  end
342
388
  end
343
389
 
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) }
390
+ def ensure_array_of_strings!(directive, value)
391
+ if (!value.is_a?(Array) || !value.compact.all? { |v| v.is_a?(String) })
346
392
  raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
347
393
  end
348
394
  end
349
395
 
350
396
  def ensure_valid_sources!(directive, source_expression)
397
+ return if source_expression == OPT_OUT
351
398
  source_expression.each do |expression|
352
399
  if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(expression)
353
400
  raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{expression}). This value must be single quoted.")
@@ -38,7 +38,7 @@ module SecureHeaders
38
38
 
39
39
  # disable Secure cookies for non-https requests
40
40
  def override_secure(env, config = {})
41
- if scheme(env) != "https"
41
+ if scheme(env) != "https" && config != OPT_OUT
42
42
  config[:secure] = OPT_OUT
43
43
  end
44
44
 
@@ -12,6 +12,7 @@ require "secure_headers/headers/x_download_options"
12
12
  require "secure_headers/headers/x_permitted_cross_domain_policies"
13
13
  require "secure_headers/headers/referrer_policy"
14
14
  require "secure_headers/headers/clear_site_data"
15
+ require "secure_headers/headers/expect_certificate_transparency"
15
16
  require "secure_headers/middleware"
16
17
  require "secure_headers/railtie"
17
18
  require "secure_headers/view_helper"
@@ -24,7 +25,7 @@ module SecureHeaders
24
25
  class NoOpHeaderConfig
25
26
  include Singleton
26
27
 
27
- def boom(arg = nil)
28
+ def boom(*args)
28
29
  raise "Illegal State: attempted to modify NoOpHeaderConfig. Create a new config instead."
29
30
  end
30
31
 
@@ -52,6 +53,7 @@ module SecureHeaders
52
53
  CSP = ContentSecurityPolicy
53
54
 
54
55
  ALL_HEADER_CLASSES = [
56
+ ExpectCertificateTransparency,
55
57
  ClearSiteData,
56
58
  ContentSecurityPolicyConfig,
57
59
  ContentSecurityPolicyReportOnlyConfig,
@@ -2,7 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
  Gem::Specification.new do |gem|
4
4
  gem.name = "secure_headers"
5
- gem.version = "4.0.0.alpha01"
5
+ gem.version = "4.0.0"
6
6
  gem.authors = ["Neil Matatall"]
7
7
  gem.email = ["neil.matatall@gmail.com"]
8
8
  gem.description = "Manages application of security headers with many safe defaults."
@@ -51,6 +51,11 @@ module SecureHeaders
51
51
  expect(csp.value).to eq("default-src example.org")
52
52
  end
53
53
 
54
+ it "does not build directives with a value of OPT_OUT (and bypasses directive requirements)" do
55
+ csp = ContentSecurityPolicy.new(default_src: %w(https://example.org), script_src: OPT_OUT)
56
+ expect(csp.value).to eq("default-src example.org")
57
+ end
58
+
54
59
  it "does not remove schemes from report-uri values" do
55
60
  csp = ContentSecurityPolicy.new(default_src: %w(https:), report_uri: %w(https://example.org))
56
61
  expect(csp.value).to eq("default-src https:; report-uri https://example.org")
@@ -91,6 +96,21 @@ module SecureHeaders
91
96
  expect(csp.value).to eq("default-src example.org")
92
97
  end
93
98
 
99
+ it "creates maximally strict sandbox policy when passed no sandbox token values" do
100
+ csp = ContentSecurityPolicy.new(default_src: %w(example.org), sandbox: [])
101
+ expect(csp.value).to eq("default-src example.org; sandbox")
102
+ end
103
+
104
+ it "creates maximally strict sandbox policy when passed true" do
105
+ csp = ContentSecurityPolicy.new(default_src: %w(example.org), sandbox: true)
106
+ expect(csp.value).to eq("default-src example.org; sandbox")
107
+ end
108
+
109
+ it "creates sandbox policy when passed valid sandbox token values" do
110
+ csp = ContentSecurityPolicy.new(default_src: %w(example.org), sandbox: %w(allow-forms allow-scripts))
111
+ expect(csp.value).to eq("default-src example.org; sandbox allow-forms allow-scripts")
112
+ end
113
+
94
114
  it "does not emit a warning when using frame-src" do
95
115
  expect(Kernel).to_not receive(:warn)
96
116
  ContentSecurityPolicy.new(default_src: %w('self'), frame_src: %w('self')).value
@@ -115,50 +135,52 @@ module SecureHeaders
115
135
  block_all_mixed_content: true,
116
136
  upgrade_insecure_requests: true,
117
137
  script_src: %w(script-src.com),
118
- script_nonce: 123456
138
+ script_nonce: 123456,
139
+ sandbox: %w(allow-forms),
140
+ plugin_types: %w(application/pdf)
119
141
  })
120
142
  end
121
143
 
122
144
  it "does not filter any directives for Chrome" do
123
145
  policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:chrome])
124
- expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; block-all-mixed-content; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; plugin-types plugin-types.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
146
+ expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; block-all-mixed-content; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; plugin-types application/pdf; sandbox allow-forms; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
125
147
  end
126
148
 
127
149
  it "does not filter any directives for Opera" do
128
150
  policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:opera])
129
- expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; block-all-mixed-content; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; plugin-types plugin-types.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
151
+ expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; block-all-mixed-content; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; plugin-types application/pdf; sandbox allow-forms; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
130
152
  end
131
153
 
132
154
  it "filters blocked-all-mixed-content, child-src, and plugin-types for firefox" do
133
155
  policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:firefox])
134
- expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; frame-src child-src.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
156
+ expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; frame-src child-src.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox allow-forms; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
135
157
  end
136
158
 
137
159
  it "filters blocked-all-mixed-content, frame-src, and plugin-types for firefox 46 and higher" do
138
160
  policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:firefox46])
139
- expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
161
+ expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox allow-forms; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
140
162
  end
141
163
 
142
164
  it "child-src value is copied to frame-src, adds 'unsafe-inline', filters base-uri, blocked-all-mixed-content, upgrade-insecure-requests, child-src, form-action, frame-ancestors, nonce sources, hash sources, and plugin-types for Edge" do
143
165
  policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:edge])
144
- expect(policy.value).to eq("default-src default-src.com; connect-src connect-src.com; font-src font-src.com; frame-src child-src.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
166
+ expect(policy.value).to eq("default-src default-src.com; connect-src connect-src.com; font-src font-src.com; frame-src child-src.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; sandbox allow-forms; script-src script-src.com 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
145
167
  end
146
168
 
147
169
  it "child-src value is copied to frame-src, adds 'unsafe-inline', filters base-uri, blocked-all-mixed-content, upgrade-insecure-requests, child-src, form-action, frame-ancestors, nonce sources, hash sources, and plugin-types for safari" do
148
170
  policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:safari6])
149
- expect(policy.value).to eq("default-src default-src.com; connect-src connect-src.com; font-src font-src.com; frame-src child-src.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
171
+ expect(policy.value).to eq("default-src default-src.com; connect-src connect-src.com; font-src font-src.com; frame-src child-src.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; sandbox allow-forms; script-src script-src.com 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
150
172
  end
151
173
 
152
174
  it "adds 'unsafe-inline', filters blocked-all-mixed-content, upgrade-insecure-requests, nonce sources, and hash sources for safari 10 and higher" do
153
175
  policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:safari10])
154
- expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; plugin-types plugin-types.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; report-uri report-uri.com")
176
+ expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; plugin-types application/pdf; sandbox allow-forms; script-src script-src.com 'nonce-123456'; style-src style-src.com; report-uri report-uri.com")
155
177
  end
156
178
 
157
179
  it "falls back to standard Firefox defaults when the useragent version is not present" do
158
180
  ua = USER_AGENTS[:firefox].dup
159
181
  allow(ua).to receive(:version).and_return(nil)
160
182
  policy = ContentSecurityPolicy.new(complex_opts, ua)
161
- expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; frame-src child-src.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
183
+ expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; frame-src child-src.com; img-src img-src.com; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox allow-forms; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
162
184
  end
163
185
  end
164
186
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ module SecureHeaders
5
+ describe ExpectCertificateTransparency do
6
+ specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: true).value).to eq("enforce; max-age=1234") }
7
+ specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: false).value).to eq("max-age=1234") }
8
+ specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: "yolocopter").value).to eq("max-age=1234") }
9
+ specify { expect(ExpectCertificateTransparency.new(max_age: 1234, report_uri: "https://report-uri.io/expect-ct").value).to eq("max-age=1234; report-uri=\"https://report-uri.io/expect-ct\"") }
10
+ specify do
11
+ config = { enforce: true, max_age: 1234, report_uri: "https://report-uri.io/expect-ct" }
12
+ header_value = "enforce; max-age=1234; report-uri=\"https://report-uri.io/expect-ct\""
13
+ expect(ExpectCertificateTransparency.new(config).value).to eq(header_value)
14
+ end
15
+
16
+ context "with an invalid configuration" do
17
+ it "raises an exception when configuration isn't a hash" do
18
+ expect do
19
+ ExpectCertificateTransparency.validate_config!(%w(a))
20
+ end.to raise_error(ExpectCertificateTransparencyConfigError)
21
+ end
22
+
23
+ it "raises an exception when max-age is not provided" do
24
+ expect do
25
+ ExpectCertificateTransparency.validate_config!(foo: "bar")
26
+ end.to raise_error(ExpectCertificateTransparencyConfigError)
27
+ end
28
+
29
+ it "raises an exception with an invalid max-age" do
30
+ expect do
31
+ ExpectCertificateTransparency.validate_config!(max_age: "abc123")
32
+ end.to raise_error(ExpectCertificateTransparencyConfigError)
33
+ end
34
+
35
+ it "raises an exception with an invalid enforce value" do
36
+ expect do
37
+ ExpectCertificateTransparency.validate_config!(enforce: "brokenstring")
38
+ end.to raise_error(ExpectCertificateTransparencyConfigError)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -57,6 +57,12 @@ module SecureHeaders
57
57
  end.to raise_error(ContentSecurityPolicyConfigError)
58
58
  end
59
59
 
60
+ it "accepts OPT_OUT as a script-src value" do
61
+ expect do
62
+ ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w('self'), script_src: OPT_OUT))
63
+ end.to_not raise_error
64
+ end
65
+
60
66
  it "requires :report_only to be a truthy value" do
61
67
  expect do
62
68
  ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_only: "steve")))
@@ -105,6 +111,36 @@ module SecureHeaders
105
111
  ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w(self none inline eval), script_src: %w('self')))
106
112
  end.to raise_error(ContentSecurityPolicyConfigError)
107
113
  end
114
+
115
+ it "rejects anything not of the form allow-* as a sandbox value" do
116
+ expect do
117
+ ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(sandbox: ["steve"])))
118
+ end.to raise_error(ContentSecurityPolicyConfigError)
119
+ end
120
+
121
+ it "accepts anything of the form allow-* as a sandbox value " do
122
+ expect do
123
+ ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(sandbox: ["allow-foo"])))
124
+ end.to_not raise_error
125
+ end
126
+
127
+ it "accepts true as a sandbox policy" do
128
+ expect do
129
+ ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(sandbox: true)))
130
+ end.to_not raise_error
131
+ end
132
+
133
+ it "rejects anything not of the form type/subtype as a plugin-type value" do
134
+ expect do
135
+ ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(plugin_types: ["steve"])))
136
+ end.to raise_error(ContentSecurityPolicyConfigError)
137
+ end
138
+
139
+ it "accepts anything of the form type/subtype as a plugin-type value " do
140
+ expect do
141
+ ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(plugin_types: ["application/pdf"])))
142
+ end.to_not raise_error
143
+ end
108
144
  end
109
145
 
110
146
  describe "#combine_policies" do
@@ -65,6 +65,17 @@ module SecureHeaders
65
65
  end
66
66
  end
67
67
 
68
+ it "allows opting out of cookie protection with OPT_OUT alone" do
69
+ Configuration.default { |config| config.cookies = OPT_OUT}
70
+
71
+ # do NOT make this request https. non-https requests modify a config,
72
+ # causing an exception when operating on OPT_OUT. This ensures we don't
73
+ # try to modify the config.
74
+ request = Rack::Request.new({})
75
+ _, env = cookie_middleware.call request.env
76
+ expect(env["Set-Cookie"]).to eq("foo=bar")
77
+ end
78
+
68
79
  context "cookies should not be flagged" do
69
80
  it "does not flags cookies as secure" do
70
81
  Configuration.default { |config| config.cookies = {secure: OPT_OUT, httponly: OPT_OUT, samesite: OPT_OUT} }
data/spec/spec_helper.rb CHANGED
@@ -33,6 +33,7 @@ def expect_default_values(hash)
33
33
  expect(hash[SecureHeaders::XContentTypeOptions::HEADER_NAME]).to eq(SecureHeaders::XContentTypeOptions::DEFAULT_VALUE)
34
34
  expect(hash[SecureHeaders::XPermittedCrossDomainPolicies::HEADER_NAME]).to eq(SecureHeaders::XPermittedCrossDomainPolicies::DEFAULT_VALUE)
35
35
  expect(hash[SecureHeaders::ReferrerPolicy::HEADER_NAME]).to be_nil
36
+ expect(hash[SecureHeaders::ExpectCertificateTransparency::HEADER_NAME]).to be_nil
36
37
  end
37
38
 
38
39
  module SecureHeaders
data/upgrading-to-4-0.md CHANGED
@@ -18,6 +18,12 @@ config.cookies = {
18
18
  config.cookies = OPT_OUT
19
19
  ```
20
20
 
21
+ ## script_src must be set
22
+
23
+ Not setting a `script_src` value means your policy falls back to whatever `default_src` (also required) is set to. This can be very dangerous and indicates the policy is too loose.
24
+
25
+ However, sometimes you really don't need a `script-src` e.g. API responses (`default-src 'none'`) so you can set `script_src: SecureHeaders::OPT_OUT` to work around this.
26
+
21
27
  ## Default Content Security Policy
22
28
 
23
29
  The default CSP has changed to be more universal without sacrificing too much security.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: secure_headers
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0.alpha01
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Neil Matatall
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-07-24 00:00:00.000000000 Z
11
+ date: 2017-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -74,6 +74,7 @@ files:
74
74
  - lib/secure_headers/headers/content_security_policy.rb
75
75
  - lib/secure_headers/headers/content_security_policy_config.rb
76
76
  - lib/secure_headers/headers/cookie.rb
77
+ - lib/secure_headers/headers/expect_certificate_transparency.rb
77
78
  - lib/secure_headers/headers/policy_management.rb
78
79
  - lib/secure_headers/headers/public_key_pins.rb
79
80
  - lib/secure_headers/headers/referrer_policy.rb
@@ -93,6 +94,7 @@ files:
93
94
  - spec/lib/secure_headers/headers/clear_site_data_spec.rb
94
95
  - spec/lib/secure_headers/headers/content_security_policy_spec.rb
95
96
  - spec/lib/secure_headers/headers/cookie_spec.rb
97
+ - spec/lib/secure_headers/headers/expect_certificate_spec.rb
96
98
  - spec/lib/secure_headers/headers/policy_management_spec.rb
97
99
  - spec/lib/secure_headers/headers/public_key_pins_spec.rb
98
100
  - spec/lib/secure_headers/headers/referrer_policy_spec.rb
@@ -128,9 +130,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
128
130
  version: '0'
129
131
  required_rubygems_version: !ruby/object:Gem::Requirement
130
132
  requirements:
131
- - - ">"
133
+ - - ">="
132
134
  - !ruby/object:Gem::Version
133
- version: 1.3.1
135
+ version: '0'
134
136
  requirements: []
135
137
  rubyforge_project:
136
138
  rubygems_version: 2.6.11
@@ -143,6 +145,7 @@ test_files:
143
145
  - spec/lib/secure_headers/headers/clear_site_data_spec.rb
144
146
  - spec/lib/secure_headers/headers/content_security_policy_spec.rb
145
147
  - spec/lib/secure_headers/headers/cookie_spec.rb
148
+ - spec/lib/secure_headers/headers/expect_certificate_spec.rb
146
149
  - spec/lib/secure_headers/headers/policy_management_spec.rb
147
150
  - spec/lib/secure_headers/headers/public_key_pins_spec.rb
148
151
  - spec/lib/secure_headers/headers/referrer_policy_spec.rb