secure_headers 3.2.0 → 3.8.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 (53) hide show
  1. checksums.yaml +5 -5
  2. data/.github/ISSUE_TEMPLATE.md +41 -0
  3. data/.github/PULL_REQUEST_TEMPLATE.md +20 -0
  4. data/.rspec +1 -0
  5. data/.ruby-version +1 -1
  6. data/.travis.yml +2 -0
  7. data/CHANGELOG.md +160 -1
  8. data/CODE_OF_CONDUCT.md +46 -0
  9. data/CONTRIBUTING.md +41 -0
  10. data/Gemfile +3 -1
  11. data/LICENSE +4 -199
  12. data/README.md +75 -334
  13. data/docs/HPKP.md +17 -0
  14. data/docs/cookies.md +51 -0
  15. data/docs/hashes.md +64 -0
  16. data/docs/named_overrides_and_appends.md +107 -0
  17. data/docs/per_action_configuration.md +105 -0
  18. data/docs/sinatra.md +25 -0
  19. data/lib/secure_headers/configuration.rb +120 -39
  20. data/lib/secure_headers/headers/clear_site_data.rb +54 -0
  21. data/lib/secure_headers/headers/content_security_policy.rb +134 -36
  22. data/lib/secure_headers/headers/content_security_policy_config.rb +162 -0
  23. data/lib/secure_headers/headers/cookie.rb +7 -1
  24. data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
  25. data/lib/secure_headers/headers/policy_management.rb +150 -66
  26. data/lib/secure_headers/headers/public_key_pins.rb +1 -1
  27. data/lib/secure_headers/headers/referrer_policy.rb +36 -0
  28. data/lib/secure_headers/headers/strict_transport_security.rb +1 -1
  29. data/lib/secure_headers/headers/x_content_type_options.rb +1 -1
  30. data/lib/secure_headers/headers/x_download_options.rb +1 -1
  31. data/lib/secure_headers/headers/x_frame_options.rb +1 -1
  32. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +1 -1
  33. data/lib/secure_headers/headers/x_xss_protection.rb +1 -1
  34. data/lib/secure_headers/middleware.rb +6 -0
  35. data/lib/secure_headers/railtie.rb +1 -1
  36. data/lib/secure_headers/utils/cookies_config.rb +6 -4
  37. data/lib/secure_headers/view_helper.rb +13 -7
  38. data/lib/secure_headers.rb +138 -55
  39. data/lib/tasks/tasks.rake +5 -4
  40. data/secure_headers.gemspec +2 -2
  41. data/spec/lib/secure_headers/configuration_spec.rb +5 -5
  42. data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +86 -0
  43. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +96 -13
  44. data/spec/lib/secure_headers/headers/cookie_spec.rb +22 -25
  45. data/spec/lib/secure_headers/headers/expect_certificate_transparency_spec.rb +42 -0
  46. data/spec/lib/secure_headers/headers/policy_management_spec.rb +59 -36
  47. data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +72 -0
  48. data/spec/lib/secure_headers/middleware_spec.rb +31 -1
  49. data/spec/lib/secure_headers/view_helpers_spec.rb +19 -7
  50. data/spec/lib/secure_headers_spec.rb +344 -44
  51. data/spec/spec_helper.rb +8 -3
  52. data/upgrading-to-3-0.md +13 -10
  53. metadata +24 -5
@@ -1,33 +1,45 @@
1
1
  require_relative 'policy_management'
2
+ require_relative 'content_security_policy_config'
3
+ require 'useragent'
2
4
 
3
5
  module SecureHeaders
4
- class ContentSecurityPolicyConfigError < StandardError; end
5
6
  class ContentSecurityPolicy
6
7
  include PolicyManagement
7
8
 
9
+ # constants to be used for version-specific UA sniffing
10
+ VERSION_46 = ::UserAgent::Version.new("46")
11
+ VERSION_10 = ::UserAgent::Version.new("10")
12
+ FALLBACK_VERSION = ::UserAgent::Version.new("0")
13
+
8
14
  def initialize(config = nil, user_agent = OTHER)
