secure_headers 3.4.0 → 3.6.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.
@@ -1,37 +1,45 @@
1
1
  require_relative 'policy_management'
2
+ require_relative 'content_security_policy_config'
2
3
  require 'useragent'
3
4
 
4
5
  module SecureHeaders
5
- class ContentSecurityPolicyConfigError < StandardError; end
6
6
  class ContentSecurityPolicy
7
7
  include PolicyManagement
8
8
 
9
9
  # constants to be used for version-specific UA sniffing
10
10
  VERSION_46 = ::UserAgent::Version.new("46")
11
+ VERSION_10 = ::UserAgent::Version.new("10")
12
+ FALLBACK_VERSION = ::UserAgent::Version.new("0")
11
13
 
12
14
  def initialize(config = nil, user_agent = OTHER)
13
- @config = Configuration.send(:deep_copy, config || DEFAULT_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
+
14
27
  @parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
15
28
  user_agent
16
29
  else
17
30
  UserAgent.parse(user_agent)
18
31
  end
19
- normalize_child_frame_src
20
- @report_only = @config[:report_only]
21
- @preserve_schemes = @config[:preserve_schemes]
22
- @script_nonce = @config[:script_nonce]
23
- @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
24
36
  end
25
37
 
26
38
  ##
27
39
  # Returns the name to use for the header. Either "Content-Security-Policy" or
28
40
  # "Content-Security-Policy-Report-Only"
29
41
  def name
30
- if @report_only
31
- REPORT_ONLY
32
- else
33
- HEADER_NAME
34
- end
42
+ @config.class.const_get(:HEADER_NAME)
35
43
  end
36
44
 
37
45
  ##
@@ -49,16 +57,16 @@ module SecureHeaders
49
57
  # frame-src is deprecated, child-src is being implemented. They are
50
58
  # very similar and in most cases, the same value can be used for both.
51
59
  def normalize_child_frame_src
52
- if @config[:frame_src] && @config[:child_src] && @config[:frame_src] != @config[:child_src]
60
+ if @config.frame_src && @config.child_src && @config.frame_src != @config.child_src
53
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.")
54
- elsif @config[:frame_src]
55
- Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config[:frame_src]}.")
62
+ elsif @config.frame_src
63
+ Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config.frame_src}.")
56
64
  end
57
65
 
58
66
  if supported_directives.include?(:child_src)
59
- @config[:child_src] = @config[:child_src] || @config[:frame_src]
67
+ @config.child_src || @config.frame_src
60
68
  else
61
- @config[:frame_src] = @config[:frame_src] || @config[:child_src]
69
+ @config.frame_src || @config.child_src
62
70
  end
63
71
  end
64
72
 
@@ -73,9 +81,9 @@ module SecureHeaders
73
81
  directives.map do |directive_name|
74
82
  case DIRECTIVE_VALUE_TYPES[directive_name]
75
83
  when :boolean
76
- symbol_to_hyphen_case(directive_name) if @config[directive_name]
84
+ symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
77
85
  when :string
78
- [symbol_to_hyphen_case(directive_name), @config[directive_name]].join(" ")
86
+ [symbol_to_hyphen_case(directive_name), @config.directive_value(directive_name)].join(" ")
79
87
  else
80
88
  build_directive(directive_name)
81
89
  end
@@ -88,11 +96,19 @@ module SecureHeaders
88
96
  #
89
97
  # Returns a string representing a directive.
90
98
  def build_directive(directive)
91
- return if @config[directive].nil?
92
-
93
- source_list = @config[directive].compact
94
- return if source_list.empty?
95
-
99
+ source_list = case directive
100
+ when :child_src
101
+ if supported_directives.include?(:child_src)
102
+ @frame_src
103
+ end
104
+ when :frame_src
105
+ unless supported_directives.include?(:child_src)
106
+ @frame_src
107
+ end
108
+ else
109
+ @config.directive_value(directive)
110
+ end
111
+ return unless source_list && source_list.any?
96
112
  normalized_source_list = minify_source_list(directive, source_list)
