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
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+ module SecureHeaders
3
+ class CookiesConfig
4
+
5
+ attr_reader :config
6
+
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def validate!
12
+ return if config.nil? || config == SecureHeaders::OPT_OUT
13
+
14
+ validate_config!
15
+ validate_secure_config! unless config[:secure].nil?
16
+ validate_httponly_config! unless config[:httponly].nil?
17
+ validate_samesite_config! unless config[:samesite].nil?
18
+ end
19
+
20
+ private
21
+
22
+ def validate_config!
23
+ raise CookiesConfigError.new("config must be a hash.") unless is_hash?(config)
24
+ end
25
+
26
+ def validate_secure_config!
27
+ validate_hash_or_true_or_opt_out!(:secure)
28
+ validate_exclusive_use_of_hash_constraints!(config[:secure], :secure)
29
+ end
30
+
31
+ def validate_httponly_config!
32
+ validate_hash_or_true_or_opt_out!(:httponly)
33
+ validate_exclusive_use_of_hash_constraints!(config[:httponly], :httponly)
34
+ end
35
+
36
+ def validate_samesite_config!
37
+ return if config[:samesite] == OPT_OUT
38
+ raise CookiesConfigError.new("samesite cookie config must be a hash") unless is_hash?(config[:samesite])
39
+
40
+ validate_samesite_boolean_config!
41
+ validate_samesite_hash_config!
42
+ end
43
+
44
+ # when configuring with booleans, only one enforcement is permitted
45
+ def validate_samesite_boolean_config!
46
+ if config[:samesite].key?(:lax) && config[:samesite][:lax].is_a?(TrueClass) && config[:samesite].key?(:strict)
47
+ raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax and strict enforcement is not permitted.")
48
+ elsif config[:samesite].key?(:strict) && config[:samesite][:strict].is_a?(TrueClass) && config[:samesite].key?(:lax)
49
+ raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax and strict enforcement is not permitted.")
50
+ end
51
+ end
52
+
53
+ def validate_samesite_hash_config!
54
+ # validate Hash-based samesite configuration
55
+ if is_hash?(config[:samesite][:lax])
56
+ validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax], "samesite lax")
57
+
58
+ if is_hash?(config[:samesite][:strict])
59
+ validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict], "samesite strict")
60
+ validate_exclusive_use_of_samesite_enforcement!(:only)
61
+ validate_exclusive_use_of_samesite_enforcement!(:except)
62
+ end
63
+ end
64
+ end
65
+
66
+ def validate_hash_or_true_or_opt_out!(attribute)
67
+ if !(is_hash?(config[attribute]) || is_true_or_opt_out?(config[attribute]))
68
+ raise CookiesConfigError.new("#{attribute} cookie config must be a hash or boolean")
69
+ end
70
+ end
71
+
72
+ # validate exclusive use of only or except but not both at the same time
73
+ def validate_exclusive_use_of_hash_constraints!(conf, attribute)
74
+ return unless is_hash?(conf)
75
+ if conf.key?(:only) && conf.key?(:except)
76
+ raise CookiesConfigError.new("#{attribute} cookie config is invalid, simultaneous use of conditional arguments `only` and `except` is not permitted.")
77
+ end
78
+ end
79
+
80
+ # validate exclusivity of only and except members within strict and lax
81
+ def validate_exclusive_use_of_samesite_enforcement!(attribute)
82
+ if (intersection = (config[:samesite][:lax].fetch(attribute, []) & config[:samesite][:strict].fetch(attribute, []))).any?
83
+ raise CookiesConfigError.new("samesite cookie config is invalid, cookie(s) #{intersection.join(', ')} cannot be enforced as lax and strict")
84
+ end
85
+ end
86
+
87
+ def is_hash?(obj)
88
+ obj && obj.is_a?(Hash)
89
+ end
90
+
91
+ def is_true_or_opt_out?(obj)
92
+ obj && (obj.is_a?(TrueClass) || obj == OPT_OUT)
93
+ end
94
+ end
95
+ end
@@ -1,5 +1,11 @@
1
+ # frozen_string_literal: true
1
2
  module SecureHeaders