9
- config = Configuration.deep_copy(DEFAULT_CONFIG) unless config
10
- @config = config
15
+ @config = if config.is_a?(Hash)
16
+ if config[:report_only]
17
+ ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
18
+ else
19
+ ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG)
20
+ end
21
+ elsif config.nil?
22
+ ContentSecurityPolicyConfig.new(DEFAULT_CONFIG)
23
+ else
24
+ config
25
+ end
26
+
11
27
  @parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
12
28
  user_agent
13
29
  else
14
30
  UserAgent.parse(user_agent)
15
31
  end
16
- @report_only = @config[:report_only]
17
- @preserve_schemes = @config[:preserve_schemes]
18
- @script_nonce = @config[:script_nonce]
19
- @style_nonce = @config[:style_nonce]
32
+ @frame_src = normalize_child_frame_src
33
+ @preserve_schemes = @config.preserve_schemes
34
+ @script_nonce = @config.script_nonce
35
+ @style_nonce = @config.style_nonce
20
36
  end
21
37
 
22
38
  ##
23
39
  # Returns the name to use for the header. Either "Content-Security-Policy" or
24
40
  # "Content-Security-Policy-Report-Only"
25
41
  def name
26
- if @report_only
27
- REPORT_ONLY
28
- else
29
- HEADER_NAME
30
- end
42
+ @config.class.const_get(:HEADER_NAME)
31
43
  end
32
44
 
33
45
  ##
@@ -42,6 +54,20 @@ module SecureHeaders
42
54
 
43
55
  private
44
56
 
57
+ # frame-src is deprecated, child-src is being implemented. They are
58
+ # very similar and in most cases, the same value can be used for both.
59
+ def normalize_child_frame_src
60
+ if @config.frame_src && @config.child_src && @config.frame_src != @config.child_src
61
+ Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] both :child_src and :frame_src supplied and do not match. This can lead to inconsistent behavior across browsers.")
62
+ end
63
+
64
+ if supported_directives.include?(:child_src)
65
+ @config.child_src || @config.frame_src
66
+ else
67
+ @config.frame_src || @config.child_src
68
+ end
69
+ end
70
+
45
71
  # Private: converts the config object into a string representing a policy.
46
72
  # Places default-src at the first directive and report-uri as the last. All
47
73
  # others are presented in alphabetical order.
@@ -53,44 +79,94 @@ module SecureHeaders
53
79
  directives.map do |directive_name|
54
80
  case DIRECTIVE_VALUE_TYPES[directive_name]
55
81
  when :boolean
56
- symbol_to_hyphen_case(directive_name)
57
- when :string
58
- [symbol_to_hyphen_case(directive_name), @config[directive_name]].join(" ")
59
- else
60
- build_directive(directive_name)
82
+ symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
83
+ when :sandbox_list
84
+ build_sandbox_list_directive(directive_name)
85
+ when :media_type_list
86
+ build_media_type_list_directive(directive_name)
87
+ when :source_list
88
+ build_source_list_directive(directive_name)
61
89
  end
62
90
  end.compact.join("; ")
63
91
  end
64
92
 
93
+ def build_sandbox_list_directive(directive)
94
+ return unless sandbox_list = @config.directive_value(directive)
95
+ max_strict_policy = case sandbox_list
96
+ when Array
97
+ sandbox_list.empty?
98
+ when true
99
+ true
100
+ else
101
+ false
102
+ end
103
+
104
+ # A maximally strict sandbox policy is just the `sandbox` directive,
105
+ # whith no configuraiton values.
106
+ if max_strict_policy
107
+ symbol_to_hyphen_case(directive)
108
+ elsif sandbox_list && sandbox_list.any?
109
+ [
110
+ symbol_to_hyphen_case(directive),
111
+ sandbox_list.uniq
112
+ ].join(" ")
113
+ end
114
+ end
115
+
116
+ def build_media_type_list_directive(directive)
117
+ return unless media_type_list = @config.directive_value(directive)
118
+ if media_type_list && media_type_list.any?
119
+ [
120
+ symbol_to_hyphen_case(directive),
121
+ media_type_list.uniq
122
+ ].join(" ")
123
+ end
124
+ end
125
+
65
126
  # Private: builds a string that represents one directive in a minified form.
