secure_headers 3.0.3 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE.md +41 -0
  3. data/.github/PULL_REQUEST_TEMPLATE.md +20 -0
  4. data/.gitignore +0 -9
  5. data/.rspec +2 -0
  6. data/.rubocop.yml +3 -0
  7. data/.ruby-version +1 -1
  8. data/.travis.yml +17 -5
  9. data/CHANGELOG.md +287 -0
  10. data/CODE_OF_CONDUCT.md +46 -0
  11. data/CONTRIBUTING.md +41 -0
  12. data/Gemfile +13 -5
  13. data/Guardfile +1 -0
  14. data/LICENSE +4 -199
  15. data/README.md +85 -265
  16. data/Rakefile +22 -18
  17. data/docs/HPKP.md +17 -0
  18. data/docs/cookies.md +64 -0
  19. data/docs/hashes.md +64 -0
  20. data/docs/named_overrides_and_appends.md +107 -0
  21. data/docs/per_action_configuration.md +133 -0
  22. data/docs/sinatra.md +25 -0
  23. data/lib/secure_headers/configuration.rb +188 -59
  24. data/lib/secure_headers/hash_helper.rb +11 -0
  25. data/lib/secure_headers/headers/clear_site_data.rb +55 -0
  26. data/lib/secure_headers/headers/content_security_policy.rb +136 -325
  27. data/lib/secure_headers/headers/content_security_policy_config.rb +162 -0
  28. data/lib/secure_headers/headers/cookie.rb +144 -0
  29. data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
  30. data/lib/secure_headers/headers/policy_management.rb +410 -0
  31. data/lib/secure_headers/headers/public_key_pins.rb +5 -4
  32. data/lib/secure_headers/headers/referrer_policy.rb +37 -0
  33. data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
  34. data/lib/secure_headers/headers/x_content_type_options.rb +3 -2
  35. data/lib/secure_headers/headers/x_download_options.rb +3 -2
  36. data/lib/secure_headers/headers/x_frame_options.rb +2 -1
  37. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +3 -2
  38. data/lib/secure_headers/headers/x_xss_protection.rb +2 -1
  39. data/lib/secure_headers/middleware.rb +44 -0
  40. data/lib/secure_headers/railtie.rb +11 -6
  41. data/lib/secure_headers/utils/cookies_config.rb +95 -0
  42. data/lib/secure_headers/view_helper.rb +76 -5
  43. data/lib/secure_headers.rb +159 -99
  44. data/lib/tasks/tasks.rake +83 -0
  45. data/secure_headers.gemspec +13 -3
  46. data/spec/lib/secure_headers/configuration_spec.rb +24 -9
  47. data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +87 -0
  48. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +88 -189
  49. data/spec/lib/secure_headers/headers/cookie_spec.rb +182 -0
  50. data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
  51. data/spec/lib/secure_headers/headers/policy_management_spec.rb +228 -0
  52. data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
  53. data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +73 -0
  54. data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +6 -5
  55. data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +2 -1
  56. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +3 -2
  57. data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +2 -1
  58. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +4 -3
  59. data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +4 -3
  60. data/spec/lib/secure_headers/middleware_spec.rb +96 -6
  61. data/spec/lib/secure_headers/view_helpers_spec.rb +138 -0
  62. data/spec/lib/secure_headers_spec.rb +407 -58
  63. data/spec/spec_helper.rb +19 -12
  64. data/upgrading-to-3-0.md +13 -9
  65. data/upgrading-to-4-0.md +55 -0
  66. metadata +45 -9
  67. data/lib/secure_headers/padrino.rb +0 -13
  68. data/travis.sh +0 -10
@@ -1,8 +1,12 @@
1
+ # frozen_string_literal: true
2
+ require "yaml"
3
+
1
4
  module SecureHeaders
2
5
  class Configuration
3
6
  DEFAULT_CONFIG = :default
4
7
  NOOP_CONFIGURATION = "secure_headers_noop_config"
5
8
  class NotYetConfiguredError < StandardError; end