2
3
  module ViewHelpers
4
+ include SecureHeaders::HashHelper
5
+ SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes"
6
+
7
+ class UnexpectedHashedScriptException < StandardError; end
8
+
3
9
  # Public: create a style tag using the content security policy nonce.
4
10
  # Instructs secure_headers to append a nonce to style/script-src directives.
5
11
  #
@@ -29,8 +35,75 @@ module SecureHeaders
29
35
  end
30
36
  end
31
37
 
38
+ def content_security_policy_script_nonce
39
+ content_security_policy_nonce(:script)
40
+ end
41
+
42
+ def content_security_policy_style_nonce
43
+ content_security_policy_nonce(:style)
44
+ end
45
+
46
+ ##
47
+ # Checks to see if the hashed code is expected and adds the hash source
48
+ # value to the current CSP.
49
+ #
50
+ # By default, in development/test/etc. an exception will be raised.
51
+ def hashed_javascript_tag(raise_error_on_unrecognized_hash = nil, &block)
52
+ hashed_tag(
53
+ :script,
54
+ :script_src,
55
+ Configuration.instance_variable_get(:@script_hashes),
56
+ raise_error_on_unrecognized_hash,
57
+ block
58
+ )
59
+ end
60
+
61
+ def hashed_style_tag(raise_error_on_unrecognized_hash = nil, &block)
62
+ hashed_tag(
63
+ :style,
64
+ :style_src,
65
+ Configuration.instance_variable_get(:@style_hashes),
66
+ raise_error_on_unrecognized_hash,
67
+ block
68
+ )
69
+ end
70
+
32
71
  private
33
72
 
73
+ def hashed_tag(type, directive, hashes, raise_error_on_unrecognized_hash, block)
74
+ if raise_error_on_unrecognized_hash.nil?
75
+ raise_error_on_unrecognized_hash = ENV["RAILS_ENV"] != "production"
76
+ end
77
+
78
+ content = capture(&block)
79
+ file_path = File.join("app", "views", self.instance_variable_get(:@virtual_path) + ".html.erb")
80
+
81
+ if raise_error_on_unrecognized_hash
82
+ hash_value = hash_source(content)
83
+ message = unexpected_hash_error_message(file_path, content, hash_value)
84
+
85
+ if hashes.nil? || hashes[file_path].nil? || !hashes[file_path].include?(hash_value)
86
+ raise UnexpectedHashedScriptException.new(message)
87
+ end
88
+ end
89
+
90
+ SecureHeaders.append_content_security_policy_directives(request, directive => hashes[file_path])
91
+
92
+ content_tag type, content
93
+ end
94
+
95
+ def unexpected_hash_error_message(file_path, content, hash_value)
96
+ <<-EOF
97
+ \n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
98
+ #{content}
99
+ *** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/secure_headers_generated_hashes.yml:***
100
+ #{file_path}:
101
+ - \"#{hash_value}\"\n\n
102
+ NOTE: dynamic javascript is not supported using script hash integration
103
+ on purpose. It defeats the point of using it in the first place.
104
+ EOF
105
+ end
106
+
34
107
  def nonced_tag(type, content_or_options, block)
35
108
  options = {}
36
109
  content = if block
@@ -44,8 +117,6 @@ module SecureHeaders
44
117
  end
45
118
  end
46
119
 
47
- module ActionView #:nodoc:
48
- class Base #:nodoc:
49
- include SecureHeaders::ViewHelpers
50
- end
51
- end
120
+ ActiveSupport.on_load :action_view do
121
+ include SecureHeaders::ViewHelpers
122
+ end if defined?(ActiveSupport)
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
1
2
  require "secure_headers/configuration"
3
+ require "secure_headers/hash_helper"
4
+ require "secure_headers/headers/cookie"
2
5
  require "secure_headers/headers/public_key_pins"