66
127
  #
67
128
  # directive_name - a symbol representing the various ALL_DIRECTIVES
68
129
  #
69
130
  # Returns a string representing a directive.
70
- def build_directive(directive)
71
- return if @config[directive].nil?
131
+ def build_source_list_directive(directive)
132
+ source_list = case directive
133
+ when :child_src
134
+ if supported_directives.include?(:child_src)
135
+ @frame_src
136
+ end
137
+ when :frame_src
138
+ unless supported_directives.include?(:child_src)
139
+ @frame_src
140
+ end
141
+ else
142
+ @config.directive_value(directive)
143
+ end
144
+ return unless source_list && source_list.any?
145
+ normalized_source_list = minify_source_list(directive, source_list).join(" ")
72
146
 
73
- source_list = @config[directive].compact
74
- return if source_list.empty?
147
+ if normalized_source_list.include?(";")
148
+ Kernel.warn("#{directive} contains a ; in '#{normalized_source_list}' which will raise an error in future versions. It has been replaced with a blank space.")
149
+ end
150
+ escaped_source_list = normalized_source_list.gsub(";", " ")
75
151
 
76
- normalized_source_list = minify_source_list(directive, source_list)
77
- [symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
152
+ [symbol_to_hyphen_case(directive), escaped_source_list].join(" ").strip
78
153
  end
79
154
 
80
155
  # If a directive contains *, all other values are omitted.
81
156
  # If a directive contains 'none' but has other values, 'none' is ommitted.
82
157
  # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
83
158
  def minify_source_list(directive, source_list)
159
+ source_list = source_list.compact
84
160
  if source_list.include?(STAR)
85
161
  keep_wildcard_sources(source_list)
86
162
  else
87
- populate_nonces!(directive, source_list)
88
- reject_all_values_if_none!(source_list)
163
+ source_list = populate_nonces(directive, source_list)
164
+ source_list = reject_all_values_if_none(source_list)
89
165
 
90
166
  unless directive == REPORT_URI || @preserve_schemes
91
- strip_source_schemes!(source_list)
167
+ source_list = strip_source_schemes(source_list)
92
168
  end
93
- dedup_source_list(source_list).join(" ")
169
+ dedup_source_list(source_list)
94
170
  end
95
171
  end
96
172
 
@@ -100,8 +176,12 @@ module SecureHeaders
100
176
  end
101
177
 
102
178
  # Discard any 'none' values if more directives are supplied since none may override values.
103
- def reject_all_values_if_none!(source_list)
104
- source_list.reject! { |value| value == NONE } if source_list.length > 1
179
+ def reject_all_values_if_none(source_list)
180
+ if source_list.length > 1
181
+ source_list.reject { |value| value == NONE }
182
+ else
183
+ source_list
184
+ end
105
185
  end
106
186
 
107
187
  # Removes duplicates and sources that already match an existing wild card.
@@ -123,12 +203,14 @@ module SecureHeaders
123
203
 
124
204
  # Private: append a nonce to the script/style directories if script_nonce
125
205
  # or style_nonce are provided.
126
- def populate_nonces!(directive, source_list)
206
+ def populate_nonces(directive, source_list)
127
207
  case directive
128
208
  when SCRIPT_SRC
129
209
  append_nonce(source_list, @script_nonce)
130
210
  when STYLE_SRC
131
211
  append_nonce(source_list, @style_nonce)
212
+ else
213
+ source_list
132
214
  end
133
215
  end
134
216
 
@@ -145,30 +227,46 @@ module SecureHeaders
145
227
  source_list << UNSAFE_INLINE
146
228
  end
147
229
  end
230
+
231
+ source_list
148
232
  end
149
233
 
150
234
  # Private: return the list of directives that are supported by the user agent,
151
235
  # starting with default-src and ending with report-uri.
152
236
  def directives
153
- [DEFAULT_SRC,
237
+ [
238
+ DEFAULT_SRC,
154
239
  BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
155
- REPORT_URI].flatten.select { |directive| @config.key?(directive) }
240
+ REPORT_URI
241
+ ].flatten
156
242
  end
157
243
 
158
244
  # Private: Remove scheme from source expressions.
159
- def strip_source_schemes!(source_list)
160
- source_list.map! { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
245
+ def strip_source_schemes(source_list)
246
+ source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
161
247
  end
162
248
 
163
249
  # Private: determine which directives are supported for the given user agent.
164
250
  #
251
+ # Add UA-sniffing special casing here.
252
+ #
165
253
  # Returns an array of symbols representing the directives.
166
254
  def supported_directives
167
- @supported_directives ||= VARIATIONS[@parsed_ua.browser] || VARIATIONS[OTHER]
255
+ @supported_directives ||= if VARIATIONS[@parsed_ua.browser]
256
+ if @parsed_ua.browser == "Firefox" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_46)
257
+ VARIATIONS["FirefoxTransitional"]
258
+ elsif @parsed_ua.browser == "Safari" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_10)
259
+ VARIATIONS["SafariTransitional"]
260
+ else
261
+ VARIATIONS[@parsed_ua.browser]
262
+ end
263
+ else
264
+ VARIATIONS[OTHER]
265
+ end
168
266
  end