9
+ class IllegalPolicyModificationError < StandardError; end
6
10
  class << self
7
11
  # Public: Set the global default configuration.
8
12
  #
@@ -23,12 +27,12 @@ module SecureHeaders
23
27
  # if no value is supplied.
24
28
  #
25
29
  # Returns: the newly created config
26
- def override(name, base = DEFAULT_CONFIG)
30
+ def override(name, base = DEFAULT_CONFIG, &block)
27
31
  unless get(base)
28
32
  raise NotYetConfiguredError, "#{base} policy not yet supplied"
29
33
  end
30
34
  override = @configurations[base].dup
31
- yield(override)
35
+ override.instance_eval(&block) if block_given?
32
36
  add_configuration(name, override)
33
37
  end
34
38
 
@@ -43,16 +47,15 @@ module SecureHeaders
43
47
  @configurations[name]
44
48
  end
45
49
 
46
- # Public: perform a basic deep dup. The shallow copy provided by dup/clone
47
- # can lead to modifying parent objects.
48
- def deep_copy(config)
49
- config.each_with_object({}) do |(key, value), hash|
50
- hash[key] = if value.is_a?(Array)
51
- value.dup
52
- else
53
- value
54
- end
55
- end
50
+ def named_appends(name)
51
+ @appends ||= {}
52
+ @appends[name]
53
+ end
54
+
55
+ def named_append(name, target = nil, &block)
56
+ @appends ||= {}
57
+ raise "Provide a configuration block" unless block_given?
58
+ @appends[name] = block
56
59
  end
57
60
 
58
61
  private
@@ -70,6 +73,7 @@ module SecureHeaders
70
73
  config.validate_config!
71
74
  @configurations ||= {}
72
75
  config.send(:cache_headers!)
76
+ config.send(:cache_hpkp_report_host)
73
77
  config.freeze
74
78
  @configurations[name] = config
75
79
  end
@@ -86,17 +90,68 @@ module SecureHeaders
86
90
 
87
91
  add_configuration(NOOP_CONFIGURATION, noop_config)
88
92
  end
93
+
94
+ # Public: perform a basic deep dup. The shallow copy provided by dup/clone
95
+ # can lead to modifying parent objects.
96
+ def deep_copy(config)
97
+ return unless config
98
+ config.each_with_object({}) do |(key, value), hash|
99
+ hash[key] = if value.is_a?(Array)
100
+ value.dup
101
+ else
102
+ value
103
+ end
104
+ end
105
+ end
106
+
107
+ # Private: convenience method purely DRY things up. The value may not be a
108
+ # hash (e.g. OPT_OUT, nil)
109
+ def deep_copy_if_hash(value)
110
+ if value.is_a?(Hash)
111
+ deep_copy(value)
112
+ else
113
+ value
114
+ end
115
+ end
89
116
  end
90
117
 
91
- attr_accessor :hsts, :x_frame_options, :x_content_type_options,
92
- :x_xss_protection, :csp, :x_download_options, :x_permitted_cross_domain_policies,
93
- :hpkp
94
- attr_reader :cached_headers
118
+ attr_writer :hsts, :x_frame_options, :x_content_type_options,
119
+ :x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
120
+ :referrer_policy, :clear_site_data, :expect_certificate_transparency
121
+
122
+ attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host
123
+
124
+ @script_hashes = nil
125
+ @style_hashes = nil
126
+
127
+ HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml"
128
+ if File.exist?(HASH_CONFIG_FILE)
129
+ config = YAML.safe_load(File.open(HASH_CONFIG_FILE))
130
+ @script_hashes = config["scripts"]
131
+ @style_hashes = config["styles"]
132
+ end
95
133
 
96
134
  def initialize(&block)
135
+ @cookies = nil
136
+ @clear_site_data = nil
137
+ @csp = nil
138
+ @csp_report_only = nil
139
+ @hpkp_report_host = nil
140
+ @hpkp = nil
141
+ @hsts = nil
142
+ @x_content_type_options = nil
143
+ @x_download_options = nil
144
+ @x_frame_options = nil
145
+ @x_permitted_cross_domain_policies = nil
146
+ @x_xss_protection = nil
147
+ @expect_certificate_transparency = nil
148
+
97
149
  self.hpkp = OPT_OUT