3
6
  require "secure_headers/headers/content_security_policy"
4
7
  require "secure_headers/headers/x_frame_options"
@@ -7,24 +10,56 @@ require "secure_headers/headers/x_xss_protection"
7
10
  require "secure_headers/headers/x_content_type_options"
8
11
  require "secure_headers/headers/x_download_options"
9
12
  require "secure_headers/headers/x_permitted_cross_domain_policies"
13
+ require "secure_headers/headers/referrer_policy"
14
+ require "secure_headers/headers/clear_site_data"
15
+ require "secure_headers/headers/expect_certificate_transparency"
10
16
  require "secure_headers/middleware"
11
17
  require "secure_headers/railtie"
12
18
  require "secure_headers/view_helper"
13
19
  require "useragent"
20
+ require "singleton"
14
21
 
15
22
  # All headers (except for hpkp) have a default value. Provide SecureHeaders::OPT_OUT
16
23
  # or ":optout_of_protection" as a config value to disable a given header
17
24
  module SecureHeaders
18
- OPT_OUT = :opt_out_of_protection
25
+ class NoOpHeaderConfig
26
+ include Singleton
27
+
28
+ def boom(*args)
29
+ raise "Illegal State: attempted to modify NoOpHeaderConfig. Create a new config instead."
30
+ end
31
+
32
+ def to_h
33
+ {}
34
+ end
35
+
36
+ def dup
37
+ self.class.instance
38
+ end
39
+
40
+ def opt_out?
41
+ true
42
+ end
43
+
44
+ alias_method :[], :boom
45
+ alias_method :[]=, :boom
46
+ alias_method :keys, :boom
47
+ end
48
+
49
+ OPT_OUT = NoOpHeaderConfig.instance
19
50
  SECURE_HEADERS_CONFIG = "secure_headers_request_config".freeze
20
51
  NONCE_KEY = "secure_headers_content_security_policy_nonce".freeze
21
52
  HTTPS = "https".freeze
22
53
  CSP = ContentSecurityPolicy
23
54
 
24
55
  ALL_HEADER_CLASSES = [
25
- ContentSecurityPolicy,
56
+ ExpectCertificateTransparency,
57
+ ClearSiteData,
58
+ ContentSecurityPolicyConfig,
59
+ ContentSecurityPolicyReportOnlyConfig,
26
60
  StrictTransportSecurity,
27
61
  PublicKeyPins,
62
+ ReferrerPolicy,
28
63
  XContentTypeOptions,
29
64
  XDownloadOptions,
30
65
  XFrameOptions,
@@ -32,7 +67,10 @@ module SecureHeaders
32
67
  XXssProtection
33
68
  ].freeze
34
69
 
35
- ALL_HEADERS_BESIDES_CSP = (ALL_HEADER_CLASSES - [CSP]).freeze
70
+ ALL_HEADERS_BESIDES_CSP = (
71
+ ALL_HEADER_CLASSES -
72
+ [ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig]
73
+ ).freeze
36
74
 
37
75
  # Headers set on http requests (excludes STS and HPKP)
38
76
  HTTP_HEADER_CLASSES =
@@ -46,16 +84,26 @@ module SecureHeaders
46
84
  #
47
85
  # additions - a hash containing directives. e.g.
48
86
  # script_src: %w(another-host.com)
49
- def override_content_security_policy_directives(request, additions)
50
- config = config_for(request)
51
- unless CSP.idempotent_additions?(config.csp, additions)
52
- config = config.dup
53
- if config.csp == OPT_OUT
54
- config.csp = {}
87
+ def override_content_security_policy_directives(request, additions, target = nil)
88
+ config, target = config_and_target(request, target)
89
+
90
+ if [:both, :enforced].include?(target)
91
+ if config.csp.opt_out?
92
+ config.csp = ContentSecurityPolicyConfig.new({})
55
93
  end
94
+
56
95
  config.csp.merge!(additions)