169
267
 
170
268
  def nonces_supported?
171
- @nonces_supported ||= MODERN_BROWSERS.include?(@parsed_ua.browser)
269
+ @nonces_supported ||= self.class.nonces_supported?(@parsed_ua)
172
270
  end
173
271
 
174
272
  def symbol_to_hyphen_case(sym)
@@ -0,0 +1,162 @@
1
+ module SecureHeaders
2
+ module DynamicConfig
3
+ def self.included(base)
4
+ base.send(:attr_writer, :modified)
5
+ base.send(:attr_reader, *base.attrs)
6
+ base.attrs.each do |attr|
7
+ base.send(:define_method, "#{attr}=") do |value|
8
+ if self.class.attrs.include?(attr)
9
+ write_attribute(attr, value)
10
+ else
11
+ raise ContentSecurityPolicyConfigError, "Unknown config directive: #{attr}=#{value}"
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ def initialize(hash)
18
+ @base_uri = nil
19
+ @block_all_mixed_content = nil
20
+ @child_src = nil
21
+ @connect_src = nil
22
+ @default_src = nil
23
+ @font_src = nil
24
+ @form_action = nil
25
+ @frame_ancestors = nil
26
+ @frame_src = nil
27
+ @img_src = nil
28
+ @manifest_src = nil
29
+ @media_src = nil
30
+ @object_src = nil
31
+ @plugin_types = nil
32
+ @preserve_schemes = nil
33
+ @report_only = nil
34
+ @report_uri = nil
35
+ @sandbox = nil
36
+ @script_nonce = nil
37
+ @script_src = nil
38
+ @style_nonce = nil
39
+ @style_src = nil
40
+ @worker_src = nil
41
+ @upgrade_insecure_requests = nil
42
+
43
+ from_hash(hash)
44
+ @modified = false
45
+ end
46
+
47
+ def update_directive(directive, value)
48
+ self.send("#{directive}=", value)
49
+ end
50
+
51
+ def directive_value(directive)
52
+ if self.class.attrs.include?(directive)
53
+ self.send(directive)
54
+ end
55
+ end
56
+
57
+ def modified?
58
+ @modified
59
+ end
60
+
61
+ def merge(new_hash)
62
+ ContentSecurityPolicy.combine_policies(self.to_h, new_hash)
63
+ end
64
+
65
+ def merge!(new_hash)
66
+ from_hash(new_hash)
67
+ end
68
+
69
+ def append(new_hash)
70
+ from_hash(ContentSecurityPolicy.combine_policies(self.to_h, new_hash))
71
+ end
72
+
73
+ def to_h
74
+ self.class.attrs.each_with_object({}) do |key, hash|
75
+ value = self.send(key)
76
+ hash[key] = value unless value.nil?
77
+ end
78
+ end
79
+
80
+ def dup
81
+ self.class.new(self.to_h)
82
+ end
83
+
84
+ def opt_out?
85
+ false
86
+ end
87
+
88
+ def ==(o)
89
+ self.class == o.class && self.to_h == o.to_h
90
+ end
91
+
92
+ alias_method :[], :directive_value
93
+ alias_method :[]=, :update_directive
94
+
95
+ private
96
+ def from_hash(hash)
97
+ hash.each_pair do |k, v|
98
+ next if v.nil?
99
+
100
+ if self.class.attrs.include?(k)
101
+ write_attribute(k, v)
102
+ else
103
+ raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{v}"
104
+ end
105
+ end
106
+ end
107
+
108
+ def write_attribute(attr, value)
109
+ value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list
110
+ attr_variable = "@#{attr}"
111
+ prev_value = self.instance_variable_get(attr_variable)
112
+ self.instance_variable_set(attr_variable, value)
113
+ if prev_value != value
114
+ @modified = true
115
+ end
116
+ end
117
+ end
118
+
119
+ class ContentSecurityPolicyConfigError < StandardError; end
120
+ class ContentSecurityPolicyConfig
121
+ CONFIG_KEY = :csp
122
+ HEADER_NAME = "Content-Security-Policy".freeze
123
+
124
+ ATTRS = PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES
125
+ def self.attrs
126
+ ATTRS
127
+ end
128
+
129
+ include DynamicConfig
130
+
131
+ # based on what was suggested in https://github.com/rails/rails/pull/24961/files
132
+ DEFAULT = {
133
+ default_src: %w('self' https:),
134
+ font_src: %w('self' https: data:),
135
+ img_src: %w('self' https: data:),
136
+ object_src: %w('none'),
137
+ script_src: %w(https:),
138
+ style_src: %w('self' https: 'unsafe-inline')
139
+ }
140
+
141
+ def report_only?
142
+ false
143
+ end
144
+
145
+ def make_report_only
146
+ ContentSecurityPolicyReportOnlyConfig.new(self.to_h)
147
+ end
148
+ end
149
+
150
+ class ContentSecurityPolicyReportOnlyConfig < ContentSecurityPolicyConfig
151
+ CONFIG_KEY = :csp_report_only
152
+ HEADER_NAME = "Content-Security-Policy-Report-Only".freeze
153
+
154
+ def report_only?
155
+ true
156
+ end
157
+
158
+ def make_report_only
159
+ self
160
+ end
161
+ end
162
+ end
@@ -81,11 +81,13 @@ module SecureHeaders
81
81
  "SameSite=Lax"
82
82
  elsif flag_samesite_strict?
83
83
  "SameSite=Strict"
84
+ elsif flag_samesite_none?
85
+ "SameSite=None"
84
86
  end
85
87
  end
86
88
 
87
89
  def flag_samesite?
88
- flag_samesite_lax? || flag_samesite_strict?
90
+ flag_samesite_lax? || flag_samesite_strict? || flag_samesite_none?
89
91
  end
90
92
 
91
93
  def flag_samesite_lax?
@@ -96,6 +98,10 @@ module SecureHeaders
96
98
  flag_samesite_enforcement?(:strict)
97
99
  end
98
100
 
101
+ def flag_samesite_none?
102
+ flag_samesite_enforcement?(:none)
103
+ end
104
+
99
105
  def flag_samesite_enforcement?(mode)
100
106
  return unless config[:samesite]
101
107
 
@@ -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
+ header_value = [
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