98
- self.csp = self.class.deep_copy(CSP::DEFAULT_CONFIG)
99
- instance_eval &block if block_given?
150
+ self.referrer_policy = OPT_OUT
151
+ self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
152
+ self.csp_report_only = OPT_OUT
153
+
154
+ instance_eval(&block) if block_given?
100
155
  end
101
156
 
102
157
  # Public: copy everything but the cached headers
@@ -104,33 +159,32 @@ module SecureHeaders
104
159
  # Returns a deep-dup'd copy of this configuration.
105
160
  def dup
106
161
  copy = self.class.new
107
- copy.hsts = hsts
108
- copy.x_frame_options = x_frame_options
109
- copy.x_content_type_options = x_content_type_options
110
- copy.x_xss_protection = x_xss_protection
111
- copy.x_download_options = x_download_options
112
- copy.x_permitted_cross_domain_policies = x_permitted_cross_domain_policies
113
- copy.csp = if csp.is_a?(Hash)
114
- self.class.deep_copy(csp)
115
- else
116
- csp
117
- end
118
-
119
- copy.hpkp = if hpkp.is_a?(Hash)
120
- self.class.deep_copy(hpkp)
121
- else
122
- hpkp
123
- end
162
+ copy.cookies = self.class.send(:deep_copy_if_hash, @cookies)
163
+ copy.csp = @csp.dup if @csp
164
+ copy.csp_report_only = @csp_report_only.dup if @csp_report_only
165
+ copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers)
166
+ copy.x_content_type_options = @x_content_type_options
167
+ copy.hsts = @hsts
168
+ copy.x_frame_options = @x_frame_options
169
+ copy.x_xss_protection = @x_xss_protection
170
+ copy.x_download_options = @x_download_options
171
+ copy.x_permitted_cross_domain_policies = @x_permitted_cross_domain_policies
172
+ copy.clear_site_data = @clear_site_data
173
+ copy.expect_certificate_transparency = @expect_certificate_transparency
174
+ copy.referrer_policy = @referrer_policy
175
+ copy.hpkp = @hpkp
176
+ copy.hpkp_report_host = @hpkp_report_host
124
177
  copy
125
178
  end
126
179
 
127
- # Public: Retrieve a config based on the CONFIG_KEY for a class
128
- #
129
- # Returns the value if available, and returns a dup of any hash values.
130
- def fetch(key)
131
- config = send(key)
132
- config = self.class.deep_copy(config) if config.is_a?(Hash)
133
- config
180
+ def opt_out(header)
181
+ send("#{header}=", OPT_OUT)
182
+ self.cached_headers.delete(header)
183
+ end
184
+
185
+ def update_x_frame_options(value)
186
+ @x_frame_options = value
187
+ self.cached_headers[XFrameOptions::CONFIG_KEY] = XFrameOptions.make_header(value)
134
188
  end
135
189
 
136
190
  # Public: validates all configurations values.
@@ -139,24 +193,96 @@ module SecureHeaders
139
193
  #
140
194
  # Returns nothing
141
195
  def validate_config!