57
- override_secure_headers_request_config(request, config)
58
96
  end
97
+
98
+ if [:both, :report_only].include?(target)
99
+ if config.csp_report_only.opt_out?
100
+ config.csp_report_only = ContentSecurityPolicyReportOnlyConfig.new({})
101
+ end
102
+
103
+ config.csp_report_only.merge!(additions)
104
+ end
105
+
106
+ override_secure_headers_request_config(request, config)
59
107
  end
60
108
 
61
109
  # Public: appends source values to the current configuration. If no value
@@ -64,13 +112,23 @@ module SecureHeaders
64
112
  #
65
113
  # additions - a hash containing directives. e.g.
66
114
  # script_src: %w(another-host.com)
67
- def append_content_security_policy_directives(request, additions)
68
- config = config_for(request)
69
- unless CSP.idempotent_additions?(config.csp, additions)
70
- config = config.dup
71
- config.csp = CSP.combine_policies(config.csp, additions)
72
- override_secure_headers_request_config(request, config)
115
+ def append_content_security_policy_directives(request, additions, target = nil)
116
+ config, target = config_and_target(request, target)
117
+
118
+ if [:both, :enforced].include?(target) && !config.csp.opt_out?
119
+ config.csp.append(additions)
73
120
  end
121
+
122
+ if [:both, :report_only].include?(target) && !config.csp_report_only.opt_out?
123
+ config.csp_report_only.append(additions)
124
+ end
125
+
126
+ override_secure_headers_request_config(request, config)
127
+ end
128
+
129
+ def use_content_security_policy_named_append(request, name)
130
+ additions = SecureHeaders::Configuration.named_appends(name).call(request)
131
+ append_content_security_policy_directives(request, additions)
74
132
  end
75
133
 
76
134
  # Public: override X-Frame-Options settings for this request.
@@ -79,16 +137,16 @@ module SecureHeaders
79
137
  #
80
138
  # Returns the current config
81
139
  def override_x_frame_options(request, value)
82
- default_config = config_for(request).dup
83
- default_config.x_frame_options = value
84
- override_secure_headers_request_config(request, default_config)
140
+ config = config_for(request)
141
+ config.update_x_frame_options(value)
142
+ override_secure_headers_request_config(request, config)
85
143
  end
86
144
 
87
145
  # Public: opts out of setting a given header by creating a temporary config
88
146
  # and setting the given headers config to OPT_OUT.
89
147
  def opt_out_of_header(request, header_key)
90
- config = config_for(request).dup
91
- config.send("#{header_key}=", OPT_OUT)
148
+ config = config_for(request)
149
+ config.opt_out(header_key)
92
150
  override_secure_headers_request_config(request, config)
93
151
  end
94
152
 
@@ -108,15 +166,29 @@ module SecureHeaders
108
166
  # returned is meant to be merged into the header value from `@app.call(env)`
109
167
  # in Rack middleware.
110
168
  def header_hash_for(request)
111
- config = config_for(request)
169
+ prevent_dup = true
170
+ config = config_for(request, prevent_dup)
171
+ headers = config.cached_headers
172
+ user_agent = UserAgent.parse(request.user_agent)
112
173
 
113
- headers = if cached_headers = config.cached_headers
114
- use_cached_headers(cached_headers, request)
115
- else
116
- build_headers(config, request)
174
+ if !config.csp.opt_out? && config.csp.modified?
175
+ headers = update_cached_csp(config.csp, headers, user_agent)
117
176
  end
118
177
 
119
- headers
178
+ if !config.csp_report_only.opt_out? && config.csp_report_only.modified?
179
+ headers = update_cached_csp(config.csp_report_only, headers, user_agent)
180
+ end
181
+
182
+ header_classes_for(request).each_with_object({}) do |klass, hash|
183
+ if header = headers[klass::CONFIG_KEY]
184
+ header_name, value = if klass == ContentSecurityPolicyConfig || klass == ContentSecurityPolicyReportOnlyConfig
185
+ csp_header_for_ua(header, user_agent)
186
+ else
187
+ header
188
+ end
189
+ hash[header_name] = value
190
+ end
191
+ end
120
192
  end