97
113
  [symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
98
114
  end
@@ -101,16 +117,17 @@ module SecureHeaders
101
117
  # If a directive contains 'none' but has other values, 'none' is ommitted.
102
118
  # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
103
119
  def minify_source_list(directive, source_list)
120
+ source_list = source_list.compact
104
121
  if source_list.include?(STAR)
105
122
  keep_wildcard_sources(source_list)
106
123
  else
107
- populate_nonces!(directive, source_list)
108
- reject_all_values_if_none!(source_list)
124
+ source_list = populate_nonces(directive, source_list)
125
+ source_list = reject_all_values_if_none(source_list)
109
126
 
110
127
  unless directive == REPORT_URI || @preserve_schemes
111
- strip_source_schemes!(source_list)
128
+ source_list = strip_source_schemes(source_list)
112
129
  end
113
- dedup_source_list(source_list).join(" ")
130
+ dedup_source_list(source_list)
114
131
  end
115
132
  end
116
133
 
@@ -120,8 +137,12 @@ module SecureHeaders
120
137
  end
121
138
 
122
139
  # Discard any 'none' values if more directives are supplied since none may override values.
123
- def reject_all_values_if_none!(source_list)
124
- source_list.reject! { |value| value == NONE } if source_list.length > 1
140
+ def reject_all_values_if_none(source_list)
141
+ if source_list.length > 1
142
+ source_list.reject { |value| value == NONE }
143
+ else
144
+ source_list
145
+ end
125
146
  end
126
147
 
127
148
  # Removes duplicates and sources that already match an existing wild card.
@@ -143,12 +164,14 @@ module SecureHeaders
143
164
 
144
165
  # Private: append a nonce to the script/style directories if script_nonce
145
166
  # or style_nonce are provided.
146
- def populate_nonces!(directive, source_list)
167
+ def populate_nonces(directive, source_list)
147
168
  case directive
148
169
  when SCRIPT_SRC
149
170
  append_nonce(source_list, @script_nonce)
150
171
  when STYLE_SRC
151
172
  append_nonce(source_list, @style_nonce)
173
+ else
174
+ source_list
152
175
  end
153
176
  end
154
177
 
@@ -165,19 +188,23 @@ module SecureHeaders
165
188
  source_list << UNSAFE_INLINE
166
189
  end
167
190
  end
191
+
192
+ source_list
168
193
  end
169
194
 
170
195
  # Private: return the list of directives that are supported by the user agent,
171
196
  # starting with default-src and ending with report-uri.
172
197
  def directives
173
- [DEFAULT_SRC,
198
+ [
199
+ DEFAULT_SRC,
174
200
  BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
175
- REPORT_URI].flatten.select { |directive| @config.key?(directive) }
201
+ REPORT_URI
202
+ ].flatten
176
203
  end
177
204
 
178
205
  # Private: Remove scheme from source expressions.
179
- def strip_source_schemes!(source_list)
180
- source_list.map! { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
206
+ def strip_source_schemes(source_list)
207
+ source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
181
208
  end
182
209
 
183
210
  # Private: determine which directives are supported for the given user agent.
@@ -187,7 +214,7 @@ module SecureHeaders
187
214
  # Returns an array of symbols representing the directives.
188
215
  def supported_directives
189
216
  @supported_directives ||= if VARIATIONS[@parsed_ua.browser]
190
- if @parsed_ua.browser == "Firefox" && @parsed_ua.version >= VERSION_46
217
+ if @parsed_ua.browser == "Firefox" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_46)
191
218
  VARIATIONS["FirefoxTransitional"]
192
219
  else
193
220
  VARIATIONS[@parsed_ua.browser]
@@ -198,7 +225,7 @@ module SecureHeaders
198
225
  end
199
226
 
200
227
  def nonces_supported?
201
- @nonces_supported ||= MODERN_BROWSERS.include?(@parsed_ua.browser)
228
+ @nonces_supported ||= self.class.nonces_supported?(@parsed_ua)
202
229
  end
203
230
 
204
231
  def symbol_to_hyphen_case(sym)
@@ -0,0 +1,128 @@
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
+ value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list
10
+ prev_value = self.instance_variable_get("@#{attr}")
11
+ self.instance_variable_set("@#{attr}", value)
12
+ if prev_value != self.instance_variable_get("@#{attr}")
13
+ @modified = true
14
+ end
15
+ else
16
+ raise ContentSecurityPolicyConfigError, "Unknown config directive: #{attr}=#{value}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def initialize(hash)
23
+ from_hash(hash)
24
+ @modified = false
25
+ end
26
+
27
+ def update_directive(directive, value)
28
+ self.send("#{directive}=", value)
29
+ end
30
+
31
+ def directive_value(directive)
32
+ if self.class.attrs.include?(directive)
33
+ self.send(directive)
34
+ end
35
+ end
36
+
37
+ def modified?
38
+ @modified
39
+ end
40
+
41
+ def merge(new_hash)
42
+ ContentSecurityPolicy.combine_policies(self.to_h, new_hash)
43
+ end
44
+
45
+ def merge!(new_hash)
46
+ from_hash(new_hash)
47
+ end
48
+
49
+ def append(new_hash)
50
+ from_hash(ContentSecurityPolicy.combine_policies(self.to_h, new_hash))
51
+ end
52
+
53
+ def to_h
54
+ self.class.attrs.each_with_object({}) do |key, hash|
55
+ hash[key] = self.send(key)
56
+ end.reject { |_, v| v.nil? }
57
+ end
58
+
59
+ def dup
60
+ self.class.new(self.to_h)
61
+ end
62
+
63
+ def opt_out?
64
+ false
65
+ end
66
+
67
+ def ==(o)
68
+ self.class == o.class && self.to_h == o.to_h
69
+ end
70
+
71
+ alias_method :[], :directive_value
72
+ alias_method :[]=, :update_directive
73
+
74
+ private
75
+ def from_hash(hash)
76
+ hash.keys.reject { |k| hash[k].nil? }.map do |k|
77
+ if self.class.attrs.include?(k)
78
+ self.send("#{k}=", hash[k])
79
+ else
80
+ raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{hash[k]}"
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ class ContentSecurityPolicyConfigError < StandardError; end
87
+ class ContentSecurityPolicyConfig
88
+ CONFIG_KEY = :csp
89
+ HEADER_NAME = "Content-Security-Policy".freeze
90
+
91
+ def self.attrs
92
+ PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES
93
+ end
94
+
95
+ include DynamicConfig
96
+
97
+ # based on what was suggested in https://github.com/rails/rails/pull/24961/files
98
+ DEFAULT = {
99
+ default_src: %w('self' https:),
100
+ font_src: %w('self' https: data:),
101
+ img_src: %w('self' https: data:),
102
+ object_src: %w('none'),
103
+ script_src: %w(https:),
104
+ style_src: %w('self' https: 'unsafe-inline')
105
+ }
106
+
107
+ def report_only?
108
+ false
109
+ end
110
+
111
+ def make_report_only
112
+ ContentSecurityPolicyReportOnlyConfig.new(self.to_h)
113
+ end
114
+ end
115
+
116
+ class ContentSecurityPolicyReportOnlyConfig < ContentSecurityPolicyConfig
117
+ CONFIG_KEY = :csp_report_only
118
+ HEADER_NAME = "Content-Security-Policy-Report-Only".freeze
119
+
120
+ def report_only?
121
+ true
122
+ end
123
+
124
+ def make_report_only
125
+ self
126
+ end
127
+ end
128
+ end
@@ -7,9 +7,6 @@ module SecureHeaders
7
7
  MODERN_BROWSERS = %w(Chrome Opera Firefox)
8
8
  DEFAULT_VALUE = "default-src https:".freeze
9
9
  DEFAULT_CONFIG = { default_src: %w(https:) }.freeze
10
- HEADER_NAME = "Content-Security-Policy".freeze
11
- REPORT_ONLY = "Content-Security-Policy-Report-Only".freeze
12
- HEADER_NAMES = [HEADER_NAME, REPORT_ONLY]
13
10
  DATA_PROTOCOL = "data:".freeze
14
11
  BLOB_PROTOCOL = "blob:".freeze
15
12
  SELF = "'self'".freeze
@@ -17,6 +14,7 @@ module SecureHeaders
17
14
  STAR = "*".freeze
18
15
  UNSAFE_INLINE = "'unsafe-inline'".freeze
19
16
  UNSAFE_EVAL = "'unsafe-eval'".freeze
17
+ STRICT_DYNAMIC = "'strict-dynamic'".freeze
20
18
 
21
19
  # leftover deprecated values that will be in common use upon upgrading.
22
20
  DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
@@ -53,16 +51,6 @@ module SecureHeaders
53
51
  FRAME_ANCESTORS = :frame_ancestors
54
52
  PLUGIN_TYPES = :plugin_types
55
53
 
56
- # These are directives that do not inherit the default-src value. This is
57
- # useful when calling #combine_policies.
58
- NON_FETCH_SOURCES = [
59
- BASE_URI,
60
- FORM_ACTION,
61
- FRAME_ANCESTORS,
62
- PLUGIN_TYPES,
63
- REPORT_URI
64
- ]
65
-
66
54
  DIRECTIVES_2_0 = [
67
55
  DIRECTIVES_1_0,
68
56
  BASE_URI,
@@ -127,6 +115,18 @@ module SecureHeaders
127
115
  # everything else is in between.
128
116
  BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
129
117
 
118
+ # These are directives that do not inherit the default-src value. This is
119
+ # useful when calling #combine_policies.
120
+ NON_FETCH_SOURCES = [
121
+ BASE_URI,
122
+ FORM_ACTION,
123
+ FRAME_ANCESTORS,
124
+ PLUGIN_TYPES,
125
+ REPORT_URI
126
+ ]
127
+
128
+ FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES
129
+
130
130
  VARIATIONS = {
131
131
  "Chrome" => CHROME_DIRECTIVES,
132
132
  "Opera" => CHROME_DIRECTIVES,
@@ -156,13 +156,13 @@ module SecureHeaders
156
156
  PLUGIN_TYPES => :source_list,
157
157
  REFLECTED_XSS => :string,
158
158
  REPORT_URI => :source_list,
159
- SANDBOX => :string,
159
+ SANDBOX => :source_list,
160
160
  SCRIPT_SRC => :source_list,
161
161
  STYLE_SRC => :source_list,
162
162
  UPGRADE_INSECURE_REQUESTS => :boolean
163
163
  }.freeze
164
164
 
165
- CONFIG_KEY = :csp
165
+
166
166
  STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
167
167
  HTTP_SCHEME_REGEX = %r{\Ahttps?://}
168
168
 
@@ -179,6 +179,11 @@ module SecureHeaders
179
179
  :preserve_schemes
180
180
  ].freeze
181
181
 
182
+ NONCES = [
183
+ :script_nonce,
184
+ :style_nonce
185
+ ].freeze
186
+
182
187
  module ClassMethods
183
188
  # Public: generate a header name, value array that is user-agent-aware.
184
189
  #
@@ -194,9 +199,11 @@ module SecureHeaders
194
199
  # Does not validate the invididual values of the source expression (e.g.
195
200
  # script_src => h*t*t*p: will not raise an exception)
196
201
  def validate_config!(config)
197
- return if config.nil? || config == OPT_OUT
198
- raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config[:default_src]
199
- config.each do |key, value|
202
+ return if config.nil? || config.opt_out?
203
+ raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config.directive_value(:default_src)
204
+ ContentSecurityPolicyConfig.attrs.each do |key|
205
+ value = config.directive_value(key)
206
+ next unless value
200
207
  if META_CONFIGS.include?(key)
201
208
  raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
202
209
  else
@@ -205,16 +212,13 @@ module SecureHeaders
205
212
  end
206
213
  end
207
214
 
208
- # Public: determine if merging +additions+ will cause a change to the
209
- # actual value of the config.
215
+ # Public: check if a user agent supports CSP nonces
210
216
  #
211
- # e.g. config = { script_src: %w(example.org google.com)} and
212
- # additions = { script_src: %w(google.com)} then idempotent_additions? would return
213
- # because google.com is already in the config.
214
- def idempotent_additions?(config, additions)
215
- return true if config == OPT_OUT && additions == OPT_OUT
216
- return false if config == OPT_OUT
217
- config == combine_policies(config, additions)
217
+ # user_agent - a String or a UserAgent object
218
+ def nonces_supported?(user_agent)
219
+ user_agent = UserAgent.parse(user_agent) if user_agent.is_a?(String)
220
+ MODERN_BROWSERS.include?(user_agent.browser) ||
221
+ user_agent.browser == "Safari" && (user_agent.version || CSP::FALLBACK_VERSION) >= CSP::VERSION_10
218
222
  end
219
223
 
220
224
  # Public: combine the values from two different configs.
@@ -231,7 +235,7 @@ module SecureHeaders
231
235
  # 3. if a value in additions does exist in the original config, the two
232
236
  # values are joined.
233
237
  def combine_policies(original, additions)
234
- if original == OPT_OUT
238
+ if original == {}
235
239
  raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
236
240
  end
237
241
 
@@ -268,8 +272,23 @@ module SecureHeaders
268
272
  def populate_fetch_source_with_default!(original, additions)
269
273
  # in case we would be appending to an empty directive, fill it with the default-src value
270
274
  additions.keys.each do |directive|
271
- unless original[directive] || !source_list?(directive) || NON_FETCH_SOURCES.include?(directive)
272
- original[directive] = original[:default_src]
275
+ if !original[directive] && ((source_list?(directive) && FETCH_SOURCES.include?(directive)) || nonce_added?(original, additions))
276
+ if nonce_added?(original, additions)
277
+ inferred_directive = directive.to_s.gsub(/_nonce/, "_src").to_sym
278
+ unless original[inferred_directive] || NON_FETCH_SOURCES.include?(inferred_directive)
279
+ original[inferred_directive] = original[:default_src]
280
+ end
281
+ else
282
+ original[directive] = original[:default_src]
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ def nonce_added?(original, additions)
289
+ [:script_nonce, :style_nonce].each do |nonce|
290
+ if additions[nonce] && !original[nonce]
291
+ return true
273
292
  end
274
293
  end
275
294
  end
@@ -6,6 +6,9 @@ module SecureHeaders
6
6
  VALID_POLICIES = %w(
7
7
  no-referrer
8
8
  no-referrer-when-downgrade
9
+ same-origin
10
+ strict-origin
11
+ strict-origin-when-cross-origin
9
12
  origin
10
13
  origin-when-cross-origin
11
14
  unsafe-url
@@ -34,6 +34,14 @@ module SecureHeaders
34
34
  end
35
35
  end
36
36
 
37
+ def content_security_policy_script_nonce
38
+ content_security_policy_nonce(:script)
39
+ end
40
+
41
+ def content_security_policy_style_nonce
42
+ content_security_policy_nonce(:style)
43
+ end
44
+
37
45
  ##
38
46
  # Checks to see if the hashed code is expected and adds the hash source
39
47
  # value to the current CSP.