142
- StrictTransportSecurity.validate_config!(hsts)
143
- ContentSecurityPolicy.validate_config!(csp)
144
- XFrameOptions.validate_config!(x_frame_options)
145
- XContentTypeOptions.validate_config!(x_content_type_options)
146
- XXssProtection.validate_config!(x_xss_protection)
147
- XDownloadOptions.validate_config!(x_download_options)
148
- XPermittedCrossDomainPolicies.validate_config!(x_permitted_cross_domain_policies)
149
- PublicKeyPins.validate_config!(hpkp)
196
+ StrictTransportSecurity.validate_config!(@hsts)
197
+ ContentSecurityPolicy.validate_config!(@csp)
198
+ ContentSecurityPolicy.validate_config!(@csp_report_only)
199
+ ReferrerPolicy.validate_config!(@referrer_policy)
200
+ XFrameOptions.validate_config!(@x_frame_options)
201
+ XContentTypeOptions.validate_config!(@x_content_type_options)
202
+ XXssProtection.validate_config!(@x_xss_protection)
203
+ XDownloadOptions.validate_config!(@x_download_options)
204
+ XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
205
+ ClearSiteData.validate_config!(@clear_site_data)
206
+ ExpectCertificateTransparency.validate_config!(@expect_certificate_transparency)
207
+ PublicKeyPins.validate_config!(@hpkp)
208
+ Cookie.validate_config!(@cookies)
209
+ end
210
+
211
+ def secure_cookies=(secure_cookies)
212
+ raise ArgumentError, "#{Kernel.caller.first}: `#secure_cookies=` is no longer supported. Please use `#cookies=` to configure secure cookies instead."
213
+ end
214
+
215
+ def csp=(new_csp)
216
+ if new_csp.respond_to?(:opt_out?)
217
+ @csp = new_csp.dup
218
+ else
219
+ if new_csp[:report_only]
220
+ # invalid configuration implies that CSPRO should be set, CSP should not - so opt out
221
+ raise ArgumentError, "#{Kernel.caller.first}: `#csp=` was supplied a config with report_only: true. Use #csp_report_only="
222
+ else
223
+ @csp = ContentSecurityPolicyConfig.new(new_csp)
224
+ end
225
+ end
226
+ end
227
+
228
+ # Configures the Content-Security-Policy-Report-Only header. `new_csp` cannot
229
+ # contain `report_only: false` or an error will be raised.
230
+ #
231
+ # NOTE: if csp has not been configured/has the default value when
232
+ # configuring csp_report_only, the code will assume you mean to only use
233
+ # report-only mode and you will be opted-out of enforce mode.
234
+ def csp_report_only=(new_csp)
235
+ @csp_report_only = begin
236
+ if new_csp.is_a?(ContentSecurityPolicyConfig)
237
+ new_csp.make_report_only
238
+ elsif new_csp.respond_to?(:opt_out?)
239
+ new_csp.dup
240
+ else
241
+ if new_csp[:report_only] == false # nil is a valid value on which we do not want to raise
242
+ raise ContentSecurityPolicyConfigError, "`#csp_report_only=` was supplied a config with report_only: false. Use #csp="
243
+ else
244
+ ContentSecurityPolicyReportOnlyConfig.new(new_csp)
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ protected
251
+
252
+ def cookies=(cookies)
253
+ @cookies = cookies
254
+ end
255
+
256
+ def cached_headers=(headers)
257
+ @cached_headers = headers
258
+ end
259
+
260
+ def hpkp=(hpkp)
261
+ @hpkp = self.class.send(:deep_copy_if_hash, hpkp)
262
+ end
263
+
264
+ def hpkp_report_host=(hpkp_report_host)
265
+ @hpkp_report_host = hpkp_report_host
150
266
  end
151
267
 
152
- # Public: Precompute the header names and values for this configuraiton.
268
+ private
269
+
270
+ def cache_hpkp_report_host
271
+ has_report_uri = @hpkp && @hpkp != OPT_OUT && @hpkp[:report_uri]
272
+ self.hpkp_report_host = if has_report_uri
273
+ parsed_report_uri = URI.parse(@hpkp[:report_uri])
274
+ parsed_report_uri.host
275
+ end
276
+ end
277
+
278
+ # Public: Precompute the header names and values for this configuration.
153
279
  # Ensures that headers generated at configure time, not on demand.
154
280
  #
155
281
  # Returns the cached headers
156
282
  def cache_headers!
157
283
  # generate defaults for the "easy" headers
158
284
  headers = (ALL_HEADERS_BESIDES_CSP).each_with_object({}) do |klass, hash|
159
- config = fetch(klass::CONFIG_KEY)
285
+ config = instance_variable_get("@#{klass::CONFIG_KEY}")
160
286
  unless config == OPT_OUT