121
193
 
122
194
  # Public: specify which named override will be used for this request.
@@ -137,7 +209,7 @@ module SecureHeaders
137
209
  #
138
210
  # Returns the nonce
139
211
  def content_security_policy_script_nonce(request)
140
- content_security_policy_nonce(request, CSP::SCRIPT_SRC)
212
+ content_security_policy_nonce(request, ContentSecurityPolicy::SCRIPT_SRC)
141
213
  end
142
214
 
143
215
  # Public: gets or creates a nonce for CSP.
@@ -146,17 +218,62 @@ module SecureHeaders
146
218
  #
147
219
  # Returns the nonce
148
220
  def content_security_policy_style_nonce(request)
149
- content_security_policy_nonce(request, CSP::STYLE_SRC)
221
+ content_security_policy_nonce(request, ContentSecurityPolicy::STYLE_SRC)
222
+ end
223
+
224
+ # Public: Retreives the config for a given header type:
225
+ #
226
+ # Checks to see if there is an override for this request, then
227
+ # Checks to see if a named override is used for this request, then
228
+ # Falls back to the global config
229
+ def config_for(request, prevent_dup = false)
230
+ config = request.env[SECURE_HEADERS_CONFIG] ||
231
+ Configuration.get(Configuration::DEFAULT_CONFIG)
232
+
233
+
234
+ # Global configs are frozen, per-request configs are not. When we're not
235
+ # making modifications to the config, prevent_dup ensures we don't dup
236
+ # the object unnecessarily. It's not necessarily frozen to begin with.
237
+ if config.frozen? && !prevent_dup
238
+ config.dup
239
+ else
240
+ config
241
+ end
150
242
  end
151
243
 
152
244
  private
245
+ TARGETS = [:both, :enforced, :report_only]
246
+ def raise_on_unknown_target(target)
247
+ unless TARGETS.include?(target)
248
+ raise "Unrecognized target: #{target}. Must be [:both, :enforced, :report_only]"
249
+ end
250
+ end
251
+
252
+ def config_and_target(request, target)
253
+ config = config_for(request)
254
+ target = guess_target(config) unless target
255
+ raise_on_unknown_target(target)
256
+ [config, target]
257
+ end
258
+
259
+ def guess_target(config)
260
+ if !config.csp.opt_out? && !config.csp_report_only.opt_out?
261
+ :both
262
+ elsif !config.csp.opt_out?
263
+ :enforced
264
+ elsif !config.csp_report_only.opt_out?
265
+ :report_only
266
+ else
267
+ :both
268
+ end
269
+ end
153
270
 
154
271
  # Private: gets or creates a nonce for CSP.
155
272
  #
156
273
  # Returns the nonce
157
274
  def content_security_policy_nonce(request, script_or_style)
158
275
  request.env[NONCE_KEY] ||= SecureRandom.base64(32).chomp
159
- nonce_key = script_or_style == CSP::SCRIPT_SRC ? :script_nonce : :style_nonce
276
+ nonce_key = script_or_style == ContentSecurityPolicy::SCRIPT_SRC ? :script_nonce : :style_nonce
160
277
  append_content_security_policy_directives(request, nonce_key => request.env[NONCE_KEY])
161
278
  request.env[NONCE_KEY]
162
279
  end
@@ -181,50 +298,12 @@ module SecureHeaders
181
298
  end
182
299
  end
183
300
 