161
287
  hash[klass::CONFIG_KEY] = klass.make_header(config).freeze
162
288
  end
@@ -165,7 +291,7 @@ module SecureHeaders
165
291
  generate_csp_headers(headers)
166
292
 
167
293
  headers.freeze
168
- @cached_headers = headers
294
+ self.cached_headers = headers
169
295
  end
170
296
 
171
297
  # Private: adds CSP headers for each variation of CSP support.
@@ -175,13 +301,16 @@ module SecureHeaders
175
301
  #
176
302
  # Returns nothing
177
303
  def generate_csp_headers(headers)
178
- unless csp == OPT_OUT
179
- headers[CSP::CONFIG_KEY] = {}
304
+ generate_csp_headers_for_config(headers, ContentSecurityPolicyConfig::CONFIG_KEY, self.csp)
305
+ generate_csp_headers_for_config(headers, ContentSecurityPolicyReportOnlyConfig::CONFIG_KEY, self.csp_report_only)
306
+ end
180
307
 
181
- CSP::VARIATIONS.each do |name, _|
182
- csp_config = fetch(CSP::CONFIG_KEY)
183
- csp = CSP.make_header(csp_config, UserAgent.parse(name))
184
- headers[CSP::CONFIG_KEY][name] = csp.freeze
308
+ def generate_csp_headers_for_config(headers, header_key, csp_config)
309
+ unless csp_config.opt_out?
310
+ headers[header_key] = {}
311
+ ContentSecurityPolicy::VARIATIONS.each do |name, _|
312
+ csp = ContentSecurityPolicy.make_header(csp_config, UserAgent.parse(name))
313
+ headers[header_key][name] = csp.freeze
185
314
  end
186
315
  end
187
316
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ require "base64"
3
+
4
+ module SecureHeaders
5
+ module HashHelper
6
+ def hash_source(inline_script, digest = :SHA256)
7
+ base64_hashed_content = Base64.encode64(Digest.const_get(digest).digest(inline_script)).chomp
8
+ "'#{digest.to_s.downcase}-#{base64_hashed_content}'"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ module SecureHeaders
3
+ class ClearSiteDataConfigError < StandardError; end
4
+ class ClearSiteData
5
+ HEADER_NAME = "Clear-Site-Data".freeze
6
+
7
+ # Valid `types`
8
+ CACHE = "cache".freeze
9
+ COOKIES = "cookies".freeze
10
+ STORAGE = "storage".freeze
11
+ EXECUTION_CONTEXTS = "executionContexts".freeze
12
+ ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]
13
+
14
+ CONFIG_KEY = :clear_site_data
15
+
16
+ class << self
17
+ # Public: make an Clear-Site-Data header name, value pair
18
+ #
19
+ # Returns nil if not configured, returns header name and value if configured.
20
+ def make_header(config = nil)
21
+ case config
22
+ when nil, OPT_OUT, []
23
+ # noop
24
+ when Array
25
+ [HEADER_NAME, make_header_value(config)]
26
+ when true
27
+ [HEADER_NAME, make_header_value(ALL_TYPES)]
28
+ end
29
+ end
30
+
31
+ def validate_config!(config)
32
+ case config
33
+ when nil, OPT_OUT, true
34
+ # valid
35
+ when Array
36
+ unless config.all? { |t| t.is_a?(String) }
37
+ raise ClearSiteDataConfigError.new("types must be Strings")
38
+ end
39
+ else
40
+ raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
41
+ end
42
+ end
43
+
44
+ # Public: Transform a Clear-Site-Data config (an Array of Strings) into a
45
+ # String that can be used as the value for the Clear-Site-Data header.
46
+ #
47
+ # types - An Array of String of types of data to clear.
48
+ #
49
+ # Returns a String of quoted values that are comma separated.
50
+ def make_header_value(types)
51
+ types.map { |t| "\"#{t}\""}.join(", ")
52
+ end
53
+ end
54
+ end
55
+ end