184
- # Private: do the heavy lifting of converting a configuration object
185
- # to a hash of headers valid for this request.
186
- #
187
- # Returns a hash of header names / values.
188
- def build_headers(config, request)
189
- header_classes_for(request).each_with_object({}) do |klass, hash|
190
- header_config = if config
191
- config.fetch(klass::CONFIG_KEY)
192
- end
193
-
194
- header_name, value = if klass == CSP
195
- make_header(klass, header_config, request.user_agent)
196
- else
197
- make_header(klass, header_config)
198
- end
199
- hash[header_name] = value if value
200
- end
201
- end
202
-
203
- # Private: takes a precomputed hash of headers and returns the Headers
204
- # customized for the request.
205
- #
206
- # Returns a hash of header names / values valid for a given request.
207
- def use_cached_headers(default_headers, request)
208
- header_classes_for(request).each_with_object({}) do |klass, hash|
209
- if default_header = default_headers[klass::CONFIG_KEY]
210
- header_name, value = if klass == CSP
211
- default_csp_header_for_ua(default_header, request)
212
- else
213
- default_header
214
- end
215
- hash[header_name] = value
216
- end
217
- end
218
- end
219
-
220
- # Private: Retreives the config for a given header type:
221
- #
222
- # Checks to see if there is an override for this request, then
223
- # Checks to see if a named override is used for this request, then
224
- # Falls back to the global config
225
- def config_for(request)
226
- request.env[SECURE_HEADERS_CONFIG] ||
227
- Configuration.get(Configuration::DEFAULT_CONFIG)
301
+ def update_cached_csp(config, headers, user_agent)
302
+ headers = Configuration.send(:deep_copy, headers)
303
+ headers[config.class::CONFIG_KEY] = {}
304
+ variation = ContentSecurityPolicy.ua_to_variation(user_agent)
305
+ headers[config.class::CONFIG_KEY][variation] = ContentSecurityPolicy.make_header(config, user_agent)
306
+ headers
228
307
  end
229
308
 
230
309
  # Private: chooses the applicable CSP header for the provided user agent.
@@ -232,31 +311,8 @@ module SecureHeaders
232
311
  # headers - a hash of header_config_key => [header_name, header_value]
233
312
  #
234
313
  # Returns a CSP [header, value] array
235
- def default_csp_header_for_ua(headers, request)
236
- family = UserAgent.parse(request.user_agent).browser
237
- if CSP::VARIATIONS.key?(family)
238
- headers[family]
239
- else
240
- headers[CSP::OTHER]
241
- end
242
- end
243
-
244
- # Private: optionally build a header with a given configure
245
- #
246
- # klass - corresponding Class for a given header
247
- # config - A string, symbol, or hash config for the header
248
- # user_agent - A string representing the UA (only used for CSP feature sniffing)
249
- #
250
- # Returns a 2 element array [header_name, header_value] or nil if config
251
- # is OPT_OUT
252
- def make_header(klass, header_config, user_agent = nil)
253
- unless header_config == OPT_OUT
254
- if klass == CSP
255
- klass.make_header(header_config, user_agent)
256
- else
257
- klass.make_header(header_config)
258
- end
259
- end
314
+ def csp_header_for_ua(headers, user_agent)
315
+ headers[ContentSecurityPolicy.ua_to_variation(user_agent)]
260
316
  end
261
317
  end
262
318
 
@@ -289,4 +345,8 @@ module SecureHeaders
289
345
  def override_x_frame_options(value)
290
346
  SecureHeaders.override_x_frame_options(request, value)
291
347
  end
348
+
349
+ def use_content_security_policy_named_append(name)
350
+ SecureHeaders.use_content_security_policy_named_append(request, name)
351
+ end
292
352
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+ INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx unless defined? INLINE_SCRIPT_REGEX
3
+ INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx unless defined? INLINE_STYLE_REGEX
4
+ INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_SCRIPT_HELPER_REGEX
5
+ INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_STYLE_HELPER_REGEX
6
+
7
+ namespace :secure_headers do
8
+ include SecureHeaders::HashHelper
9
+
10
+ def is_erb?(filename)
11
+ filename =~ /\.erb\Z/
12
+ end
13
+
14
+ def is_mustache?(filename)
15
+ filename =~ /\.mustache\Z/
16
+ end
17
+
18
+ def dynamic_content?(filename, inline_script)
19
+ (is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
20
+ (is_erb?(filename) && inline_script =~ /<%.*%>/)
21
+ end
22
+
23
+ def find_inline_content(filename, regex, hashes)
24
+ file = File.read(filename)
25
+ file.scan(regex) do # TODO don't use gsub
26
+ inline_script = Regexp.last_match.captures.last
27
+ if dynamic_content?(filename, inline_script)
28
+ puts "Looks like there's some dynamic content inside of a tag :-/"
29
+ puts "That pretty much means the hash value will never match."
30
+ puts "Code: " + inline_script
31
+ puts "=" * 20
32
+ end
33
+
34
+ hashes << hash_source(inline_script)
35
+ end
36
+ end
37
+
38
+ def generate_inline_script_hashes(filename)
39
+ hashes = []
40
+
41
+ [INLINE_SCRIPT_REGEX, INLINE_HASH_SCRIPT_HELPER_REGEX].each do |regex|
42
+ find_inline_content(filename, regex, hashes)
43
+ end
44
+
45
+ hashes
46
+ end
47
+
48
+ def generate_inline_style_hashes(filename)
49
+ hashes = []
50
+
51
+ [INLINE_STYLE_REGEX, INLINE_HASH_STYLE_HELPER_REGEX].each do |regex|
52
+ find_inline_content(filename, regex, hashes)
53
+ end
54
+
55
+ hashes
56
+ end
57
+
58
+ desc "Generate #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
59
+ task :generate_hashes do |t, args|
60
+ script_hashes = {
61
+ "scripts" => {},
62
+ "styles" => {}
63
+ }
64
+
65
+ Dir.glob("app/{views,templates}/**/*.{erb,mustache}") do |filename|
66
+ hashes = generate_inline_script_hashes(filename)
67
+ if hashes.any?
68
+ script_hashes["scripts"][filename] = hashes
69
+ end
70
+
71
+ hashes = generate_inline_style_hashes(filename)
72
+ if hashes.any?
73
+ script_hashes["styles"][filename] = hashes
74
+ end
75
+ end
76
+
77
+ File.open(SecureHeaders::Configuration::HASH_CONFIG_FILE, "w") do |file|
78
+ file.write(script_hashes.to_yaml)
79
+ end
80
+
81
+ puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
82
+ end
83
+ end
@@ -1,10 +1,11 @@
1
1
  # -*- encoding: utf-8 -*-
2
+ # frozen_string_literal: true
2
3
  Gem::Specification.new do |gem|
3
4
  gem.name = "secure_headers"
4
- gem.version = "3.0.3"
5
+ gem.version = "4.0.0"
5
6
  gem.authors = ["Neil Matatall"]
6
7
  gem.email = ["neil.matatall@gmail.com"]
7
- gem.description = 'Security related headers all in one gem.'
8
+ gem.description = "Manages application of security headers with many safe defaults."
8
9
  gem.summary = 'Add easily configured security headers to responses
9
10
  including content-security-policy, x-frame-options,
10
11
  strict-transport-security, etc.'
@@ -15,5 +16,14 @@ Gem::Specification.new do |gem|
15
16
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
16
17
  gem.require_paths = ["lib"]
17
18
  gem.add_development_dependency "rake"
18
- gem.add_dependency "useragent"
19
+ gem.add_dependency "useragent", ">= 0.15.0"
20
+
21
+ # TODO: delete this after 4.1 is cut or a number of 4.0.x releases have occurred
22
+ gem.post_install_message = <<-POST_INSTALL
23
+
24
+ **********
25
+ :wave: secure_headers 4.0 introduces a lot of breaking changes (in the name of security!). It's highly likely you will need to update your secure_headers cookie configuration to avoid breaking things. See the upgrade guide for details: https://github.com/twitter/secureheaders/blob/master/upgrading-to-4-0.md
26
+ **********
27
+
28
+ POST_INSTALL
19
29
  end